From 2ec2f461e2c3da2def9d7ef38a5b092d991815d0 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Fri, 31 Jul 2026 17:29:41 +0200 Subject: [PATCH] feat(i18n): add UI translation infrastructure (stage 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 .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/..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 --- app/i18n.py | 349 +++++++++++++++++++++++++++++ app/main.py | 12 +- app/routes/api.py | 20 +- app/routes/i18n.py | 66 ++++++ app/static/css/style.css | 4 +- app/static/js/app.js | 67 +++++- app/static/js/i18n-runtime.js | 113 ++++++++++ app/static/js/sw.js | 11 +- app/templates/_head_i18n.html | 10 + app/templates/base.html | 29 ++- app/templates/console.html | 4 +- app/templates/contacts.html | 162 ------------- app/templates/contacts_base.html | 10 +- app/templates/dm.html | 10 +- app/templates/logs.html | 4 +- app/templates/path-analyzer.html | 10 +- app/templates/repeater-manage.html | 10 +- app/templates/repeaters.html | 10 +- app/translations/en.json | 5 + app/translations/pl.json | 5 + docs/translations.md | 194 ++++++++++++++++ scripts/i18n_check.py | 222 ++++++++++++++++++ 22 files changed, 1128 insertions(+), 199 deletions(-) create mode 100644 app/i18n.py create mode 100644 app/routes/i18n.py create mode 100644 app/static/js/i18n-runtime.js create mode 100644 app/templates/_head_i18n.html delete mode 100644 app/templates/contacts.html create mode 100644 app/translations/en.json create mode 100644 app/translations/pl.json create mode 100644 docs/translations.md create mode 100644 scripts/i18n_check.py diff --git a/app/i18n.py b/app/i18n.py new file mode 100644 index 0000000..91a51ea --- /dev/null +++ b/app/i18n.py @@ -0,0 +1,349 @@ +""" +UI internationalization — catalog loading, language resolution, translation helpers. + +Design notes: +- Catalogs are flat JSON: {"namespace.area.element": "text"}. A dict value is always a + plural form ({"one": ..., "other": ...}), never a nested namespace. +- Two sources: built-in (app/translations/) and an admin drop-in directory + ($MC_CONFIG_DIR/translations/) which wins. Adding a language needs no restart. +- Catalogs are merged over en.json server-side, so the JS runtime needs no fallback logic. +- The backend stays English: this module translates the UI only, never API responses. +""" + +import hashlib +import json +import logging +import os +import re +from pathlib import Path +from typing import Any, Callable, Optional + +from flask import current_app, request +from markupsafe import Markup, escape + +from .config import config + +logger = logging.getLogger(__name__) + +BUILTIN_DIR = Path(__file__).parent / 'translations' +DEFAULT_LANG = 'en' + +# Language codes we are willing to touch the filesystem for. Guards the catalog route +# against path traversal before any path join happens. +LANG_RE = re.compile(r'^[a-z]{2}(-[A-Z]{2})?$') + +# Only {word} is a placeholder. A bare "{" before punctuation is left alone, so strings +# like "set {name} " survive. Deliberately not str.format: catalogs are +# community-supplied data, and str.format on untrusted data leaks globals. +PARAM_RE = re.compile(r'\{(\w+)\}') + +CLDR_CATEGORIES = {'zero', 'one', 'two', 'few', 'many', 'other'} + +# Caches, each keyed on a cheap stat() fingerprint of the files behind them. +_catalog_cache: dict[str, tuple[tuple, dict, str, bytes]] = {} # lang -> (stamp, catalog, hash, js) +_languages_cache: Optional[tuple[tuple, dict[str, str]]] = None +_warned_keys: set[str] = set() + + +# --------------------------------------------------------------------------- +# Paths and filesystem fingerprinting +# --------------------------------------------------------------------------- + +def override_dir() -> Path: + """Admin drop-in directory. Mounted as /data/translations in Docker.""" + return Path(config.MC_CONFIG_DIR) / 'translations' + + +def _candidate_paths(lang: str) -> list[Path]: + """Files that contribute to `lang`, most significant first.""" + paths = [override_dir() / f'{lang}.json', BUILTIN_DIR / f'{lang}.json'] + if lang != DEFAULT_LANG: + paths += [override_dir() / f'{DEFAULT_LANG}.json', BUILTIN_DIR / f'{DEFAULT_LANG}.json'] + return paths + + +def _stat_stamp(paths) -> tuple: + """Cheap fingerprint so a dropped-in file is picked up without a restart.""" + out = [] + for p in paths: + try: + st = os.stat(p) + out.append((st.st_mtime_ns, st.st_size)) + except OSError: + out.append(None) + return tuple(out) + + +# --------------------------------------------------------------------------- +# Catalog loading +# --------------------------------------------------------------------------- + +def _read_catalog_file(path: Path) -> dict[str, Any]: + """ + Read and validate one catalog file. + + A malformed community catalog must never take the app down — log and return empty. + """ + try: + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + except FileNotFoundError: + return {} + except (OSError, json.JSONDecodeError) as e: + logger.warning(f"Ignoring translation catalog {path}: {e}") + return {} + + if not isinstance(data, dict): + logger.warning(f"Ignoring translation catalog {path}: top level is not an object") + return {} + + clean: dict[str, Any] = {} + for key, value in data.items(): + if not isinstance(key, str): + continue + if isinstance(value, str): + clean[key] = value + elif isinstance(value, dict) and all( + isinstance(k, str) and k in CLDR_CATEGORIES and isinstance(v, str) + for k, v in value.items() + ): + clean[key] = value + else: + logger.warning(f"Ignoring key '{key}' in {path}: value must be a string or plural object") + return clean + + +def _load(lang: str) -> tuple[dict, str, bytes]: + """ + Return (catalog, hash8, js_body) for `lang`, rebuilding only when a file changed. + + The catalog is en.json overlaid by the target language, so per-key English fallback + is baked in here and the JS runtime never needs to think about it. + """ + paths = _candidate_paths(lang) + stamp = _stat_stamp(paths) + + cached = _catalog_cache.get(lang) + if cached and cached[0] == stamp: + return cached[1], cached[2], cached[3] + + # Least significant first, so more significant sources overwrite. + catalog: dict[str, Any] = {} + for path in reversed(paths): + catalog.update(_read_catalog_file(path)) + + payload = json.dumps(catalog, ensure_ascii=False, separators=(',', ':'), sort_keys=True) + js = f'window.MC_LANG={json.dumps(lang)};window.MC_I18N={payload};'.encode('utf-8') + hash8 = hashlib.sha256(js).hexdigest()[:8] + + _catalog_cache[lang] = (stamp, catalog, hash8, js) + return catalog, hash8, js + + +def get_catalog(lang: str) -> dict[str, Any]: + return _load(lang)[0] + + +def get_catalog_js(lang: str) -> tuple[str, bytes]: + """Return (hash8, js_body) for the catalog route.""" + _, hash8, js = _load(lang) + return hash8, js + + +def catalog_url(lang: str) -> str: + """Content-hashed URL. Hash lives in the path, not a query string, so intermediary + caches and the service worker bust reliably.""" + _, hash8, _ = _load(lang) + return f'/i18n/{lang}.{hash8}.js' + + +# --------------------------------------------------------------------------- +# Language discovery +# --------------------------------------------------------------------------- + +def available_languages() -> dict[str, str]: + """ + Map language code -> display name, e.g. {'en': 'English', 'pl': 'Polski'}. + + The display name comes from `meta.language_name` inside each catalog, so a community + hu.json shows up as "Magyar" with no code change anywhere. + """ + global _languages_cache + + dirs = [override_dir(), BUILTIN_DIR] + stamp = _stat_stamp(dirs) + if _languages_cache and _languages_cache[0] == stamp: + return _languages_cache[1] + + codes: set[str] = set() + for directory in dirs: + try: + entries = list(directory.glob('*.json')) + except OSError: + continue + for entry in entries: + if LANG_RE.match(entry.stem): + codes.add(entry.stem) + + langs: dict[str, str] = {} + for code in sorted(codes): + # Read the language's own files, not the en-merged catalog: a catalog that is + # malformed or empty contributes nothing and must not be offered in the picker, + # where it would look like a real language but render as pure English. + own: dict[str, Any] = {} + for path in (BUILTIN_DIR / f'{code}.json', override_dir() / f'{code}.json'): + own.update(_read_catalog_file(path)) + if not own: + continue + + name = own.get('meta.language_name') + langs[code] = name if isinstance(name, str) and name else code + + if DEFAULT_LANG not in langs: + # Built-in en.json is missing or unreadable; keep the app usable regardless. + langs[DEFAULT_LANG] = 'English' + + _languages_cache = (stamp, langs) + return langs + + +def clear_cache() -> None: + """Drop every cache. Backs POST /api/i18n/reload, for filesystems whose mtime + granularity is too coarse for the stat fingerprint to notice a change.""" + global _languages_cache + _catalog_cache.clear() + _languages_cache = None + _warned_keys.clear() + + +# --------------------------------------------------------------------------- +# Language resolution +# --------------------------------------------------------------------------- + +LANG_COOKIE = 'mc_lang' + + +def _server_default_lang() -> Optional[str]: + try: + settings = current_app.db.get_setting_json('ui_settings', {}) or {} + value = settings.get('language') + return value if isinstance(value, str) else None + except Exception: + return None + + +def resolve_lang() -> str: + """ + Per-browser cookie wins over the server-wide default from the database. + + Deliberately no Accept-Language sniffing: this is a single-admin appliance, and + auto-detection means the admin sets Polish and then sees German on their phone. + """ + langs = available_languages() + + cookie = request.cookies.get(LANG_COOKIE) if request else None + if cookie in langs: + return cookie + + saved = _server_default_lang() + if saved in langs: + return saved + + return DEFAULT_LANG + + +# --------------------------------------------------------------------------- +# Plural categories +# --------------------------------------------------------------------------- + +_SLAVIC_4FORM = {'pl'} + + +def plural_category(lang: str, count: int) -> str: + """ + CLDR cardinal category for server-side rendering. + + Only en-like and pl are implemented exactly; anything else falls back to one/other, + which is correct for de/es and near enough for fr. The JS side uses real + Intl.PluralRules, so client-rendered plurals are always right — prefer putting + plurals there. See docs/translations.md. + """ + base = lang.split('-')[0] + + if base in _SLAVIC_4FORM: + if count == 1: + return 'one' + mod10, mod100 = abs(count) % 10, abs(count) % 100 + if 2 <= mod10 <= 4 and not 12 <= mod100 <= 14: + return 'few' + return 'many' + + return 'one' if count == 1 else 'other' + + +# --------------------------------------------------------------------------- +# Translation helpers +# --------------------------------------------------------------------------- + +def _interpolate(text: str, params: dict) -> str: + """Substitute {name} placeholders. An unknown placeholder is left verbatim so it is + visible in the UI rather than silently swallowed.""" + if not params or '{' not in text: + return text + return PARAM_RE.sub(lambda m: str(params.get(m.group(1), m.group(0))), text) + + +def _lookup(catalog: dict, key: str) -> Any: + value = catalog.get(key) + if value is None: + if key not in _warned_keys: + _warned_keys.add(key) + logger.warning(f"Missing translation key: {key}") + return None + return value + + +def _resolve(catalog: dict, lang: str, key: str, count: Optional[int]) -> str: + """Return the raw catalog string for `key`, or the key itself when missing. + + A missing key rendering as its own dotted name is deliberate: it is unmistakable in + the UI and greppable in a screenshot. + """ + value = _lookup(catalog, key) + if value is None: + return key + if isinstance(value, str): + return value + + category = plural_category(lang, count if count is not None else 1) + return value.get(category) or value.get('other') or value.get('one') or key + + +def make_helpers(lang: str) -> dict[str, Callable]: + """Build the t/t_html/tn callables bound to one language, for inject_globals().""" + catalog = get_catalog(lang) + + def t(key: str, /, **params) -> str: + """Translate to a plain string. + + Returns str, NOT Markup — Jinja's autoescape handles escaping at the insertion + point. Escaping here would double-escape and render every French apostrophe + as '. + """ + return _interpolate(_resolve(catalog, lang, key, None), params) + + def t_html(key: str, /, **params) -> Markup: + """Translate a string that carries markup (, ). + + Markup in the catalog is trusted (the admin installed the file); params are + escaped individually, so interpolated user data can never inject HTML. + """ + text = _resolve(catalog, lang, key, None) + return Markup(_interpolate(text, {k: str(escape(v)) for k, v in params.items()})) + + def tn(key: str, count: int, /, **params) -> str: + """Translate with a plural form. {count} is injected automatically.""" + text = _resolve(catalog, lang, key, count) + return _interpolate(text, {'count': count, **params}) + + return {'t': t, 't_html': t_html, 'tn': tn} diff --git a/app/main.py b/app/main.py index 1a1a719..cec88a4 100644 --- a/app/main.py +++ b/app/main.py @@ -15,6 +15,7 @@ from pathlib import Path from typing import Optional from flask import Flask, request as flask_request from flask_socketio import SocketIO, emit +from app import i18n from app.config import config, runtime_config from app.database import Database from app.device_manager import DeviceManager, parse_meshcore_uri @@ -22,6 +23,7 @@ from app.log_handler import MemoryLogHandler from app.observer import ObserverManager from app.routes.views import views_bp from app.routes.api import api_bp +from app.routes.i18n import i18n_bp from app.version import RELEASE_VERSION, VERSION_STRING, GIT_BRANCH # Configure logging @@ -226,19 +228,27 @@ def create_app(): app.config['DEBUG'] = config.FLASK_DEBUG app.config['SECRET_KEY'] = 'mc-webui-secret-key-change-in-production' - # Inject version, branch, and transport type into all templates + # Inject version, branch, transport type, and UI language into all templates. + # This is the single injection point for i18n — it covers every render_template() + # in the app, including the six standalone pages loaded as fullscreen iframes. @app.context_processor def inject_globals(): + lang = i18n.resolve_lang() return { 'release': RELEASE_VERSION, 'version': VERSION_STRING, 'git_branch': GIT_BRANCH, 'transport_type': config.transport_type, + 'lang': lang, + 'i18n_catalog_url': i18n.catalog_url(lang), + 'available_languages': i18n.available_languages(), + **i18n.make_helpers(lang), } # Register blueprints app.register_blueprint(views_bp) app.register_blueprint(api_bp) + app.register_blueprint(i18n_bp) # Initialize SocketIO socketio.init_app(app, cors_allowed_origins="*", async_mode='threading') diff --git a/app/routes/api.py b/app/routes/api.py index a8fb8f3..eecf8ae 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -17,6 +17,7 @@ from datetime import datetime from io import BytesIO from pathlib import Path from flask import Blueprint, jsonify, request, send_file, current_app +from app import i18n from app.meshcore import cli, parser from app.meshcore.regions import derive_scope_key_hex, is_valid_region_name from app.config import config, runtime_config @@ -319,6 +320,7 @@ UI_SETTINGS_DEFAULTS = { 'toast_timeout_sec': 2.0, # auto-hide delay for notification toasts 'toast_no_autoclose': False, # when True, toasts stay until dismissed 'toast_position': 'top-left', # one of TOAST_POSITIONS + 'language': 'en', # server-wide default UI language (see app/i18n.py) } TOAST_POSITIONS = {'top-left', 'top-right', 'bottom-left', 'bottom-right', 'center'} @@ -378,12 +380,20 @@ def get_ui_settings() -> dict: def save_ui_settings(settings: dict) -> bool: - """Save UI settings to database.""" + """ + Merge `settings` into the stored UI settings. + + Must merge, not replace: the Interface form and the language dropdown each POST only + their own keys, so a replace would wipe whichever group was not submitted. Merging + over the stored blob rather than over UI_SETTINGS_DEFAULTS keeps unset keys tracking + the defaults instead of freezing today's values into the database. + """ db = _get_db() if not db: return False try: - db.set_setting_json('ui_settings', settings) + current = db.get_setting_json('ui_settings', {}) or {} + db.set_setting_json('ui_settings', {**current, **settings}) return True except Exception as e: logger.error(f"Failed to save UI settings: {e}") @@ -3003,6 +3013,12 @@ def set_ui_config(): return jsonify({'success': False, 'error': 'Invalid value for toast_position'}), 400 settings['toast_position'] = val + if 'language' in data: + val = data['language'] + if val not in i18n.available_languages(): + return jsonify({'success': False, 'error': 'Invalid value for language'}), 400 + settings['language'] = val + if not settings: return jsonify({'success': False, 'error': 'No valid settings provided'}), 400 diff --git a/app/routes/i18n.py b/app/routes/i18n.py new file mode 100644 index 0000000..6092f11 --- /dev/null +++ b/app/routes/i18n.py @@ -0,0 +1,66 @@ +""" +Translation catalog delivery. + +The catalog is served as a blocking script rather than fetched, so window.t exists +before any page script runs. It cannot live under /static because admin-supplied +catalogs are outside the image, in $MC_CONFIG_DIR/translations/. +""" + +import logging + +from flask import Blueprint, Response, abort, jsonify + +from app import i18n + +logger = logging.getLogger(__name__) + +i18n_bp = Blueprint('i18n', __name__) + + +@i18n_bp.route('/i18n/') +def catalog_js(filename: str): + """ + Serve a language catalog as JavaScript. + + URL shape: /i18n/..js — the content hash is in the path, so the + response can be immutable and the service worker can cache-first it safely. + The hash is not verified: any hash for a known language returns the current + catalog, which is what makes a dropped-in file appear on the next refresh. + """ + if not filename.endswith('.js'): + abort(404) + + # "..js" -> lang. Reject anything else before touching the filesystem. + parts = filename[:-3].split('.') + if len(parts) != 2: + abort(404) + + lang = parts[0] + if not i18n.LANG_RE.match(lang) or lang not in i18n.available_languages(): + abort(404) + + hash8, body = i18n.get_catalog_js(lang) + + resp = Response(body, mimetype='application/javascript') + resp.headers['Cache-Control'] = 'public, max-age=31536000, immutable' + resp.headers['ETag'] = f'"{hash8}"' + resp.charset = 'utf-8' + return resp + + +@i18n_bp.route('/api/i18n/reload', methods=['POST']) +def reload_catalogs(): + """ + Drop the catalog caches. + + Normally unnecessary — catalogs are fingerprinted with stat() on every render, so a + dropped-in file is live on the next refresh. This covers network filesystems whose + mtime granularity is too coarse for that to work. + """ + try: + i18n.clear_cache() + langs = i18n.available_languages() + logger.info(f"Translation catalogs reloaded: {', '.join(sorted(langs))}") + return jsonify({'success': True, 'languages': langs}), 200 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 diff --git a/app/static/css/style.css b/app/static/css/style.css index d01d3df..48cff85 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -1941,7 +1941,7 @@ emoji-picker { } /* ============================================================================= - Contact Management Styles (shared between contacts.html and contacts_base.html) + Contact Management Styles (shared by contacts_base.html and its contacts-* pages) ============================================================================= */ .compact-setting { @@ -2191,7 +2191,7 @@ emoji-picker { #rptLeafletMap .leaflet-bottom { z-index: 1000; } /* ============================================================================= - Contact Management - Page-specific Styles (contacts.html) + Contact Management - Page-specific Styles (contacts-* pages) ============================================================================= */ .contact-info-row { diff --git a/app/static/js/app.js b/app/static/js/app.js index 12d0d8c..016f957 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -2592,7 +2592,8 @@ async function saveChatSettings() { const UI_SETTINGS_DEFAULTS = { toast_timeout_sec: 2, toast_no_autoclose: false, - toast_position: 'top-left' + toast_position: 'top-left', + language: 'en' }; const TOAST_POSITION_CLASSES = { @@ -2618,8 +2619,9 @@ function applyToastPosition(position) { window.applyToastPosition = applyToastPosition; function populateUiSettingsForm(data) { - const t = document.getElementById('settToastTimeout'); - if (t) t.value = data.toast_timeout_sec ?? UI_SETTINGS_DEFAULTS.toast_timeout_sec; + // Not `t` — that would shadow the global translation helper for this whole function. + const timeout = document.getElementById('settToastTimeout'); + if (timeout) timeout.value = data.toast_timeout_sec ?? UI_SETTINGS_DEFAULTS.toast_timeout_sec; const noClose = document.getElementById('settToastNoAutoclose'); if (noClose) noClose.checked = !!(data.toast_no_autoclose ?? UI_SETTINGS_DEFAULTS.toast_no_autoclose); const pos = document.getElementById('settToastPosition'); @@ -2675,6 +2677,59 @@ async function saveUiSettings() { } } +// --- UI Language --- + +/** + * Switch the interface language. + * + * The cookie is what the server reads when rendering, and same-origin iframes send it + * automatically — so reloading the top-level page is all the propagation needed. The + * existing modal-open wiring in index.html re-assigns every iframe src, and each one + * then renders in the new language. The POST additionally makes this the server-wide + * default for browsers that have no cookie of their own. + */ +async function changeLanguage(code) { + if (!code || code === window.MC_LANG) return; + + document.cookie = `mc_lang=${encodeURIComponent(code)}; Path=/; Max-Age=31536000; SameSite=Lax`; + + try { + await fetch('/api/ui/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ language: code }) + }); + } catch (e) { + // The cookie is already set, so the reload still switches this browser. + console.error('Failed to save server default language:', e); + } + + location.reload(); +} + +/** + * Re-scan the translations folder without restarting the app. + * + * Catalogs are fingerprinted with stat() on every render, so a dropped-in file is + * normally live on the next refresh already. This covers network filesystems whose + * mtime granularity is too coarse for that to be noticed. + */ +async function reloadTranslations() { + try { + const resp = await fetch('/api/i18n/reload', { method: 'POST' }); + const data = await resp.json(); + if (resp.ok && data.success) { + const names = Object.values(data.languages || {}).join(', '); + showNotification(`Translations reloaded: ${names}`, 'success'); + setTimeout(() => location.reload(), 800); + } else { + showNotification(data.error || 'Failed to reload translations', 'danger'); + } + } catch (e) { + showNotification('Failed to reload translations', 'danger'); + } +} + // --- DM Retry Settings --- const DM_RETRY_DEFAULTS = { @@ -2876,6 +2931,12 @@ document.addEventListener('DOMContentLoaded', () => { populateUiSettingsForm(UI_SETTINGS_DEFAULTS); }); + document.getElementById('settLanguage')?.addEventListener('change', (e) => { + changeLanguage(e.target.value); + }); + + document.getElementById('reloadTranslationsBtn')?.addEventListener('click', reloadTranslations); + // --- Device Settings --- const devicePublicInfoForm = document.getElementById('devicePublicInfoForm'); if (devicePublicInfoForm) { diff --git a/app/static/js/i18n-runtime.js b/app/static/js/i18n-runtime.js new file mode 100644 index 0000000..caa37c9 --- /dev/null +++ b/app/static/js/i18n-runtime.js @@ -0,0 +1,113 @@ +/** + * UI Translation Runtime + * + * Reads the catalog that /i18n/..js put on window.MC_I18N. That catalog is + * already merged over English server-side, so there is no fallback chain here — a key + * that is missing is genuinely missing everywhere. + * + * Must load as a blocking script in , before any script that calls t(). + * + * Two functions, one rule: + * t() - textContent, .title, .placeholder, alert(), Notification body + * tHtml() - anywhere the result lands in innerHTML / insertAdjacentHTML + * + * tHtml() escapes the interpolated params, never the catalog value. Markup in the + * catalog is trusted (the admin installed the file); interpolated data is not. + */ + +// Only {word} is a placeholder, so a literal "{" before punctuation survives untouched. +const I18N_PARAM_RE = /\{(\w+)\}/g; + +const _pluralRules = {}; + +function _i18nCatalog() { + return window.MC_I18N || {}; +} + +/** + * Substitute {name} placeholders. + * An unknown placeholder is left verbatim so it shows up in the UI instead of vanishing. + */ +function _i18nInterpolate(text, params) { + if (!params || text.indexOf('{') === -1) return text; + return text.replace(I18N_PARAM_RE, (match, name) => ( + Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match + )); +} + +function _i18nEscape(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Resolve a key to its raw catalog string. + * A missing key returns the key itself — unmistakable in the UI, greppable in a screenshot. + */ +function _i18nResolve(key, count) { + const value = _i18nCatalog()[key]; + + if (value === undefined) { + if (window.MC_I18N_DEBUG) console.warn('Missing translation key:', key); + return key; + } + if (typeof value === 'string') return value; + + const lang = window.MC_LANG || 'en'; + if (!_pluralRules[lang]) { + try { + _pluralRules[lang] = new Intl.PluralRules(lang); + } catch (e) { + _pluralRules[lang] = new Intl.PluralRules('en'); + } + } + const category = _pluralRules[lang].select(count === undefined ? 1 : count); + return value[category] || value.other || value.one || key; +} + +/** + * Translate. Result is NOT HTML-escaped — use for text sinks only. + * @param {string} key + * @param {Object} [params] - values for {name} placeholders + * @returns {string} + */ +function t(key, params) { + return _i18nInterpolate(_i18nResolve(key), params); +} + +/** + * Translate for an HTML sink. Catalog markup is preserved, params are escaped. + * @param {string} key + * @param {Object} [params] + * @returns {string} + */ +function tHtml(key, params) { + const text = _i18nResolve(key); + if (!params) return text; + + const escaped = {}; + for (const name of Object.keys(params)) { + escaped[name] = _i18nEscape(params[name]); + } + return _i18nInterpolate(text, escaped); +} + +/** + * Translate with a plural form, using the catalog's {one, few, many, other} object. + * {count} is injected automatically. + * @param {string} key + * @param {number} count + * @param {Object} [params] + * @returns {string} + */ +function tn(key, count, params) { + return _i18nInterpolate(_i18nResolve(key, count), Object.assign({ count: count }, params || {})); +} + +window.t = t; +window.tHtml = tHtml; +window.tn = tn; diff --git a/app/static/js/sw.js b/app/static/js/sw.js index 3e3be4c..ca6ebad 100644 --- a/app/static/js/sw.js +++ b/app/static/js/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'mc-webui-v9'; +const CACHE_NAME = 'mc-webui-v10'; const ASSETS_TO_CACHE = [ '/', '/static/css/style.css', @@ -7,6 +7,7 @@ const ASSETS_TO_CACHE = [ '/static/js/contacts.js', '/static/js/message-utils.js', '/static/js/filter-utils.js', + '/static/js/i18n-runtime.js', '/static/js/console.js', '/static/images/android-chrome-192x192.png', '/static/images/android-chrome-512x512.png', @@ -52,12 +53,16 @@ self.addEventListener('activate', (event) => { // Fetch event - hybrid strategy: // - Cache-first for vendor libraries (static, unchanging) +// - Cache-first for translation catalogs (URL is content-hashed, so it cannot go stale) // - Network-first for app content (dynamic, needs updates) +// +// Note: /i18n/..js is deliberately NOT in ASSETS_TO_CACHE — the hashed +// filename isn't known when this file is written. It gets cached on first fetch instead. self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); - // Cache-first for vendor libraries (Bootstrap, Icons) - if (url.pathname.includes('/static/vendor/')) { + // Cache-first for vendor libraries (Bootstrap, Icons) and translation catalogs + if (url.pathname.includes('/static/vendor/') || url.pathname.startsWith('/i18n/')) { event.respondWith( caches.match(event.request) .then((cachedResponse) => { diff --git a/app/templates/_head_i18n.html b/app/templates/_head_i18n.html new file mode 100644 index 0000000..0cfc7cd --- /dev/null +++ b/app/templates/_head_i18n.html @@ -0,0 +1,10 @@ +{# Translation catalog + runtime. + + Both must block: window.t has to exist before any inline handler or page script runs, + so never add defer/async. Include this in every , immediately after the theme + pre-apply IIFE, giving a uniform ordering: theme -> i18n -> everything else. + + The catalog URL is content-hashed and served immutable, so all 8 entry points + (including the 6 iframes) share a single cached fetch. #} + + diff --git a/app/templates/base.html b/app/templates/base.html index 224151b..4cc8c7a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,5 +1,5 @@ - + @@ -15,9 +15,9 @@ @@ -34,6 +34,8 @@ })(); + {% include "_head_i18n.html" %} + @@ -766,6 +768,25 @@
+
Language
+
+ + +
+ + Applies to this browser and becomes the server default for others. + Add your own by dropping a catalog into the translations folder — see + docs/translations.md. + + +
Theme
diff --git a/app/templates/console.html b/app/templates/console.html index ab0339a..664245e 100644 --- a/app/templates/console.html +++ b/app/templates/console.html @@ -1,10 +1,12 @@ - + Console - mc-webui + {% include "_head_i18n.html" %} + diff --git a/app/templates/contacts.html b/app/templates/contacts.html deleted file mode 100644 index 18556c9..0000000 --- a/app/templates/contacts.html +++ /dev/null @@ -1,162 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Contact Management - mc-webui{% endblock %} - -{% block extra_head %} -{% endblock %} - -{% block content %} -
-
-
- -
-

- Contact Management -

- -
- - -
-
- - -
- -
- - -
-
-
- Pending Contacts - -
- -
- - - - - - - - -
- - - -
- - -
-
-
- Existing Contacts - -
- -
- - -
- - -
- - - - - - - - -
- - - -
-
-
-
- - - - - -
- -
-{% endblock %} - -{% block extra_scripts %} - -{% endblock %} diff --git a/app/templates/contacts_base.html b/app/templates/contacts_base.html index 12aab50..4bff5b8 100644 --- a/app/templates/contacts_base.html +++ b/app/templates/contacts_base.html @@ -1,5 +1,5 @@ - + @@ -8,12 +8,14 @@ + {% include "_head_i18n.html" %} + diff --git a/app/templates/dm.html b/app/templates/dm.html index 060ae7d..f12e36c 100644 --- a/app/templates/dm.html +++ b/app/templates/dm.html @@ -1,5 +1,5 @@ - + @@ -8,9 +8,9 @@ @@ -39,6 +39,8 @@ })(); + {% include "_head_i18n.html" %} + diff --git a/app/templates/logs.html b/app/templates/logs.html index 48235b4..7815e59 100644 --- a/app/templates/logs.html +++ b/app/templates/logs.html @@ -1,10 +1,12 @@ - + System Log - mc-webui + {% include "_head_i18n.html" %} + diff --git a/app/templates/path-analyzer.html b/app/templates/path-analyzer.html index 7925b7c..1c9462f 100644 --- a/app/templates/path-analyzer.html +++ b/app/templates/path-analyzer.html @@ -1,5 +1,5 @@ - + @@ -8,12 +8,14 @@ + {% include "_head_i18n.html" %} + diff --git a/app/templates/repeater-manage.html b/app/templates/repeater-manage.html index e2d15a5..be4417d 100644 --- a/app/templates/repeater-manage.html +++ b/app/templates/repeater-manage.html @@ -1,5 +1,5 @@ - + @@ -8,12 +8,14 @@ + {% include "_head_i18n.html" %} + diff --git a/app/templates/repeaters.html b/app/templates/repeaters.html index 4dd733e..79e9bc9 100644 --- a/app/templates/repeaters.html +++ b/app/templates/repeaters.html @@ -1,5 +1,5 @@ - + @@ -8,12 +8,14 @@ + {% include "_head_i18n.html" %} + diff --git a/app/translations/en.json b/app/translations/en.json new file mode 100644 index 0000000..f11dbee --- /dev/null +++ b/app/translations/en.json @@ -0,0 +1,5 @@ +{ + "meta.language_english_name": "English", + "meta.language_name": "English", + "meta.translator": "mc-webui" +} diff --git a/app/translations/pl.json b/app/translations/pl.json new file mode 100644 index 0000000..3e12af8 --- /dev/null +++ b/app/translations/pl.json @@ -0,0 +1,5 @@ +{ + "meta.language_english_name": "Polish", + "meta.language_name": "Polski", + "meta.translator": "mc-webui" +} diff --git a/docs/translations.md b/docs/translations.md new file mode 100644 index 0000000..37c8eba --- /dev/null +++ b/docs/translations.md @@ -0,0 +1,194 @@ +# Translating mc-webui + +The interface can be translated into any language. Translations live in a single JSON +file per language, so adding one needs no rebuild, no restart and no compile step — +drop the file on your server and pick it in **Settings → Appearance → Language**. + +Built-in: **English** (`en`, the source) and **Polish** (`pl`). Everything else comes +from the community. + +> **The backend stays English.** This system translates the interface only. Error and +> status messages produced by the server (roughly one toast in seven, mostly on error +> paths) will still appear in English. That is deliberate, not a gap in your translation. + +--- + +## 1. Read this first: what must NOT be translated + +Mesh operators use the English protocol terms whatever language their UI is in. A +translated "flood" matches no firmware documentation, no `meshcore-cli` output and no +forum post — it just makes the app harder to use. + +**Leave these in English, always:** + +| | | +|---|---| +| **Protocol and radio terms** | flood, direct, hop, path, advert, ACK, RSSI, SNR, LoRa, MQTT, broker, telemetry, pubkey, packet hash, spreading factor, bandwidth, coding rate | +| **Node roles** | Companion, Repeater, Room Server, Sensor — and the codes `COM`, `REP`, `ROOM`, `SENS` | +| **CLI surfaces** | everything the Console prints, its `help` screen, `Usage:` lines, command names | +| **Log output** | log lines and the level names `DEBUG`, `INFO`, `WARNING`, `ERROR` | +| **Device/firmware fields** | `radio.rxgain`, `advert.interval` and similar | +| **Region and preset names** | `EU/UK (Narrow)`, `USA/Canada (Recommended)` — these are proper nouns | + +You **do** translate the text around them. A sentence explaining a technical concept is +translated even though the term inside it is not: + +```json +"settings.device.path_hash_desc": "Bytes per hop in routing paths. 1B = shortest path, more collisions." +``` +```json +"settings.device.path_hash_desc": "Bajtów na hop w ścieżkach routingu. 1B = najkrótsza ścieżka, więcej kolizji." +``` + +"hop" survives untranslated inside translated prose. That is the intended shape. +Inflecting a term is fine — *"hopów"*, *"repeatera"* — deleting it is not. + +The canonical glossary list lives in `scripts/i18n_check.py` (`GLOSSARY`), which warns +when a translation drops one of these terms. + +--- + +## 2. Adding a language + +### Get the file to work from + +```bash +cp app/translations/en.json app/translations/hu.json # or work outside the repo +``` + +Then translate the **values**, never the keys: + +```json +{ + "meta.language_name": "Magyar", + "meta.language_english_name": "Hungarian", + "meta.translator": "Your Name ", + + "common.close": "Bezárás", + "chat.compose.input_ph": "Írj üzenetet..." +} +``` + +`meta.language_name` is what appears in the Settings dropdown, written in the language +itself. The other `meta.*` keys are optional. + +### Install it on your server + +Drop the file into the `translations` folder inside your config directory — the same +volume that holds the database. With the stock `docker-compose.yml` that is: + +```bash +mkdir -p ./data/translations +cp hu.json ./data/translations/ +``` + +Refresh the browser. The language appears in **Settings → Appearance → Language** +immediately; no restart is needed. If it does not show up (some network filesystems +report file timestamps too coarsely), click the ⟳ button next to the dropdown. + +A file in this folder **overrides** a built-in one of the same name, so you can also use +it to correct the shipped `pl.json` on your own server without touching the image. + +### Contribute it back + +Open a pull request adding your file to `app/translations/`. Please run the checker +first (below) and mention which mc-webui version you translated against. + +--- + +## 3. The file format + +Flat keys, one JSON object, no nesting: + +```json +"settings.appearance.theme": "Motyw" +``` + +Key names describe where the string appears (`settings.appearance.theme`), and a suffix +describes what kind of string it is: + +| Suffix | Meaning | +|---|---| +| *(none)* | visible text | +| `_title` | tooltip (`title=`) | +| `_ph` | input placeholder | +| `_aria` | screen-reader label | +| `_desc` | helper text under a control | +| `_btn` | button label | + +### Placeholders + +`{name}` is substituted at runtime. **Keep every placeholder** — the checker reports a +mismatch as an error. You may reorder them freely: + +```json +"toast.contacts.deleted": "Deleted {name}" +``` +```json +"toast.contacts.deleted": "Usunięto kontakt {name}" +``` + +A `{` that is not followed by a word is left alone, so `set {name} ` is safe. + +### Plurals + +A value may be an object of plural forms instead of a string. Use the categories your +language actually needs — the app picks the right one via the browser's own CLDR rules: + +```json +"contacts.path.hops": { "one": "({count} hop)", "other": "({count} hops)" } +``` +```json +"contacts.path.hops": { + "one": "({count} hop)", + "few": "({count} hopy)", + "many": "({count} hopów)", + "other": "({count} hopa)" +} +``` + +`{count}` is always available. Valid categories: `zero`, `one`, `two`, `few`, `many`, +`other`. English needs `one` + `other`; Polish needs four. + +> Plurals in server-rendered text use a simpler built-in rule table that implements +> English and Polish exactly and falls back to `one`/`other` elsewhere. Client-rendered +> plurals — the large majority — always use the browser's full CLDR rules. + +### Markup + +A few values contain HTML such as `` or `
`. Keep the tags intact and +translate the text between them. Do **not** add markup to a value that had none. + +### Missing keys + +Anything you leave out falls back to English automatically. A partial translation is +perfectly usable — ship it and fill in the rest over time. + +--- + +## 4. Checking your work + +```bash +python scripts/i18n_check.py # everything +python scripts/i18n_check.py --lang hu # one language +python scripts/i18n_check.py --missing hu # your worklist: untranslated keys + English text +``` + +`--missing` prints tab-separated `keyEnglish text`, which pastes straight into a +spreadsheet. + +The checker reports as **errors**: a placeholder you dropped or invented, a key that no +longer exists, invalid JSON. As **warnings**: a glossary term that disappeared, and keys +present in the catalog but unused in the code. + +--- + +## 5. Security note + +An installed catalog is trusted content. Values may contain HTML, and the app renders it +— the same trust level as installing a plugin. Only install catalogs you have read or +that came from a source you trust. Interpolated values (contact names, message text) are +always escaped, so a catalog cannot be used to attack data flowing through it, and +catalog text is never executed as code. + +A malformed or unreadable catalog is logged and skipped; it cannot take the app down. diff --git a/scripts/i18n_check.py b/scripts/i18n_check.py new file mode 100644 index 0000000..5fd466e --- /dev/null +++ b/scripts/i18n_check.py @@ -0,0 +1,222 @@ +#!/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'''(? 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())