"""Language negotiation and translation of the resolved configuration tree. Translation is catalogue-driven: a string is replaced only when the target language's catalogue holds an entry for the exact English source string. Anything unknown falls through to English, so a partially filled catalogue degrades instead of breaking. """ import logging from pathlib import Path import yaml try: from app.utils.languages import LANGUAGES, RTL_LANGUAGES except ImportError: # pragma: no cover - supports running from the app/ directory. from utils.languages import LANGUAGES, RTL_LANGUAGES I18N_DIR = Path(__file__).resolve().parent.parent / "i18n" UI_DIR = I18N_DIR / "ui" CONTENT_DIR = I18N_DIR / "content" SOURCE_LANGUAGE = "en" TRANSLATABLE_KEYS = frozenset( {"description", "info", "name", "subtitel", "text", "title", "warning"} ) UI_STRINGS = ( "Alternatives", "Close", "Copy", "Identifier copied to clipboard!", "Imprint", "Information", "Language", "Open", "Open Link", "Options", "Warning", ) _catalogs: dict[str, dict[str, str]] = {} def direction(code): """Return the writing direction of ``code`` as an HTML ``dir`` value.""" return "rtl" if code in RTL_LANGUAGES else "ltr" def read_catalog(path): """Return the catalogue at ``path``, or an empty one if it is unusable. Catalogues are hand-edited and machine-written, so a stray character must degrade that language to English rather than take every page down with a parse error. Non-string entries are dropped for the same reason: they would otherwise reach the templates and render as ``42`` or ``null``. """ if not path.exists(): return {} try: loaded = yaml.safe_load(path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, yaml.YAMLError): logging.warning("Ignoring unreadable translation catalogue: %s", path) return {} if not isinstance(loaded, dict): logging.warning( "Ignoring translation catalogue that is not a mapping: %s", path ) return {} return { key: value for key, value in loaded.items() if isinstance(key, str) and isinstance(value, str) } def clear_catalogs(): """Drop the memoized catalogues so edited files are picked up.""" _catalogs.clear() def catalog(code): """Return the merged UI and content catalogue for ``code``.""" if code not in _catalogs: _catalogs[code] = { **read_catalog(UI_DIR / f"{code}.yaml"), **read_catalog(CONTENT_DIR / f"{code}.yaml"), } return _catalogs[code] def negotiate(accepted, default=SOURCE_LANGUAGE): """Pick the best supported language from ``Accept-Language`` pairs. Args: accepted: iterable of ``(tag, quality)`` as produced by ``flask.request.accept_languages``. default: language returned when no tag is supported. Werkzeug's own ``best_match`` returns an exact match before it considers primary-tag fallbacks, so ``de-DE,en;q=0.8`` resolves to English. Matching on the primary subtag up front avoids that. """ best, best_quality = default, 0.0 for tag, quality in accepted: code = tag.replace("_", "-").split("-")[0].lower() if code in LANGUAGES and quality > best_quality: best, best_quality = code, quality return best def translate_tree(node, code, key=None): """Return a copy of ``node`` with translatable leaves swapped for ``code``. Args: node: the resolved configuration tree, or any subtree of it. code: target language code. key: the mapping key ``node`` was reached through. """ if isinstance(node, dict): return {name: translate_tree(value, code, name) for name, value in node.items()} if isinstance(node, list): return [translate_tree(item, code, key) for item in node] if isinstance(node, str) and key in TRANSLATABLE_KEYS: return catalog(code).get(node, node) return node def ui_strings(code): """Return the interface strings for ``code``, keyed by their English source.""" entries = catalog(code) return {source: entries.get(source, source) for source in UI_STRINGS}