Files
homepage.veen.world/app/utils/i18n.py
Kevin Veen-Birkenbach e96e8684e6 feat(i18n): translate labels, and keep brand names out of it
name and title were translated at render time but never machine-filled,
on the grounds that no backend tells the menu label "Pictures" from the
brand "Mastodon". That left the visible half of a card in English. They
are filled now, and the two key sets collapse into one.

The brands need somewhere to be named instead. app/i18n/keep.txt lists
them, one per line, and every entry is stored as itself in every target
language: no request, and never over an entry written by hand. --keep adds
one-off strings, --keep-file points elsewhere.

The shipped list holds the 43 product names that appear as name: or title:
in config.sample.yaml. Generic labels — Pictures, Imprint, Settings,
Certificates — are deliberately absent, and so are Cybermaster, Polymath
and Yachtmaster, which read as brand or as job title depending on who is
asking.

Two of these behaviours first shipped unguarded. A test that protected a
string and asserted the hand-written value survived passed either way,
because a run where nothing is missing reports "complete" and never
writes; and nothing exercised main(), so the keep file could stop being
read without a failure. Both are covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:19:16 +02:00

135 lines
4.2 KiB
Python

"""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}