mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-07 01:03:15 +02:00
feat(i18n): add UI translation infrastructure (stage 0)
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>
This commit is contained in:
+349
@@ -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} <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}
|
||||
+11
-1
@@ -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')
|
||||
|
||||
+18
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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/<path:filename>')
|
||||
def catalog_js(filename: str):
|
||||
"""
|
||||
Serve a language catalog as JavaScript.
|
||||
|
||||
URL shape: /i18n/<lang>.<hash8>.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)
|
||||
|
||||
# "<lang>.<hash8>.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
|
||||
@@ -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 {
|
||||
|
||||
+64
-3
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* UI Translation Runtime
|
||||
*
|
||||
* Reads the catalog that /i18n/<lang>.<hash>.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 <head>, 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, '"')
|
||||
.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;
|
||||
+8
-3
@@ -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/<lang>.<hash>.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) => {
|
||||
|
||||
@@ -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 <head>, 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. #}
|
||||
<script src="{{ i18n_catalog_url }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/i18n-runtime.js') }}"></script>
|
||||
+25
-4
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light" data-bs-theme="light">
|
||||
<html lang="{{ lang }}" data-theme="light" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
@@ -15,9 +15,9 @@
|
||||
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
|
||||
<script>
|
||||
(function() {
|
||||
var t = localStorage.getItem('mc-webui-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
document.documentElement.setAttribute('data-bs-theme', t);
|
||||
var theme = localStorage.getItem('mc-webui-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% include "_head_i18n.html" %}
|
||||
|
||||
<!-- Bootstrap 5 CSS (local) -->
|
||||
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
|
||||
<!-- Bootstrap Icons (local) -->
|
||||
@@ -766,6 +768,25 @@
|
||||
</form>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tabSettingsAppearance">
|
||||
<h6 class="text-muted mb-3">Language</h6>
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<select class="form-select form-select-sm" id="settLanguage">
|
||||
{% for code, name in available_languages.items() %}
|
||||
<option value="{{ code }}"{% if code == lang %} selected{% endif %}>{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-outline-secondary" type="button" id="reloadTranslationsBtn"
|
||||
title="Re-scan the translations folder">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
<small class="d-block text-muted mb-3">
|
||||
Applies to this browser and becomes the server default for others.
|
||||
Add your own by dropping a catalog into the translations folder — see
|
||||
<code>docs/translations.md</code>.
|
||||
</small>
|
||||
|
||||
<hr>
|
||||
<h6 class="text-muted mb-3">Theme</h6>
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<div class="theme-option active" data-theme-value="light" onclick="setTheme('light')">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{{ lang }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>Console - mc-webui</title>
|
||||
|
||||
{% include "_head_i18n.html" %}
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Contact Management - mc-webui{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-3 py-4">
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-8 mx-auto">
|
||||
<!-- Page Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">
|
||||
<i class="bi bi-person-check"></i> Contact Management
|
||||
</h2>
|
||||
<button class="btn btn-outline-secondary" onclick="window.history.back();" title="Go back">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Manual Approval Settings Section (Compact) -->
|
||||
<div class="compact-setting">
|
||||
<div class="form-check form-switch mb-0 d-flex align-items-center gap-2">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="manualApprovalSwitch" style="cursor: pointer; min-width: 3rem; min-height: 1.5rem;">
|
||||
<label class="form-check-label mb-0" for="manualApprovalSwitch" style="cursor: pointer; font-weight: 500;">
|
||||
<span id="switchLabel">Loading...</span>
|
||||
</label>
|
||||
</div>
|
||||
<i class="bi bi-info-circle info-icon"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
title="When enabled, new contacts must be manually approved before they can communicate with your node"></i>
|
||||
</div>
|
||||
|
||||
<!-- Pending Contacts Section (Compact) -->
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center section-compact">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-hourglass-split"></i> Pending Contacts
|
||||
<span class="badge bg-primary rounded-pill" id="pendingCount" style="display: none;">0</span>
|
||||
</h6>
|
||||
<button class="btn btn-sm btn-outline-primary" id="refreshPendingBtn">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div id="pendingLoading" class="text-center py-2" style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
<span class="ms-2 text-muted small">Loading...</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty State (Compact) -->
|
||||
<div id="pendingEmpty" class="empty-state compact" style="display: none;">
|
||||
<i class="bi bi-check-circle"></i>
|
||||
<p class="mb-0 small">No pending requests</p>
|
||||
</div>
|
||||
|
||||
<!-- Pending Contacts List -->
|
||||
<div id="pendingList"></div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div id="pendingError" class="alert alert-danger py-2 mb-0" style="display: none;" role="alert">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<span class="small" id="errorMessage">Failed to load pending contacts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing Contacts Section -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-person-lines-fill"></i> Existing Contacts
|
||||
<span class="badge counter-badge counter-ok rounded-pill" id="contactsCounter" style="display: none;">0 / 350</span>
|
||||
</h5>
|
||||
<button class="btn btn-sm btn-outline-primary" id="refreshExistingBtn">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter Toolbar -->
|
||||
<div class="search-toolbar">
|
||||
<input type="text" class="form-control" id="searchInput" placeholder="Search by name or public key...">
|
||||
<select class="form-select" id="typeFilter" style="max-width: 150px;">
|
||||
<option value="ALL">All Types</option>
|
||||
<option value="COM">COM</option>
|
||||
<option value="REP">REP</option>
|
||||
<option value="ROOM">ROOM</option>
|
||||
<option value="SENS">SENS</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div id="existingLoading" class="text-center py-3" style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
<span class="ms-2 text-muted">Loading contacts...</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="existingEmpty" class="empty-state" style="display: none;">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<p class="mb-0">No contacts found</p>
|
||||
<small class="text-muted">Contacts will appear here once added</small>
|
||||
</div>
|
||||
|
||||
<!-- Existing Contacts List -->
|
||||
<div id="existingList"></div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div id="existingError" class="alert alert-danger" style="display: none;" role="alert">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<span id="existingErrorMessage">Failed to load contacts</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteContactModal" tabindex="-1" aria-labelledby="deleteContactModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title" id="deleteContactModalLabel">
|
||||
<i class="bi bi-exclamation-triangle"></i> Confirm Delete
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-2">Are you sure you want to delete this contact?</p>
|
||||
<div class="alert alert-warning mb-0">
|
||||
<strong id="deleteContactName"></strong><br>
|
||||
<small class="font-monospace" id="deleteContactKey"></small>
|
||||
</div>
|
||||
<p class="text-muted small mt-2 mb-0">This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
|
||||
<i class="bi bi-trash"></i> Delete Contact
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast container for notifications (position classes applied by JS from ui_settings) -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3" data-toast-container>
|
||||
<div id="contactToast" class="toast" role="alert">
|
||||
<div class="toast-header">
|
||||
<strong class="me-auto">Contact Management</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="toast-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('static', filename='js/contacts.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light" data-bs-theme="light">
|
||||
<html lang="{{ lang }}" data-theme="light" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
@@ -8,12 +8,14 @@
|
||||
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
|
||||
<script>
|
||||
(function() {
|
||||
var t = localStorage.getItem('mc-webui-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
document.documentElement.setAttribute('data-bs-theme', t);
|
||||
var theme = localStorage.getItem('mc-webui-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% include "_head_i18n.html" %}
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light" data-bs-theme="light">
|
||||
<html lang="{{ lang }}" data-theme="light" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
@@ -8,9 +8,9 @@
|
||||
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
|
||||
<script>
|
||||
(function() {
|
||||
var t = localStorage.getItem('mc-webui-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
document.documentElement.setAttribute('data-bs-theme', t);
|
||||
var theme = localStorage.getItem('mc-webui-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% include "_head_i18n.html" %}
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{{ lang }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>System Log - mc-webui</title>
|
||||
|
||||
{% include "_head_i18n.html" %}
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light" data-bs-theme="light">
|
||||
<html lang="{{ lang }}" data-theme="light" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
@@ -8,12 +8,14 @@
|
||||
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
|
||||
<script>
|
||||
(function() {
|
||||
var t = localStorage.getItem('mc-webui-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
document.documentElement.setAttribute('data-bs-theme', t);
|
||||
var theme = localStorage.getItem('mc-webui-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% include "_head_i18n.html" %}
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light" data-bs-theme="light">
|
||||
<html lang="{{ lang }}" data-theme="light" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
@@ -8,12 +8,14 @@
|
||||
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
|
||||
<script>
|
||||
(function() {
|
||||
var t = localStorage.getItem('mc-webui-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
document.documentElement.setAttribute('data-bs-theme', t);
|
||||
var theme = localStorage.getItem('mc-webui-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% include "_head_i18n.html" %}
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light" data-bs-theme="light">
|
||||
<html lang="{{ lang }}" data-theme="light" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
@@ -8,12 +8,14 @@
|
||||
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
|
||||
<script>
|
||||
(function() {
|
||||
var t = localStorage.getItem('mc-webui-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
document.documentElement.setAttribute('data-bs-theme', t);
|
||||
var theme = localStorage.getItem('mc-webui-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% include "_head_i18n.html" %}
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"meta.language_english_name": "English",
|
||||
"meta.language_name": "English",
|
||||
"meta.translator": "mc-webui"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"meta.language_english_name": "Polish",
|
||||
"meta.language_name": "Polski",
|
||||
"meta.translator": "mc-webui"
|
||||
}
|
||||
@@ -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 <you@example.com>",
|
||||
|
||||
"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} <value>` 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 `<b>` or `<a href="...">`. 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 `key<TAB>English 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.
|
||||
@@ -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'''(?<![\w.$])(t|tn|tHtml|t_html)\s*\(\s*(['"])((?:(?!\2).)*)\2''')
|
||||
PARAM_RE = re.compile(r'\{(\w+)\}')
|
||||
SHADOW_RE = re.compile(r'(?:const|let|var)\s+t\s*=')
|
||||
|
||||
HTML_SINK_RE = re.compile(r'innerHTML|insertAdjacentHTML')
|
||||
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
|
||||
def err(msg): errors.append(msg)
|
||||
def warn(msg): warnings.append(msg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_catalog(lang: str) -> dict:
|
||||
path = TRANSLATIONS / f'{lang}.json'
|
||||
try:
|
||||
return json.loads(path.read_text(encoding='utf-8'))
|
||||
except FileNotFoundError:
|
||||
err(f"{path.relative_to(REPO)}: not found")
|
||||
return {}
|
||||
except json.JSONDecodeError as e:
|
||||
err(f"{path.relative_to(REPO)}: invalid JSON — {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def available_langs() -> list[str]:
|
||||
return sorted(p.stem for p in TRANSLATIONS.glob('*.json'))
|
||||
|
||||
|
||||
def scan_sources() -> tuple[dict[str, set[str]], dict[str, list[str]]]:
|
||||
"""Return (key -> set of call kinds used, key -> list of 'file:line' sites)."""
|
||||
kinds: dict[str, set[str]] = defaultdict(set)
|
||||
sites: dict[str, list[str]] = defaultdict(list)
|
||||
|
||||
for pattern in SCAN_GLOBS:
|
||||
for path in sorted(REPO.glob(pattern)):
|
||||
rel = path.relative_to(REPO).as_posix()
|
||||
for lineno, line in enumerate(path.read_text(encoding='utf-8').splitlines(), 1):
|
||||
for func, _, key in CALL_RE.findall(line):
|
||||
kinds[key].add(func)
|
||||
sites[key].append(f'{rel}:{lineno}')
|
||||
|
||||
# A t() result landing in innerHTML must be tHtml() — its params are
|
||||
# escaped, so interpolated user data cannot inject markup.
|
||||
if HTML_SINK_RE.search(line) and re.search(r'\$\{\s*t\s*\(', line):
|
||||
err(f'{rel}:{lineno}: t() inside an HTML sink — use tHtml()')
|
||||
|
||||
if SHADOW_RE.search(line):
|
||||
err(f'{rel}:{lineno}: `t` is assigned here, shadowing the global '
|
||||
f'translation helper — rename the variable')
|
||||
|
||||
return kinds, sites
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def value_strings(value) -> list[str]:
|
||||
"""All text variants of a catalog value (plural objects have several)."""
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
if isinstance(value, dict):
|
||||
return [v for v in value.values() if isinstance(v, str)]
|
||||
return []
|
||||
|
||||
|
||||
def placeholders(value) -> set[str]:
|
||||
out: set[str] = set()
|
||||
for text in value_strings(value):
|
||||
out |= set(PARAM_RE.findall(text))
|
||||
return out
|
||||
|
||||
|
||||
def check_usage(en: dict, kinds: dict[str, set[str]], sites: dict[str, list[str]]):
|
||||
for key in sorted(kinds):
|
||||
if key not in en:
|
||||
where = sites[key][0]
|
||||
err(f'{where}: key not in en.json — {key!r}')
|
||||
|
||||
unused = set(en) - set(kinds) - META_KEYS
|
||||
for key in sorted(unused):
|
||||
warn(f'en.json: unused key {key!r}')
|
||||
|
||||
# Markup in a catalog value only survives through the _html variants.
|
||||
for key, value in sorted(en.items()):
|
||||
if key not in kinds:
|
||||
continue
|
||||
has_markup = any('<' in text for text in value_strings(value))
|
||||
used_plain = bool(kinds[key] & {'t', 'tn'})
|
||||
used_html = bool(kinds[key] & {'tHtml', 't_html'})
|
||||
|
||||
if has_markup and used_plain:
|
||||
err(f'{sites[key][0]}: {key!r} contains markup but is used via t()/tn() — '
|
||||
f'use tHtml()/t_html()')
|
||||
if not has_markup and used_html:
|
||||
warn(f'{sites[key][0]}: {key!r} has no markup but is used via the _html '
|
||||
f'variant — t() is enough')
|
||||
|
||||
|
||||
def check_language(lang: str, en: dict, catalog: dict) -> float:
|
||||
translated = [k for k in en if k in catalog]
|
||||
coverage = 100.0 * len(translated) / len(en) if en else 100.0
|
||||
|
||||
for key in sorted(catalog):
|
||||
if key not in en:
|
||||
warn(f'{lang}.json: key not in en.json — {key!r} (renamed or removed?)')
|
||||
|
||||
for key in sorted(translated):
|
||||
want, got = placeholders(en[key]), placeholders(catalog[key])
|
||||
if want != got:
|
||||
missing = ', '.join(sorted(want - got)) or '-'
|
||||
extra = ', '.join(sorted(got - want)) or '-'
|
||||
err(f'{lang}.json: {key!r} placeholder mismatch — missing: {missing}; '
|
||||
f'unexpected: {extra}')
|
||||
|
||||
# Deleting a glossary term is a bug; inflecting it ("hopów") is fine, so this
|
||||
# is a substring test and only ever a warning.
|
||||
en_text = ' '.join(value_strings(en[key])).lower()
|
||||
tr_text = ' '.join(value_strings(catalog[key])).lower()
|
||||
for term in GLOSSARY:
|
||||
if re.search(rf'\b{re.escape(term.lower())}', en_text) and term.lower() not in tr_text:
|
||||
warn(f'{lang}.json: {key!r} drops the glossary term {term!r}')
|
||||
|
||||
return coverage
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('--lang', help='check only this language')
|
||||
parser.add_argument('--missing', metavar='LANG',
|
||||
help='print untranslated keys for LANG and exit')
|
||||
args = parser.parse_args()
|
||||
|
||||
en = load_catalog('en')
|
||||
if not en:
|
||||
print('\n'.join(errors), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.missing:
|
||||
catalog = load_catalog(args.missing)
|
||||
for key in sorted(set(en) - set(catalog) - META_KEYS):
|
||||
value = en[key]
|
||||
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
|
||||
print(f'{key}\t{text}')
|
||||
return 0
|
||||
|
||||
kinds, sites = scan_sources()
|
||||
check_usage(en, kinds, sites)
|
||||
|
||||
langs = [args.lang] if args.lang else [l for l in available_langs() if l != 'en']
|
||||
coverage = {lang: check_language(lang, en, load_catalog(lang)) for lang in langs}
|
||||
|
||||
print(f'en.json: {len(en)} keys, {len(kinds)} used in code\n')
|
||||
if coverage:
|
||||
print('Coverage')
|
||||
for lang, pct in sorted(coverage.items()):
|
||||
print(f' {lang:6} {pct:5.1f}%')
|
||||
print()
|
||||
|
||||
for msg in warnings:
|
||||
print(f'WARN {msg}')
|
||||
for msg in errors:
|
||||
print(f'ERROR {msg}', file=sys.stderr)
|
||||
|
||||
print(f'\n{len(errors)} error(s), {len(warnings)} warning(s)')
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user