Files
wadamesh/scripts/build/i18n-audit.py
T
Kaj SchittecatandClaude Opus 5.5 218bc6ed3e touch: beta_91, Home becomes widgets, backgrounds, GPS in the bar
Home is a grid of arrangeable widgets (24 kinds, presets, a layout per
orientation, Lua app widgets via wada.widget); Unread, Chat and Mentions
get a large size. Theme > Background puts an aurora (still by default, or
moving), a colour, a picture or nothing behind every screen, with glass
cards and a smoked-glass top bar. A GPS icon in the bar, a GPS battery
saver (u-blox GNSS stop/start where there is no power switch), and a
battery saver that no longer waits for Bluetooth and Wi-Fi to be off.
Also: navigation bar position, every settings page on the redesign's
cards, a faster app drawer, slower screensaver drift for burn-in, text
wrapping at spaces only, the V4-R8 Store Apps tab, and SDK Test marked
extended-SDK only (keeps the 2 MB V4 at 99.8%).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:14:04 +02:00

360 lines
16 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""i18n audit: every TR("...") key in the touch UI must be covered by every
deploy/apps/lang/*.lang file (the canonical translation source — translators
edit those files and PR them; the firmware ships no baked-in table).
Run from the repo root: python3 scripts/build/i18n-audit.py
Exit code 1 if any language is missing keys (prints them).
"""
import glob, os, re, sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def unescape_c(s):
"""Decode a C string literal body to the exact runtime text: escapes become
BYTES (like the compiler emits), normal chars their UTF-8 bytes, then the
whole buffer decodes as UTF-8 — so "\xE2\x80\xA6" and a literal … agree."""
out, i = bytearray(), 0
while i < len(s):
if s[i] == '\\' and i + 1 < len(s):
n = s[i + 1]
if n == 'n': out.append(0x0A); i += 2
elif n == 't': out.append(0x09); i += 2
elif n == 'x': out.append(int(s[i + 2:i + 4], 16)); i += 4
else: out += n.encode('utf-8'); i += 2
else:
out += s[i].encode('utf-8'); i += 1
return out.decode('utf-8', 'replace')
def source_keys():
keys = set()
for f in glob.glob(os.path.join(ROOT, 'src/ui-touch/*.cpp')) + \
glob.glob(os.path.join(ROOT, 'src/ui-touch/*.h')):
src = strip_comments(open(f, encoding='utf-8').read())
# An optional LV_SYMBOL_* macro may precede the literal: TR(LV_SYMBOL_OK " Text").
# Requiring the group to START with a quote silently skipped every such
# call - the audit's worst blind spot (the sheet rows never got checked).
for m in re.finditer(r'TR\(\s*((?:LV_SYMBOL_[A-Z0-9_]+\s+)?(?:"(?:[^"\\]|\\.)*"\s*)+)\)', src):
parts = re.findall(r'"((?:[^"\\]|\\.)*)"', m.group(1))
k = unescape_c(''.join(parts))
# strip icon glyphs + their spacer, exactly as TR() does at runtime
while k and 0xE000 <= ord(k[0]) <= 0xF8FF:
k = k[1:]
k = k.lstrip(' ')
if k.strip():
keys.add(k)
# INDIRECT keys: TR(kSomeTable[i]) / TR(tbl[i].field). The regex above only sees
# TR("literal"), so every string reached through a table looked unused — including
# "About", "Backups" and every other settings-category name. Anyone pruning the
# language files on that output would have deleted live translations, which is
# exactly what a translator asked about before doing it (#262).
#
# So: find the table names that appear inside a TR(...) subscript, then pull every
# string literal out of each table's initialiser. Deliberately greedy — over-
# collecting keeps a translation alive, under-collecting deletes one.
for f in glob.glob(os.path.join(ROOT, 'src/ui-touch/*.cpp')) + \
glob.glob(os.path.join(ROOT, 'src/ui-touch/*.h')):
src = strip_comments(open(f, encoding='utf-8').read())
for name in set(re.findall(r'TR\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\[', src)):
for m in re.finditer(r'\b' + re.escape(name) + r'\s*\[[^\]]*\]\s*=\s*\{', src):
i, depth = m.end(), 1
while i < len(src) and depth:
if src[i] == '{': depth += 1
elif src[i] == '}': depth -= 1
i += 1
for lit in re.findall(r'"((?:[^"\\]|\\.)*)"', src[m.end():i]):
k = unescape_c(lit)
if k.strip():
keys.add(k)
# HELPER-WRAPPED keys: mk_row_btn("Reload tiles in view", cb) and its ~20
# siblings take a raw literal and call TR() on it *inside* the helper. At
# runtime they translate correctly -- but the extractor above only ever saw
# TR("literal"), so the literal at the CALL SITE was never emitted as a key,
# so it never landed in a .lang file, so no translator could ever supply it.
# That is why a whole map/options/settings sheet stayed English in every
# language while looking perfectly wrapped in the source (#257).
#
# Two passes: find every function or lambda that calls TR() on one of its own
# parameters and note which parameter, then pull the literal out of that
# argument position at every call site.
srcs = [strip_comments(open(f, encoding='utf-8').read()) for f in _sources()]
helpers = _tr_wrapping_helpers(srcs)
for src in srcs:
for name, idx in helpers:
for arg in _call_args_at(src, name, idx):
for lit in literal_groups(arg):
k = unescape_c(lit)
while k and 0xE000 <= ord(k[0]) <= 0xF8FF:
k = k[1:]
k = k.lstrip(' ')
if k.strip():
keys.add(k)
# RANGE-FOR over a local table: `struct {...} rows[] = {{"Show contacts", ...}};`
# then `for (auto& r : rows) TR(r.label)`. Same blind spot as the helper case --
# correct at runtime, invisible to a TR("literal") scan, so untranslatable.
for f in _sources():
src = strip_comments(open(f, encoding='utf-8').read())
for m in re.finditer(r'for\s*\(\s*(?:const\s+)?auto\s*&?\s*(\w+)\s*:\s*(\w+)\s*\)', src):
var, tbl = m.group(1), m.group(2)
brace = src.find('{', m.end())
if brace < 0 or brace - m.end() > 8: # single-statement body, no block
brace = -1
body = src[m.end():_balanced(src, brace)] if brace >= 0 else src[m.end():m.end() + 400]
if not re.search(r'\bTR\(\s*' + re.escape(var) + r'\s*(?:\.|->)', body):
continue
init = None
for d in re.finditer(r'\b' + re.escape(tbl) + r'\s*\[[^\]]*\]\s*=\s*\{', src[:m.start()]):
init = d
if init:
seg = src[init.end():_balanced(src, init.end() - 1)]
for lit in re.findall(r'"((?:[^"\\]|\\.)*)"', seg):
k = unescape_c(lit)
if k.strip():
keys.add(k)
# TR(someFunc(...)): a helper that RETURNS one of several literals, translated
# by the caller. Every literal it can return is a key.
for f in _sources():
src = strip_comments(open(f, encoding='utf-8').read())
for name in set(re.findall(r'TR\(\s*([A-Za-z_]\w*)\s*\(', src)):
for d in re.finditer(r'\b' + re.escape(name) + r'\s*\([^;{}]*\)\s*\{', src):
seg = src[d.end() - 1:_balanced(src, d.end() - 1)]
for lit in re.findall(r'"((?:[^"\\]|\\.)*)"', seg):
k = unescape_c(lit)
if k.strip():
keys.add(k)
# LUA APPS: wada.sys.tr("...") (aliased to a local `tr` by convention). The
# apps ship from the same catalog and their strings belong in the same files.
for f in sorted(glob.glob(os.path.join(ROOT, 'deploy/apps/*/*/*.lua'))):
src = strip_comments(open(f, encoding='utf-8').read())
for lit in re.findall(r'\b(?:sys\.)?tr\(\s*"((?:[^"\\]|\\.)*)"', src):
k = unescape_c(lit)
if k.strip():
keys.add(k)
return keys
def strip_comments(src):
"""Blank out //... and /*...*/ while preserving string/char literals and line
count. The extractor scans raw text, so a quoted phrase inside a comment was
being collected as a translatable key -- that is how the Dutch string
"Geblokkeerde gebruikers", which only appears in a comment explaining how a
long translation degrades, ended up as a KEY in all thirteen files."""
out, i, n = [], 0, len(src)
while i < n:
c = src[i]
if c == '"' or c == "'":
q = c; out.append(c); i += 1
while i < n:
out.append(src[i])
if src[i] == '\\' and i + 1 < n:
out.append(src[i + 1]); i += 2; continue
if src[i] == q: i += 1; break
i += 1
continue
if c == '/' and i + 1 < n and src[i + 1] == '/':
while i < n and src[i] != '\n': i += 1
continue
if c == '/' and i + 1 < n and src[i + 1] == '*':
i += 2
while i + 1 < n and not (src[i] == '*' and src[i + 1] == '/'):
if src[i] == '\n': out.append('\n')
i += 1
i += 2
continue
out.append(c); i += 1
return ''.join(out)
def literal_groups(arg):
"""Keys from one call argument.
A C compiler concatenates ADJACENT string literals, so `"a" "b"` is one
string. `cond ? "a" : "b"` is two, and joining them (which this used to do)
invented keys that exist nowhere: "Paste (move)Paste (copy)",
"Unblock Block", "Unfav Favorite". Those shipped to every translator in
v18. Only whitespace and an LV_SYMBOL_* macro may sit between literals of
one group; anything else starts a new one."""
groups, cur, i, n = [], [], 0, len(arg)
while i < n:
c = arg[i]
if c == '"':
j, buf = i + 1, []
while j < n:
if arg[j] == '\\' and j + 1 < n: buf.append(arg[j:j + 2]); j += 2; continue
if arg[j] == '"': break
buf.append(arg[j]); j += 1
cur.append(''.join(buf)); i = j + 1; continue
if c.isspace(): i += 1; continue
m = re.match(r'LV_SYMBOL_[A-Z0-9_]+', arg[i:])
if m: i += m.end(); continue
if cur: groups.append(cur); cur = []
i += 1
if cur: groups.append(cur)
return [''.join(g) for g in groups]
def _sources():
return sorted(glob.glob(os.path.join(ROOT, 'src/ui-touch/*.cpp')) +
glob.glob(os.path.join(ROOT, 'src/ui-touch/*.h')))
def _balanced(src, i):
"""Index just past the '}' closing the block whose '{' is at src[i]."""
depth = 0
while i < len(src):
if src[i] == '{': depth += 1
elif src[i] == '}':
depth -= 1
if depth == 0: return i + 1
i += 1
return len(src)
def _split_args(s):
"""Top-level comma split, ignoring commas inside (), [], {} or a string."""
out, cur, depth, q, i = [], [], 0, None, 0
while i < len(s):
c = s[i]
if q:
cur.append(c)
if c == '\\':
if i + 1 < len(s): cur.append(s[i+1]); i += 1
elif c == q: q = None
elif c in '"\'': q = c; cur.append(c)
elif c in '([{': depth += 1; cur.append(c)
elif c in ')]}': depth -= 1; cur.append(c)
elif c == ',' and depth == 0: out.append(''.join(cur)); cur = []
else: cur.append(c)
i += 1
out.append(''.join(cur))
return out
# A function/lambda header: a name, a parenthesised parameter list, then '{'.
_HDR = re.compile(r'(?:auto\s+(?P<lam>[A-Za-z_]\w*)\s*=\s*\[[^\]]*\]\s*'
r'|\b(?P<fn>[A-Za-z_]\w*)\s*)\((?P<args>[^;{}]*)\)\s*'
r'(?:const\s*)?(?:->\s*[\w:*&<> ]+\s*)?\{')
def _tr_wrapping_helpers(srcs):
"""(helper name, argument index) for every helper that TR()s a parameter.
Transitive: a helper that hands its parameter, untouched, to a translated
position of another helper translates it too. The settings kit is built that
way (setSwitchRow passes its title and help line to setRowText, which TR()s
them), and without this every switch, choice and slider name on the rebuilt
pages looked untranslated-by-design and never reached a .lang file."""
funcs = []
for src in srcs:
for m in _HDR.finditer(src):
name = m.group('lam') or m.group('fn')
if not name or name in ('if', 'for', 'while', 'switch', 'catch', 'TR'):
continue
params = []
for a in _split_args(m.group('args')):
names = re.findall(r'[A-Za-z_]\w*', a)
params.append(names[-1] if names else '')
body = src[m.end() - 1:_balanced(src, m.end() - 1)]
calls = set(re.findall(r'\b([A-Za-z_]\w*)\s*\(', body))
funcs.append((name, params, body, calls))
found = set()
for name, params, body, _ in funcs:
for i, pn in enumerate(params):
if pn and re.search(r'\bTR\(\s*' + re.escape(pn) + r'\s*\)', body):
found.add((name, i))
changed = True
while changed:
changed = False
by_name = {}
for h, k in found:
by_name.setdefault(h, set()).add(k)
for name, params, body, calls in funcs:
for i, pn in enumerate(params):
if not pn or (name, i) in found:
continue
for h in calls & by_name.keys():
if any(a.strip() == pn for k in by_name[h] for a in _call_args_at(body, h, k)):
found.add((name, i))
changed = True
break
return found
def _call_args_at(src, name, idx):
"""Every argument at position idx across all calls to name(...)."""
out = []
for m in re.finditer(r'\b' + re.escape(name) + r'\s*\(', src):
i = m.end()
depth, q, start = 1, None, i
while i < len(src) and depth:
c = src[i]
if q:
if c == '\\': i += 1
elif c == q: q = None
elif c in '"\'': q = c
elif c == '(': depth += 1
elif c == ')': depth -= 1
i += 1
args = _split_args(src[start:i - 1])
if idx < len(args):
out.append(args[idx])
return out
# Known untranslatable / identical-everywhere keys.
SKIP = {'...', '😊', 'Snake', '© OpenStreetMap', '© OpenTopoMap'}
def lang_keys(path):
have = set()
for line in open(path, encoding='utf-8'):
# Header lines have no tab; '#' can legitimately START a key (LVGL
# recolor markup), so never treat a leading '#' as a comment marker.
if '\t' not in line:
continue
k = line.split('\t', 1)[0]
have.add(k.replace('\\n', '\n').replace('\\t', '\t').replace('\\\\', '\\'))
return have
def main():
keys = source_keys() - SKIP
# --obsolete: the REVERSE check. Rows in the .lang files that no TR() key matches
# any more, i.e. translation work that is being carried for strings the firmware
# no longer shows. Reported, never deleted automatically.
#
# Read the caveat before acting on it: this is static analysis. A string reached in
# a way the extractor cannot see reads as unreferenced, and pruning it silently
# deletes a working translation. That is not hypothetical -- before the indirect-
# table support above, "About" and every other settings-category name appeared in
# this list. Treat the output as candidates to CHECK, not a delete list.
if "--obsolete" in sys.argv:
for path in sorted(glob.glob(os.path.join(ROOT, 'deploy/apps/lang/*.lang'))):
have = lang_keys(path)
dead = sorted(k for k in have if k not in keys)
tag = os.path.basename(path)
print(f"{tag}: {len(have)} rows, {len(dead)} unreferenced")
for k in dead:
print(f" {k!r}")
print("\nCandidates only. Grep the source for one before removing it: a string "
"reached indirectly can look unreferenced while being on screen.")
return 0
bad = 0
for path in sorted(glob.glob(os.path.join(ROOT, 'deploy/apps/lang/*.lang'))):
missing = sorted(k for k in keys if k not in lang_keys(path))
tag = os.path.basename(path)
if missing:
bad += 1
print(f"{tag}: MISSING {len(missing)}")
for k in missing:
print(f" {k!r}")
else:
print(f"{tag}: ok ({len(keys)} keys covered)")
sys.exit(1 if bad else 0)
if __name__ == '__main__':
main()