#!/usr/bin/env bash # # gKeyMapper - everything that touches po/*.po, po/gkeymapper.pot and the compiled # catalogs, in one place, so a translation change is a command instead of a list of # steps to remember. The rules behind it are written down in po/README.md. # # Usage: scripts/i18n.sh [arguments] # extract regenerate po/gkeymapper.pot from the sources # update [lang...] merge the pot into the catalogs: new strings come in # untranslated, existing translations are kept # check validate the pot, the catalogs and the language list # status per-language overview (counts + compiled catalog) # todo what is still untranslated or fuzzy, with the source # locations from the pot # new [name] create po/.po from the pot (name = Language-Team, # e.g. "Italiano"); "new en" writes the identity catalog # help this text # # Only gettext tools and coreutils are used. The tools can be overridden from the # environment, like the Makefile's variables: XGETTEXT=... scripts/i18n.sh extract set -euo pipefail ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) # Everything is addressed relative to the repository root and run from there: the # source locations xgettext writes into the pot ("src/main.cpp:582") and the file # names the gettext tools print are relative to the current directory, and absolute # paths would end up in the pot and in every message this script shows. cd -- "$ROOT" || exit 1 PO_DIR="po" POT="$PO_DIR/gkeymapper.pot" LOCALE_DIR="locale" PACKAGE="gkeymapper" # What the catalogs carry in Project-Id-Version (see po/*.po). PO_VERSION="0.1" XGETTEXT=${XGETTEXT:-xgettext} MSGFMT=${MSGFMT:-msgfmt} MSGMERGE=${MSGMERGE:-msgmerge} MSGATTRIB=${MSGATTRIB:-msgattrib} MSGCAT=${MSGCAT:-msgcat} # The counts and message lists below are parsed, so the tools have to answer in # English, whatever the session's language is. export LC_ALL=C # A temp file for a pot, removed on every exit - also on the error paths, where it # is the half-written file that must not stay behind. POT_TMP="" trap 'rm -f "${POT_TMP:-}"' EXIT # --------------------------------------------------------------------------- # helpers # --------------------------------------------------------------------------- die() { printf 'i18n: %s\n' "$*" >&2 exit 1 } note() { printf '%s\n' "$*" } require_tools() { local tool for tool in "$@"; do command -v "$tool" >/dev/null 2>&1 || die "$tool not found - install gettext (see README, Requirements)" done } # Every source that may hold translatable strings: the GUI, which is the only # program with gettext set up (src/main.cpp) - the same two directories the Makefile # compiles into it. The daemon is English on purpose (see `check`), so its sources # are not scanned. The pot is generated from this list, in this order, so `extract` # is reproducible: a hand-ordered file list once made the pot's message order depend # on the machine it ran on. sources() { local file for file in "$ROOT"/src/*.cpp "$ROOT"/shared/*.cpp; do if [ -e "$file" ]; then printf '%s\n' "${file#"$ROOT"/}"; fi done } # The languages that have a catalog, i.e. po/*.po - the same list the Makefile # compiles and installs. languages() { local file for file in "$PO_DIR"/*.po; do if [ -e "$file" ]; then basename "$file" .po; fi done | sort } # The messages of a po/pot, one per line and multi-line strings joined: msgcat does # the joining, so `_("a" "b")` in the sources and the pot entry are the same string. # The header (empty msgid) is left out. msgids() { "$MSGCAT" --stringtable-output "$1" 2>/dev/null | sed -n 's/^"\(.*\)" = .*$/\1/p' | sed '/^$/d' } # translated/fuzzy/untranslated of one catalog, into three globals. msgfmt prints # them as a comma separated list ("81 translated messages, 1 untranslated # message.") and leaves the parts out that are zero, so the list is split first - # a pattern like ".*\([0-9]\+\) untranslated" would stop at the last digit of a # count and read 82 as 2. stats() { local out parts out=$("$MSGFMT" --statistics -o /dev/null "$1" 2>&1) || die "$MSGFMT rejected $1" parts=$(printf '%s\n' "$out" | tr ',' '\n') translated=$(printf '%s\n' "$parts" | sed -n 's/^ *\([0-9]\+\) translated.*/\1/p'); : "${translated:=0}" fuzzy=$(printf '%s\n' "$parts" | sed -n 's/^ *\([0-9]\+\) fuzzy.*/\1/p'); : "${fuzzy:=0}" untranslated=$(printf '%s\n' "$parts" | sed -n 's/^ *\([0-9]\+\) untranslated.*/\1/p'); : "${untranslated:=0}" } # The `Language:` header of a catalog, so a copy-pasted file is noticed. po_language() { sed -n 's/^"Language: \(.*\)\\n"$/\1/p' "$1" } # The POT-Creation-Date of a po/pot. It does not decide whether a file is up to # date (the message set does); it tells whether a merge happened after the last # extract. pot_date() { sed -n 's/^"POT-Creation-Date: \(.*\)\\n"$/\1/p' "$1" } # One line per entry whose msgstr is not the msgid again - the rule for po/en.po, # which is an identity catalog because English is the source language. Compared as # raw (still escaped) text: both sides come from the same file, so the escaping # matches. Plurals need more than this and are not used anywhere (no ngettext). identity_mismatches() { awk ' function quoted(line) { sub(/^[^"]*"/, "", line); sub(/"$/, "", line); return line } function check() { if (id != "" && str != id) { print id; mismatches++ } } /^msgid_plural|^msgstr\[/ { print "plural entries are not supported"; exit 2 } /^msgid / { check(); id = quoted($0); str = ""; in_id = 1; next } /^msgstr / { str = quoted($0); in_id = 0; next } /^"/ { if (in_id) id = id quoted($0); else str = str quoted($0); next } /^#/ { next } /^[[:space:]]*$/ { next } END { check(); exit (mismatches > 0 ? 1 : 0) } ' "$1" } # The pot as xgettext writes it right now, into the given file. `_()` marks # strings that are translated when they are used, `N_()` the ones that are only # collected (the help text is assembled later on); both are named explicitly, # because a gettext that does not know `_` by itself is not unheard of. extract_to() { local out="$1" file require_tools "$XGETTEXT" local -a files=() while IFS= read -r file; do files+=("$file"); done < <(sources) [ "${#files[@]}" -gt 0 ] || die "no sources found under src/ or shared/" "$XGETTEXT" --from-code=UTF-8 --language=C++ --keyword=_ --keyword=N_ \ --output="$out" "${files[@]}" || die "$XGETTEXT failed" # Nothing or header-only means the extraction is broken, never that the program # has no strings. Checked before the pot is replaced, so a broken gettext # cannot empty a good pot. [ -s "$out" ] || die "$XGETTEXT produced no output (is gettext working? $XGETTEXT --version)" [ "$(grep -c '^msgid "' "$out")" -gt 1 ] || die "$XGETTEXT extracted no message - are the keywords right?" } # The pot without its header entry and without source locations: the entries of a # fresh catalog. With identity=1 every msgstr repeats its msgid (po/en.po). pot_body() { awk -v identity="$2" ' NR == 1 { skipping = 1 } skipping { if ($0 ~ /^$/) skipping = 0; next } /^#:/ { next } /^msgid / { if (identity) { buffer = $0; sub(/^msgid/, "msgstr", buffer); collecting = 1 } print; next } /^"/ { if (collecting) buffer = buffer "\n" $0; print; next } /^msgstr ""$/ { if (collecting) { print buffer; collecting = 0; next } print; next } { print } ' "$1" } # The language codes the GUI offers (src/optionsdialog.cpp, without the leading # "" for "follow the session"). A catalog that is not in this list cannot be # picked; a code without a catalog is a window that stays English. gui_language_codes() { sed -n 's/.*kLanguageCodes\[\] *= *{\(.*\)};.*/\1/p' "src/optionsdialog.cpp" | tr ',' '\n' | tr -d ' "' | sed '/^$/d' } # The labels of that dropdown: one more than the codes, the first being # "System language". gui_language_labels() { sed -n '/languageOptions\[\] *= *{/,/nullptr/p' "src/optionsdialog.cpp" | sed 's/.*{//; s/nullptr.*//' | tr ',' '\n' | grep -c '[^[:space:]]' } # --------------------------------------------------------------------------- # commands # --------------------------------------------------------------------------- cmd_extract() { POT_TMP=$(mktemp "${TMPDIR:-/tmp}/$PACKAGE-pot.XXXXXX") extract_to "$POT_TMP" if [ -f "$POT" ]; then local added gone added=$(comm -13 <(msgids "$POT" | sort) <(msgids "$POT_TMP" | sort)) gone=$(comm -23 <(msgids "$POT" | sort) <(msgids "$POT_TMP" | sort)) note "po/gkeymapper.pot: $(msgids "$POT_TMP" | wc -l) messages" [ -z "$added" ] || { note "new:"; printf '%s\n' "$added" | sed 's/^/ /'; } [ -z "$gone" ] || { note "gone:"; printf '%s\n' "$gone" | sed 's/^/ /'; } else note "po/gkeymapper.pot: $(msgids "$POT_TMP" | wc -l) messages" fi mv "$POT_TMP" "$POT" POT_TMP="" # moved, nothing to clean up note "wrote po/gkeymapper.pot - now: scripts/i18n.sh update" } cmd_update() { local lang po require_tools "$MSGMERGE" "$MSGFMT" [ -f "$POT" ] || die "$POT is missing - run: scripts/i18n.sh extract" local -a targets if [ "$#" -gt 0 ]; then targets=("$@"); else mapfile -t targets < <(languages); fi [ "${#targets[@]}" -gt 0 ] || die "no po/*.po to update" for lang in "${targets[@]}"; do po="$PO_DIR/$lang.po" [ -f "$po" ] || die "$po does not exist (a new language: scripts/i18n.sh new $lang)" # --no-location: the catalogs carry no "#: file:line" comments on purpose - # they are noise for translators and churn whenever a line moves. The # locations live in the pot, and `todo` shows them from there. "$MSGMERGE" --quiet --update --no-location "$po" "$POT" # msgmerge keeps the previous file as ~; a backup in git is noise. rm -f "$po~" stats "$po" note "po/$lang.po: $translated translated, $fuzzy fuzzy, $untranslated untranslated" done } cmd_check() { local lang po code out failures=0 stale_dates="" require_tools "$MSGFMT" "$MSGATTRIB" "$MSGCAT" "$MSGMERGE" "$XGETTEXT" [ -f "$POT" ] || die "$POT is missing" local langs langs=$(languages) [ -n "$langs" ] || die "no po/*.po found" # 1. Is the pot what the sources say? A message that was added or changed # without touching the pot is what this catches - the source locations only # drift when a line moves, which is worth a note, not a failure. POT_TMP=$(mktemp "${TMPDIR:-/tmp}/$PACKAGE-pot.XXXXXX") extract_to "$POT_TMP" local added gone added=$(comm -13 <(msgids "$POT" | sort) <(msgids "$POT_TMP" | sort)) gone=$(comm -23 <(msgids "$POT" | sort) <(msgids "$POT_TMP" | sort)) if [ -n "$added" ] || [ -n "$gone" ]; then note "po/gkeymapper.pot is out of date:" [ -z "$added" ] || { note " in the sources, not in the pot:"; printf '%s\n' "$added" | sed 's/^/ /'; } [ -z "$gone" ] || { note " in the pot, not in the sources:"; printf '%s\n' "$gone" | sed 's/^/ /'; } note " fix: scripts/i18n.sh extract && scripts/i18n.sh update" failures=$((failures + 1)) elif ! diff -q <(grep -v '^"POT-Creation-Date:' "$POT_TMP") \ <(grep -v '^"POT-Creation-Date:' "$POT") >/dev/null; then note "po/gkeymapper.pot has the same messages, but an older extract (line numbers, order)" note " only the locations in 'todo' are affected - scripts/i18n.sh extract refreshes them" fi # 2. Every catalog: parsable, complete, translated, and named what it says. for lang in $langs; do po="$PO_DIR/$lang.po" # -c checks the format strings (%s, %d, ...) and the entry structure. if ! out=$("$MSGFMT" -c -o /dev/null "$po" 2>&1); then note "$lang: $MSGFMT -c failed:" printf '%s\n' "$out" | sed 's/^/ /' failures=$((failures + 1)) fi # Does the catalog define the same messages as the pot? Compared as two # lists rather than with msgcmp, which would also report every untranslated # entry and drown the real problem (the counts below say what is missing). local missing extra missing=$(comm -23 <(msgids "$POT" | sort) <(msgids "$po" | sort)) extra=$(comm -13 <(msgids "$POT" | sort) <(msgids "$po" | sort)) if [ -n "$missing" ]; then note "$lang: $po does not define:" printf '%s\n' "$missing" | sed 's/^/ /' failures=$((failures + 1)) fi if [ -n "$extra" ]; then note "$lang: $po defines messages the pot does not know:" printf '%s\n' "$extra" | sed 's/^/ /' failures=$((failures + 1)) fi stats "$po" if [ "$untranslated" -ne 0 ]; then note "$lang: $untranslated untranslated message(s) - scripts/i18n.sh todo $lang" failures=$((failures + 1)) fi if [ "$fuzzy" -ne 0 ]; then note "$lang: $fuzzy fuzzy message(s) - those are shown in English" failures=$((failures + 1)) fi # Strings that vanished from the program: msgmerge keeps them as "#~" # entries. Not an error, but stale food for the next translator. local obsolete=0 obsolete=$(grep -c '^#~' "$po" || true) [ "$obsolete" -eq 0 ] || note "$lang: $obsolete obsolete entries (gone from the sources; msgattrib --no-obsolete drops them)" if [ "$(po_language "$po")" != "$lang" ]; then note "$lang: the header says Language: $(po_language "$po")" failures=$((failures + 1)) fi # English has a catalog of its own (an explicit language has to work on a # German desktop) and it is an identity one. if [ "$lang" = "en" ]; then if ! out=$(identity_mismatches "$po"); then note "en: po/en.po is not an identity catalog:" printf '%s\n' "$out" | head -20 | sed 's/^/ /' failures=$((failures + 1)) fi fi # A note, not a failure: it only means the file was not merged after the # last extract, so its header carries an older date than the pot. Collected # into one line - it says nothing about a single language. if [ "$(pot_date "$po")" != "$(pot_date "$POT")" ]; then stale_dates="$stale_dates $lang" fi done [ -z "$stale_dates" ] || note "not merged against the current pot:$stale_dates (scripts/i18n.sh update)" # 3. The languages the GUI offers (src/optionsdialog.cpp): a catalog that cannot # be selected is dead weight, a code without a catalog is an English window # with a German label. local codes codes=$(gui_language_codes) [ -n "$codes" ] || die "could not read kLanguageCodes[] from src/optionsdialog.cpp" for lang in $langs; do if ! grep -qx "$lang" <<<"$codes"; then note "po/$lang.po exists, but $lang is not offered in the options window" note " add it to kLanguageCodes[] and languageOptions[] (src/optionsdialog.cpp)" failures=$((failures + 1)) fi done for code in $codes; do if [ ! -f "$PO_DIR/$code.po" ]; then note "the options window offers $code, but po/$code.po is missing" failures=$((failures + 1)) fi done if [ "$(gui_language_labels)" -ne "$(( $(printf '%s\n' "$codes" | wc -l) + 1 ))" ]; then note "kLanguageCodes[] and languageOptions[] differ in length (src/optionsdialog.cpp)" failures=$((failures + 1)) fi # 4. Strings marked in the daemon. It has no gettext setup (no setlocale, no # bindtextdomain - see daemon/main.cpp), so a `_()` there would end up in the # pot, get translated, and still show English forever. The GUI is the # translated program. local daemon_marked daemon_marked=$(grep -lE '(^|[^[:alnum:]_])_\("|N_\("' daemon/*.cpp 2>/dev/null || true) if [ -n "$daemon_marked" ]; then note "the daemon is not translated, but these files mark strings:" printf '%s\n' "$daemon_marked" | sed 's/^/ /' note " either drop the marker, or give the daemon its own gettext setup and" note " add daemon/ to sources() in scripts/i18n.sh" failures=$((failures + 1)) fi if [ "$failures" -gt 0 ]; then note "i18n: $failures problem(s)" return 1 fi note "i18n: $(printf '%s\n' "$langs" | wc -l) languages, $(msgids "$POT" | wc -l) messages, everything in sync" } cmd_status() { local lang po mo mo_state require_tools "$MSGFMT" "$MSGCAT" [ -f "$POT" ] || die "$POT is missing" printf '%-4s %-8s %10s %6s %11s %-9s %s\n' \ lang messages translated fuzzy untranslated catalog file for lang in $(languages); do po="$PO_DIR/$lang.po" stats "$po" mo="$LOCALE_DIR/$lang/LC_MESSAGES/$PACKAGE.mo" mo_state="not built" if [ -f "$mo" ]; then # `make translations` rebuilds a catalog that is older than its po. if [ "$mo" -nt "$po" ]; then mo_state="current"; else mo_state="stale"; fi fi printf '%-4s %-8s %10s %6s %11s %-9s %s\n' "$lang" \ "$((translated + fuzzy + untranslated))" "$translated" "$fuzzy" "$untranslated" \ "$mo_state" "po/$lang.po" done printf '\npot: %s messages, %s\n' "$(msgids "$POT" | wc -l)" "$(pot_date "$POT")" } cmd_todo() { local lang="${1:-}" [ -n "$lang" ] || die "usage: scripts/i18n.sh todo " local po="$PO_DIR/$lang.po" [ -f "$po" ] || die "$po does not exist" require_tools "$MSGMERGE" "$MSGATTRIB" # Merging the pot into the catalog is what puts the source locations next to # the messages - the catalogs themselves have none (see cmd_update). local open open=$("$MSGMERGE" --quiet "$po" "$POT" | "$MSGATTRIB" --untranslated --no-obsolete) if [ -n "$open" ]; then # A valid po comment, so the output can be kept as a file and translated. note "# --- untranslated in po/$lang.po ---" printf '%s\n' "$open" else note "po/$lang.po: nothing untranslated" fi local fuzzy fuzzy=$("$MSGMERGE" --quiet "$po" "$POT" | "$MSGATTRIB" --only-fuzzy --no-obsolete) if [ -n "$fuzzy" ]; then note note "# --- fuzzy: shown in English until reviewed, the old text is kept as a comment ---" printf '%s\n' "$fuzzy" fi } cmd_new() { local lang="${1:-}" team="${2:-}" [ -n "$lang" ] || die "usage: scripts/i18n.sh new [Language-Team]" case "$lang" in *[!a-zA-Z_@.-]*) die "unexpected language code: $lang" ;; esac [ -f "$POT" ] || die "$POT is missing - run: scripts/i18n.sh extract" local po="$PO_DIR/$lang.po" [ -e "$po" ] && die "$po already exists" if [ -z "$team" ]; then # The two catalogs for a language that has been here from the start carry # the English name of the language; anything else falls back to its code. case "$lang" in en) team="English" ;; *) team="$lang" ;; esac fi { if [ "$lang" = "en" ]; then printf '# English translation for %s.\n' "$PACKAGE" printf '# English is the source language; this catalog keeps the strings explicit.\n' else printf '# %s translation for %s.\n' "$team" "$PACKAGE" printf '# Copyright (C) %s\n' "$(date '+%Y')" printf '# This file is distributed under the same license as the %s package.\n' "$PACKAGE" fi printf '#\n' # The header in the shape the other catalogs use, so a new language does not # start out looking different. cat <"$po" note "wrote po/$lang.po" if [ "$lang" != "en" ]; then note "next:" note " translate it, then: scripts/i18n.sh check && make translations" note " offer it in the options window, or nobody can pick it:" note " src/optionsdialog.cpp: kLanguageCodes[] and languageOptions[]" fi } usage() { cat <<'EOF' scripts/i18n.sh [arguments] extract regenerate po/gkeymapper.pot from the sources update [lang...] merge the pot into the catalogs: new strings come in untranslated, existing translations are kept check validate the pot, the catalogs and the language list status per-language overview (counts + compiled catalog) todo what is still untranslated or fuzzy, with the source locations from the pot new [name] create po/.po from the pot (name = Language-Team, e.g. "Italiano"); "new en" writes the identity catalog help this text The rules behind the commands are in po/README.md. gettext tools are used from PATH; override them like the Makefile's variables, e.g. XGETTEXT=/usr/bin/xgettext EOF } command="${1:-help}" shift || true case "$command" in extract|pot) cmd_extract "$@" ;; update) cmd_update "$@" ;; check) cmd_check "$@" ;; status) cmd_status "$@" ;; todo) cmd_todo "$@" ;; new) cmd_new "$@" ;; help|-h|--help) usage ;; *) usage; exit 1 ;; esac