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())