mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-08-24 05:04:33 +00:00
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>
307 lines
9.5 KiB
Python
307 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Fill missing content translations from a LibreTranslate instance.
|
|
|
|
Reads the prose strings out of the live ``app/config.yaml`` and writes one
|
|
catalogue per language to ``app/i18n/content/``. Entries that already exist are
|
|
never overwritten, so a hand-corrected translation survives every later run.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
import yaml
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
from app.utils import i18n # noqa: E402
|
|
|
|
DEFAULT_CONFIG_PATH = REPO_ROOT / "app" / "config.yaml"
|
|
DEFAULT_KEEP_PATH = i18n.I18N_DIR / "keep.txt"
|
|
REQUEST_TIMEOUT = 30
|
|
|
|
|
|
def collect_sources(node, key=None, found=None):
|
|
"""Return every prose string in ``node`` that a backend may translate.
|
|
|
|
Args:
|
|
node: the raw configuration tree, or any subtree of it.
|
|
key: the mapping key ``node`` was reached through.
|
|
found: accumulator, for recursion.
|
|
"""
|
|
found = set() if found is None else found
|
|
if isinstance(node, dict):
|
|
for name, value in node.items():
|
|
collect_sources(value, name, found)
|
|
elif isinstance(node, list):
|
|
for item in node:
|
|
collect_sources(item, key, found)
|
|
elif isinstance(node, str) and key in i18n.TRANSLATABLE_KEYS and node.strip():
|
|
found.add(node)
|
|
return found
|
|
|
|
|
|
def read_keep(path):
|
|
"""Return the strings listed in ``path``, ignoring blanks and comments.
|
|
|
|
Args:
|
|
path: a text file with one string per line, or a path that does not exist.
|
|
"""
|
|
if not path.is_file():
|
|
return []
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
return [
|
|
stripped
|
|
for stripped in (line.strip() for line in lines)
|
|
if stripped and not stripped.startswith("#")
|
|
]
|
|
|
|
|
|
class BackendError(Exception):
|
|
"""The translation backend answered in a way the run cannot continue from."""
|
|
|
|
|
|
def supported_languages(url, session):
|
|
"""Return the language codes the LibreTranslate instance at ``url`` offers."""
|
|
try:
|
|
response = session.get(f"{url}/languages", timeout=REQUEST_TIMEOUT)
|
|
response.raise_for_status()
|
|
offered = response.json()
|
|
except requests.RequestException as error:
|
|
raise BackendError(f"{url} is not reachable: {error}")
|
|
except ValueError:
|
|
raise BackendError(f"{url}/languages did not answer JSON")
|
|
|
|
if not isinstance(offered, list):
|
|
raise BackendError(
|
|
f"{url}/languages answered {type(offered).__name__}, not a list"
|
|
)
|
|
codes = {
|
|
entry["code"]
|
|
for entry in offered
|
|
if isinstance(entry, dict) and isinstance(entry.get("code"), str)
|
|
}
|
|
if not codes:
|
|
raise BackendError(f"{url}/languages listed no usable language codes")
|
|
return codes
|
|
|
|
|
|
def load_existing(path):
|
|
"""Return the catalogue at ``path``, or None when it must not be rewritten.
|
|
|
|
Distinct from ``i18n.read_catalog``, which degrades an unreadable catalogue
|
|
to English at render time. Here the same file means the hand-written entries
|
|
are unknown, and writing would replace them with a fresh machine pass.
|
|
"""
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
except (OSError, UnicodeDecodeError, yaml.YAMLError) as error:
|
|
print(f" ! {path.name}: {type(error).__name__}, refusing to overwrite it")
|
|
return None
|
|
if loaded is None:
|
|
return {}
|
|
if not isinstance(loaded, dict):
|
|
print(f" ! {path.name}: not a mapping, refusing to overwrite it")
|
|
return None
|
|
return loaded
|
|
|
|
|
|
def write_catalog(path, catalog):
|
|
"""Replace ``path`` with ``catalog`` in one step.
|
|
|
|
Writing in place would leave a half-written catalogue behind if the process
|
|
is interrupted or the disk fills, and the truncated remainder can still be
|
|
valid YAML — the next run would then machine-fill the destroyed entries.
|
|
"""
|
|
temporary = path.with_name(f"{path.name}.tmp")
|
|
temporary.write_text(
|
|
yaml.safe_dump(catalog, allow_unicode=True, sort_keys=True, width=1000),
|
|
encoding="utf-8",
|
|
)
|
|
os.replace(temporary, path)
|
|
|
|
|
|
def translate(session, url, api_key, text, target):
|
|
"""Translate one string into ``target``, or return None on failure."""
|
|
payload = {
|
|
"q": text,
|
|
"source": i18n.SOURCE_LANGUAGE,
|
|
"target": target,
|
|
"format": "text",
|
|
}
|
|
if api_key:
|
|
payload["api_key"] = api_key
|
|
|
|
try:
|
|
response = session.post(
|
|
f"{url}/translate", data=payload, timeout=REQUEST_TIMEOUT
|
|
)
|
|
except requests.RequestException as error:
|
|
print(f" ! {target}: {type(error).__name__}, {error}")
|
|
return None
|
|
|
|
if not response.ok:
|
|
print(f" ! {target}: {response.status_code} {response.text[:120]}")
|
|
return None
|
|
|
|
try:
|
|
translated = response.json().get("translatedText")
|
|
except ValueError:
|
|
print(f" ! {target}: answered 200 but not JSON")
|
|
return None
|
|
|
|
if not isinstance(translated, str) or not translated.strip():
|
|
print(f" ! {target}: unusable translatedText ({translated!r})")
|
|
return None
|
|
return translated
|
|
|
|
|
|
def sync(url, api_key, sources, targets, directory, keep=()):
|
|
"""Fill and write the catalogue of every language in ``targets``.
|
|
|
|
Args:
|
|
url: base URL of the LibreTranslate instance.
|
|
api_key: API key, or an empty string.
|
|
sources: English strings to translate.
|
|
targets: language codes to fill.
|
|
directory: catalogue directory to write into.
|
|
keep: strings to store as themselves instead of translating.
|
|
"""
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
session = requests.Session()
|
|
|
|
available = supported_languages(url, session)
|
|
for target in targets:
|
|
if target not in available:
|
|
print(f"- {target}: not offered by {url}, skipped")
|
|
continue
|
|
|
|
path = directory / f"{target}.yaml"
|
|
catalog = load_existing(path)
|
|
if catalog is None:
|
|
continue
|
|
|
|
protected = {word: word for word in keep if word not in catalog}
|
|
catalog.update(protected)
|
|
|
|
shipped = (
|
|
{}
|
|
if directory == i18n.UI_DIR
|
|
else i18n.read_catalog(i18n.UI_DIR / f"{target}.yaml")
|
|
)
|
|
missing = sorted(
|
|
source
|
|
for source in sources
|
|
if source not in catalog and source not in shipped
|
|
)
|
|
if not missing:
|
|
print(f"- {target}: complete")
|
|
continue
|
|
|
|
print(f"- {target}: translating {len(missing)} string(s)")
|
|
added = 0
|
|
for source in missing:
|
|
translated = translate(session, url, api_key, source, target)
|
|
if translated:
|
|
catalog[source] = translated
|
|
added += 1
|
|
|
|
if not added and not protected:
|
|
print(f" ! {target}: nothing translated, leaving the file untouched")
|
|
continue
|
|
|
|
try:
|
|
write_catalog(path, catalog)
|
|
except OSError as error:
|
|
print(f" ! {target}: could not write {path.name}: {error}")
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--url",
|
|
required=True,
|
|
help="Base URL of a LibreTranslate instance, e.g. http://localhost:5002",
|
|
)
|
|
parser.add_argument(
|
|
"--api-key",
|
|
default="",
|
|
help="API key, if the instance requires one.",
|
|
)
|
|
parser.add_argument(
|
|
"--config",
|
|
type=Path,
|
|
default=DEFAULT_CONFIG_PATH,
|
|
help=f"Configuration to read the prose from (default: {DEFAULT_CONFIG_PATH}).",
|
|
)
|
|
parser.add_argument(
|
|
"--catalog",
|
|
choices=("content", "ui"),
|
|
default="content",
|
|
help=(
|
|
"content: your configuration's prose, generated per deployment. "
|
|
"ui: the interface strings that ship with the project."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--keep",
|
|
nargs="+",
|
|
default=[],
|
|
metavar="STRING",
|
|
help=(
|
|
"Store these as themselves instead of translating, "
|
|
"in addition to --keep-file."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--keep-file",
|
|
type=Path,
|
|
default=DEFAULT_KEEP_PATH,
|
|
help=(
|
|
f"One string per line, stored untranslated (default: {DEFAULT_KEEP_PATH})."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--languages",
|
|
nargs="+",
|
|
default=[code for code in i18n.LANGUAGES if code != i18n.SOURCE_LANGUAGE],
|
|
help="Language codes to fill (default: every shipped language).",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
if args.catalog == "ui":
|
|
directory = i18n.UI_DIR
|
|
sources = set(i18n.UI_STRINGS)
|
|
print(f"{len(sources)} interface string(s)")
|
|
else:
|
|
directory = i18n.CONTENT_DIR
|
|
config = yaml.safe_load(args.config.read_text(encoding="utf-8"))
|
|
sources = collect_sources(config) | set(i18n.UI_STRINGS)
|
|
print(f"{len(sources)} string(s) in {args.config} and the interface")
|
|
|
|
keep = read_keep(args.keep_file) + args.keep
|
|
print(f"{len(keep)} string(s) kept untranslated")
|
|
|
|
try:
|
|
sync(
|
|
args.url.rstrip("/"),
|
|
args.api_key,
|
|
sources,
|
|
args.languages,
|
|
directory,
|
|
keep,
|
|
)
|
|
except BackendError as error:
|
|
print(f"ERROR: {error}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|