Files
gKeyMapper/scripts/i18n.sh
2026-10-27 18:57:14 +01:00

536 lines
22 KiB
Bash
Executable File

#!/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 <command> [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 <lang> what is still untranslated or fuzzy, with the source
# locations from the pot
# new <lang> [name] create po/<lang>.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 <po>~; 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 <lang>"
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 <lang> [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 <<EOF
msgid ""
msgstr ""
"Project-Id-Version: $PACKAGE $PO_VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: $(pot_date "$POT")\n"
"PO-Revision-Date: $(date '+%Y-%m-%d %H:%M%z')\n"
"Last-Translator: \n"
"Language-Team: $team\n"
"Language: $lang\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
EOF
printf '\n'
# The entries come from the pot: it defines what has to be translated, and
# every msgstr in it is already empty.
pot_body "$POT" "$([ "$lang" = "en" ] && echo 1 || echo 0)"
} >"$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 <command> [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 <lang> what is still untranslated or fuzzy, with the source
locations from the pot
new <lang> [name] create po/<lang>.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