mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-10-30 23:51:57 +01:00
Groundwork for translating the interface. No strings are extracted yet — this stage only adds the mechanism, so the UI is byte-identical in English. Translations are one flat JSON file per language. An admin can add a language by dropping <lang>.json into $MC_CONFIG_DIR/translations/ and picking it in Settings > Appearance — no rebuild, no restart, no compile step. A drop-in file overrides a built-in one of the same name. Built-ins: en (source) and pl. How it works: - app/i18n.py loads and merges catalogs over en.json, so per-key English fallback is baked in server-side and the JS runtime needs no fallback logic. Catalogs are fingerprinted with stat() per render, so a dropped-in file is live on the next refresh rather than the next restart — which matters, because restarting drops the device connection for up to 60s. - Template text renders server-side via t()/t_html()/tn() from inject_globals(), so the first paint is already correct. That context processor covers every render_template() in the app, including the six standalone iframe pages, so routes/views.py needed no changes. - JS text comes from /i18n/<lang>.<hash8>.js, a blocking immutable script shared by all 8 entry points. The hash is in the path, not a query string, so intermediary caches and the service worker bust reliably. - Language = per-browser mc_lang cookie over a server-wide DB default. The cookie is what makes iframes work: they are same-origin, so they send it automatically and the existing modal-open reload wiring needs no changes. t() deliberately does not escape — Jinja autoescape handles that, and escaping here would double-escape every French apostrophe. t_html()/tHtml() escape their params but trust catalog markup. Also fixed along the way: - save_ui_settings() replaced the whole settings blob instead of merging, so a language-only POST would have wiped the toast settings. It worked before only because the one form always submitted every key. - `const t = document.getElementById(...)` in populateUiSettingsForm shadowed the global translation helper; same for `var t` in six theme IIFEs. scripts/i18n_check.py now fails the build if that pattern comes back. - Removed app/templates/contacts.html, dead since the contacts pages were split (no render_template reference anywhere), and two stale CSS comments. Verified locally: catalog route headers/security, drop-in without restart, malformed catalogs logged and skipped rather than offered in the picker, cookie propagation into iframes, and 360px layout. Python and JS agree on interpolation, escaping and Polish plural categories. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
350 lines
12 KiB
Python
350 lines
12 KiB
Python
"""
|
|
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} <value>" 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 (<b>, <a>).
|
|
|
|
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}
|