mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-10-30 23:51:57 +01:00
Groundwork for translating the interface. No strings are extracted yet — this stage only adds the mechanism, so the UI is byte-identical in English. Translations are one flat JSON file per language. An admin can add a language by dropping <lang>.json into $MC_CONFIG_DIR/translations/ and picking it in Settings > Appearance — no rebuild, no restart, no compile step. A drop-in file overrides a built-in one of the same name. Built-ins: en (source) and pl. How it works: - app/i18n.py loads and merges catalogs over en.json, so per-key English fallback is baked in server-side and the JS runtime needs no fallback logic. Catalogs are fingerprinted with stat() per render, so a dropped-in file is live on the next refresh rather than the next restart — which matters, because restarting drops the device connection for up to 60s. - Template text renders server-side via t()/t_html()/tn() from inject_globals(), so the first paint is already correct. That context processor covers every render_template() in the app, including the six standalone iframe pages, so routes/views.py needed no changes. - JS text comes from /i18n/<lang>.<hash8>.js, a blocking immutable script shared by all 8 entry points. The hash is in the path, not a query string, so intermediary caches and the service worker bust reliably. - Language = per-browser mc_lang cookie over a server-wide DB default. The cookie is what makes iframes work: they are same-origin, so they send it automatically and the existing modal-open reload wiring needs no changes. t() deliberately does not escape — Jinja autoescape handles that, and escaping here would double-escape every French apostrophe. t_html()/tHtml() escape their params but trust catalog markup. Also fixed along the way: - save_ui_settings() replaced the whole settings blob instead of merging, so a language-only POST would have wiped the toast settings. It worked before only because the one form always submitted every key. - `const t = document.getElementById(...)` in populateUiSettingsForm shadowed the global translation helper; same for `var t` in six theme IIFEs. scripts/i18n_check.py now fails the build if that pattern comes back. - Removed app/templates/contacts.html, dead since the contacts pages were split (no render_template reference anywhere), and two stale CSS comments. Verified locally: catalog route headers/security, drop-in without restart, malformed catalogs logged and skipped rather than offered in the picker, cookie propagation into iframes, and 360px layout. Python and JS agree on interpolation, escaping and Polish plural categories. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
223 lines
8.1 KiB
Python
223 lines
8.1 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Translation catalog checker.
|
|
|
|
Run from the repo root:
|
|
python scripts/i18n_check.py # check everything
|
|
python scripts/i18n_check.py --lang pl # one language
|
|
python scripts/i18n_check.py --missing pl > pl-todo.txt
|
|
|
|
Exits non-zero if any ERROR is reported. Warnings never fail the run.
|
|
|
|
For translators the useful output is the coverage table and --missing, which prints the
|
|
untranslated keys with their English text — that is your worklist.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
TRANSLATIONS = REPO / 'app' / 'translations'
|
|
SCAN_GLOBS = ['app/templates/**/*.html', 'app/static/js/*.js']
|
|
|
|
# Never flag these as unused — they are read by app/i18n.py, not by a t() call.
|
|
META_KEYS = {'meta.language_name', 'meta.language_english_name', 'meta.translator',
|
|
'meta.review_status'}
|
|
|
|
# Terms that must survive translation. Mesh operators use the English words regardless
|
|
# of UI language, and a translated "flood" matches no firmware doc or forum post.
|
|
# Canonical list — docs/translations.md points here.
|
|
GLOSSARY = [
|
|
'flood', 'direct', 'hop', 'advert', 'ACK', 'RSSI', 'SNR', 'LoRa', 'MQTT',
|
|
'repeater', 'room server', 'companion', 'sensor', 'pubkey', 'telemetry', 'broker',
|
|
'spreading factor', 'bandwidth', 'coding rate',
|
|
]
|
|
|
|
# t('key'), tn('key', n), tHtml('key', {...}), t_html('key', name=x).
|
|
# The lookbehind stops "format(" / ".at(" / "$t(" from matching.
|
|
CALL_RE = re.compile(r'''(?<![\w.$])(t|tn|tHtml|t_html)\s*\(\s*(['"])((?:(?!\2).)*)\2''')
|
|
PARAM_RE = re.compile(r'\{(\w+)\}')
|
|
SHADOW_RE = re.compile(r'(?:const|let|var)\s+t\s*=')
|
|
|
|
HTML_SINK_RE = re.compile(r'innerHTML|insertAdjacentHTML')
|
|
|
|
errors: list[str] = []
|
|
warnings: list[str] = []
|
|
|
|
|
|
def err(msg): errors.append(msg)
|
|
def warn(msg): warnings.append(msg)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def load_catalog(lang: str) -> dict:
|
|
path = TRANSLATIONS / f'{lang}.json'
|
|
try:
|
|
return json.loads(path.read_text(encoding='utf-8'))
|
|
except FileNotFoundError:
|
|
err(f"{path.relative_to(REPO)}: not found")
|
|
return {}
|
|
except json.JSONDecodeError as e:
|
|
err(f"{path.relative_to(REPO)}: invalid JSON — {e}")
|
|
return {}
|
|
|
|
|
|
def available_langs() -> list[str]:
|
|
return sorted(p.stem for p in TRANSLATIONS.glob('*.json'))
|
|
|
|
|
|
def scan_sources() -> tuple[dict[str, set[str]], dict[str, list[str]]]:
|
|
"""Return (key -> set of call kinds used, key -> list of 'file:line' sites)."""
|
|
kinds: dict[str, set[str]] = defaultdict(set)
|
|
sites: dict[str, list[str]] = defaultdict(list)
|
|
|
|
for pattern in SCAN_GLOBS:
|
|
for path in sorted(REPO.glob(pattern)):
|
|
rel = path.relative_to(REPO).as_posix()
|
|
for lineno, line in enumerate(path.read_text(encoding='utf-8').splitlines(), 1):
|
|
for func, _, key in CALL_RE.findall(line):
|
|
kinds[key].add(func)
|
|
sites[key].append(f'{rel}:{lineno}')
|
|
|
|
# A t() result landing in innerHTML must be tHtml() — its params are
|
|
# escaped, so interpolated user data cannot inject markup.
|
|
if HTML_SINK_RE.search(line) and re.search(r'\$\{\s*t\s*\(', line):
|
|
err(f'{rel}:{lineno}: t() inside an HTML sink — use tHtml()')
|
|
|
|
if SHADOW_RE.search(line):
|
|
err(f'{rel}:{lineno}: `t` is assigned here, shadowing the global '
|
|
f'translation helper — rename the variable')
|
|
|
|
return kinds, sites
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Checks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def value_strings(value) -> list[str]:
|
|
"""All text variants of a catalog value (plural objects have several)."""
|
|
if isinstance(value, str):
|
|
return [value]
|
|
if isinstance(value, dict):
|
|
return [v for v in value.values() if isinstance(v, str)]
|
|
return []
|
|
|
|
|
|
def placeholders(value) -> set[str]:
|
|
out: set[str] = set()
|
|
for text in value_strings(value):
|
|
out |= set(PARAM_RE.findall(text))
|
|
return out
|
|
|
|
|
|
def check_usage(en: dict, kinds: dict[str, set[str]], sites: dict[str, list[str]]):
|
|
for key in sorted(kinds):
|
|
if key not in en:
|
|
where = sites[key][0]
|
|
err(f'{where}: key not in en.json — {key!r}')
|
|
|
|
unused = set(en) - set(kinds) - META_KEYS
|
|
for key in sorted(unused):
|
|
warn(f'en.json: unused key {key!r}')
|
|
|
|
# Markup in a catalog value only survives through the _html variants.
|
|
for key, value in sorted(en.items()):
|
|
if key not in kinds:
|
|
continue
|
|
has_markup = any('<' in text for text in value_strings(value))
|
|
used_plain = bool(kinds[key] & {'t', 'tn'})
|
|
used_html = bool(kinds[key] & {'tHtml', 't_html'})
|
|
|
|
if has_markup and used_plain:
|
|
err(f'{sites[key][0]}: {key!r} contains markup but is used via t()/tn() — '
|
|
f'use tHtml()/t_html()')
|
|
if not has_markup and used_html:
|
|
warn(f'{sites[key][0]}: {key!r} has no markup but is used via the _html '
|
|
f'variant — t() is enough')
|
|
|
|
|
|
def check_language(lang: str, en: dict, catalog: dict) -> float:
|
|
translated = [k for k in en if k in catalog]
|
|
coverage = 100.0 * len(translated) / len(en) if en else 100.0
|
|
|
|
for key in sorted(catalog):
|
|
if key not in en:
|
|
warn(f'{lang}.json: key not in en.json — {key!r} (renamed or removed?)')
|
|
|
|
for key in sorted(translated):
|
|
want, got = placeholders(en[key]), placeholders(catalog[key])
|
|
if want != got:
|
|
missing = ', '.join(sorted(want - got)) or '-'
|
|
extra = ', '.join(sorted(got - want)) or '-'
|
|
err(f'{lang}.json: {key!r} placeholder mismatch — missing: {missing}; '
|
|
f'unexpected: {extra}')
|
|
|
|
# Deleting a glossary term is a bug; inflecting it ("hopów") is fine, so this
|
|
# is a substring test and only ever a warning.
|
|
en_text = ' '.join(value_strings(en[key])).lower()
|
|
tr_text = ' '.join(value_strings(catalog[key])).lower()
|
|
for term in GLOSSARY:
|
|
if re.search(rf'\b{re.escape(term.lower())}', en_text) and term.lower() not in tr_text:
|
|
warn(f'{lang}.json: {key!r} drops the glossary term {term!r}')
|
|
|
|
return coverage
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument('--lang', help='check only this language')
|
|
parser.add_argument('--missing', metavar='LANG',
|
|
help='print untranslated keys for LANG and exit')
|
|
args = parser.parse_args()
|
|
|
|
en = load_catalog('en')
|
|
if not en:
|
|
print('\n'.join(errors), file=sys.stderr)
|
|
return 1
|
|
|
|
if args.missing:
|
|
catalog = load_catalog(args.missing)
|
|
for key in sorted(set(en) - set(catalog) - META_KEYS):
|
|
value = en[key]
|
|
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
|
|
print(f'{key}\t{text}')
|
|
return 0
|
|
|
|
kinds, sites = scan_sources()
|
|
check_usage(en, kinds, sites)
|
|
|
|
langs = [args.lang] if args.lang else [l for l in available_langs() if l != 'en']
|
|
coverage = {lang: check_language(lang, en, load_catalog(lang)) for lang in langs}
|
|
|
|
print(f'en.json: {len(en)} keys, {len(kinds)} used in code\n')
|
|
if coverage:
|
|
print('Coverage')
|
|
for lang, pct in sorted(coverage.items()):
|
|
print(f' {lang:6} {pct:5.1f}%')
|
|
print()
|
|
|
|
for msg in warnings:
|
|
print(f'WARN {msg}')
|
|
for msg in errors:
|
|
print(f'ERROR {msg}', file=sys.stderr)
|
|
|
|
print(f'\n{len(errors)} error(s), {len(warnings)} warning(s)')
|
|
return 1 if errors else 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|