Files
wadamesh/scripts/build/audit-lang.py
T
Kaj SchittecatandClaude Opus 5 d4ade2e616 i18n: add the 48 UI strings no language file had, as empty rows for translators
The firmware asked for these keys and no .lang file carried them, so they
rendered English in all 13 languages with nothing to show a translator that
they were missing. Mostly strings that shipped with a feature whose PR did not
touch the language files: the SD arbitration and migration warnings, the
Wi-Fi/BLE coexistence messages, the keyboard-navigation vocabulary
(Up/Down/Left/Right/Select/Scroll) and section names (General, Clock & time,
Sensors, Screen, Home, Messages, Keys, Compact, Snake).

They go in with an EMPTY translation, which is the format's own way of saying
'not translated yet': the runtime loader requires a non-empty value
(`if (*p && tab[1])`) and gen-lang-builtin.py requires `if k and v`, so both
skip these rows and the English falls through exactly as before. Confirmed:
i18n_builtin.h regenerates byte-identical. No `# ver:` bump for the same
reason — device behaviour is unchanged, so there is nothing to re-download.

Also fixes the audit's own row parser to match the loader: a line is a comment
only when it has NO tab. Testing for a leading '#' hid every key starting with
LVGL recolor markup ("#7A7F87 Wardrive: …#"), the exact class the loader has a
comment warning about.

Every language is now at 0 missing keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 10:24:56 +02:00

113 lines
5.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""Audit a .lang file against the TR() keys the firmware actually looks up.
Nothing validated this before: the .lang files are hand-maintained and
i18n_builtin.h is generated FROM them, so a row whose English text no longer
appears in any TR() call is translated for nothing and silently stays that way
(and a TR() key with no row silently renders English). This diffs both ways.
Matching mirrors TR() (src/ui-touch/i18n.cpp) exactly, which is the whole
trick — a naive grep gets this wrong three ways:
* adjacent C literals are concatenated by the compiler ("a" "b" -> "ab"),
* LV_SYMBOL_* icon prefixes are part of the source literal but TR() strips
the glyph and following spaces before looking up,
* non-ASCII is often written as \\x hex escapes, which must be decoded to the
same UTF-8 bytes the .lang file stores literally.
Usage: scripts/build/audit-lang.py [deploy/apps/lang/hu.lang ...]
(no args = every .lang file)
"""
import re, sys, glob, os
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def c_unescape(s: str) -> str:
"""Decode a C string literal body to the bytes TR() receives, as UTF-8 text."""
out = bytearray(); i = 0
while i < len(s):
if s[i] != '\\':
out += s[i].encode('utf-8'); i += 1; continue
i += 1
if i >= len(s): break
c = s[i]
if c == 'x': # \xNN
j = i + 1
while j < len(s) and j <= i + 2 and s[j] in '0123456789abcdefABCDEF': j += 1
out.append(int(s[i+1:j], 16)); i = j
else:
out += {'n': b'\n', 't': b'\t', 'r': b'\r', '\\': b'\\',
'"': b'"', "'": b"'", '0': b'\0'}.get(c, ('\\' + c).encode())
i += 1
return out.decode('utf-8', 'replace')
def strip_icon(s: str) -> str:
"""TR()'s prefix strip: drop leading 3-byte 0xEE/0xEF glyphs, then spaces."""
b = s.encode('utf-8'); i = 0
while i + 2 < len(b) and b[i] in (0xEE, 0xEF): i += 3
if i:
while i < len(b) and b[i:i+1] == b' ': i += 1
return b[i:].decode('utf-8', 'replace') or s
return s
# --- every TR( ... ) argument in the UI sources, concatenation-aware ----------
LIT = r'"((?:[^"\\]|\\.)*)"'
def tr_keys():
keys = set()
for f in glob.glob(f'{ROOT}/src/**/*.cpp', recursive=True) + \
glob.glob(f'{ROOT}/src/**/*.h', recursive=True):
if 'i18n' in os.path.basename(f): # the generated table + engine itself
continue
src = open(f, encoding='utf-8', errors='ignore').read()
for m in re.finditer(r'TR\(\s*((?:(?:LV_SYMBOL_\w+|' + LIT + r')\s*)+)', src):
parts = re.findall(LIT, m.group(1))
if parts:
keys.add(strip_icon(c_unescape(''.join(parts))).strip())
# Labels reached through a variable — TR(kSettingsCats[c].label), TR(d->name).
# The literals live in the table's initialiser, so resolve the base
# identifier and harvest every string in its definition block. Without
# this the whole tab/category/menu vocabulary looks untranslated.
for ident in set(re.findall(r'TR\(\s*([A-Za-z_]\w*)\s*[\[.\-]', src)):
for tm in re.finditer(re.escape(ident) + r'\s*(?:\[[^\]]*\])?\s*=\s*\{', src):
depth, i = 0, tm.end() - 1
while i < len(src): # walk to the matching brace
if src[i] == '{': depth += 1
elif src[i] == '}':
depth -= 1
if depth == 0: break
i += 1
for lit in re.findall(LIT, src[tm.end():i]):
keys.add(strip_icon(c_unescape(lit)).strip())
return keys
def audit(path, keys):
rows = []
for line in open(path, encoding='utf-8'):
line = line.rstrip('\n')
# A line is a header/comment only when it has NO tab — same rule as the
# loader. Testing for a leading '#' would drop every key that starts with
# LVGL recolor markup ("#7A7F87 Wardrive: …#"), which are real keys.
if '\t' not in line: continue
en, tr = line.split('\t', 1)
rows.append((c_unescape(en), tr))
dead = [en for en, tr in rows if en not in keys]
# Keys with no letters (a lone emoji, a glyph-only button) translate to
# themselves — not worth a row, and not a gap.
missing = sorted(k for k in keys
if k and k not in {en for en, _ in rows} and re.search(r'[A-Za-z]', k))
name = os.path.basename(path)
print(f'\n=== {name}{len(rows)} rows vs {len(keys)} TR() keys ===')
print(f' translated but NEVER looked up : {len(dead)}')
print(f' looked up but NOT translated : {len(missing)}')
for e in dead: print(' dead ', repr(e)[:110])
for e in missing: print(' missing ', repr(e)[:110])
return len(dead), len(missing)
if __name__ == '__main__':
keys = tr_keys()
files = sys.argv[1:] or sorted(glob.glob(f'{ROOT}/deploy/apps/lang/*.lang'))
worst = 0
for f in files:
d, m = audit(f, keys)
worst = max(worst, d)
print(f'\n{len(keys)} distinct TR() keys in the UI.')