Files
ProxMenux/.github/scripts/build_i18n_messages.py
T
MacRimi 5c33852a65 feat(i18n): add Swedish support + include Slovak in auto-fill defaults
Two related changes to the translation infrastructure:

1. Add Swedish (sv) as a supported locale in both the CLI and Monitor
   translation systems. Inspired by PR #121, which arrived before the
   current auto-generated cache layout existed.

2. Include Slovak (sk) in the default auto-fill target set. Guardrail #1
   in build_i18n_messages.py never overwrites a key whose target value
   differs from EN, so Vaso73's curated Slovak strings stay intact —
   auto-translation only fills keys that are still on the English
   fallback. Trade-off accepted: sk users see decent machine translation
   for new keys immediately instead of raw English while human curation
   catches up, and Vaso73 keeps full ownership of the wording via
   follow-up PRs.

Changes:
- AppImage/lib/i18n/languages.ts: add "sv" to LanguageCode + register
  in SUPPORTED_LANGUAGES.
- AppImage/messages/sv/common.json: stub — will be filled by the i18n
  workflow on the local bootstrap.
- .github/scripts/build_translation_cache.py: add "sv" to
  DEFAULT_LANGUAGES.
- .github/scripts/build_i18n_messages.py: add "sk" and "sv" to
  DEFAULT_LANGUAGES; update comments to explain the sk decision.
- .github/workflows/build-i18n-messages.yml: default input includes
  sk + sv; comments updated to match.
2026-08-09 10:35:03 +02:00

324 lines
12 KiB
Python

#!/usr/bin/env python3
"""
Auto-translate missing keys in AppImage/messages/<locale>/common.json
against the English source (AppImage/messages/en/common.json).
Guardrails:
- Keys already translated in a target locale are PRESERVED. A key is
considered "already translated" when the target value is non-empty
AND differs from the English source. This protects human-curated
locales (Vaso73's sk) from being overwritten.
- `{placeholder}` tokens (next-intl style: `{vmid}`, `{appName}`, etc.)
are extracted before translation and restored afterwards, so the
interpolation contract stays intact regardless of what the
translation provider does with the surrounding text.
- `sk` IS translated by default too. Guardrail #1 protects every key
Vaso73 has curated by hand; auto-translation only fills the keys
that are still on the English fallback in sk.
Reuses the same translation providers as build_translation_cache.py so
the CI environment (googletrans pinning, AppImage provider) stays
identical.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from pathlib import Path
# Reuse providers + cleaner from the CLI translation script.
sys.path.insert(0, str(Path(__file__).parent))
from build_translation_cache import ( # noqa: E402
clean_translation,
translate_appimage,
translate_google_web,
translate_googletrans,
)
# sk IS included in the default. Guardrail #1 (never overwrite a key
# whose target value differs from EN) protects every string Vaso73 has
# already curated by hand — auto-translation only ever touches keys
# that are still on the English fallback in sk. Trade-off accepted:
# users on sk see a decent auto-translation for new keys instead of raw
# English while the human maintainer catches up, and Vaso73 keeps full
# ownership of the wording via follow-up PRs.
DEFAULT_LANGUAGES = ("es", "de", "fr", "it", "pt", "sk", "sv")
DEFAULT_CONTEXT = "Context: Technical UI text for a Proxmox management dashboard. Translate:"
# next-intl / ICU-style placeholders: {name}, {vmid}, {count}, {app_name}.
# We deliberately do NOT match `{{ escaped }}` or nested braces — the
# codebase uses only the simple form.
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")
def flatten(node: dict, prefix: str = "") -> dict[str, str]:
"""Depth-first flatten of a nested dict into ``{"a.b.c": "value"}``.
Non-string leaves are coerced to str (should not happen in messages
catalogs, but keeps the function total)."""
out: dict[str, str] = {}
for key, value in node.items():
path = f"{prefix}{key}" if not prefix else f"{prefix}.{key}"
if isinstance(value, dict):
out.update(flatten(value, path))
elif value is None:
out[path] = ""
else:
out[path] = str(value)
return out
def unflatten(flat: dict[str, str]) -> dict:
"""Inverse of ``flatten``: rebuild nested structure from dotted keys."""
out: dict = {}
for path, value in flat.items():
parts = path.split(".")
cursor = out
for part in parts[:-1]:
existing = cursor.get(part)
if not isinstance(existing, dict):
existing = {}
cursor[part] = existing
cursor = existing
cursor[parts[-1]] = value
return out
def protect_placeholders(text: str) -> tuple[str, list[str]]:
"""Swap each ``{xxx}`` for an opaque token that machine translators
tend to leave alone. Order is preserved so restore_placeholders can
walk it linearly."""
placeholders: list[str] = []
def _swap(match: re.Match) -> str:
placeholders.append(match.group(0))
return f"__PMX_PH_{len(placeholders) - 1}__"
return PLACEHOLDER_RE.sub(_swap, text), placeholders
def restore_placeholders(text: str, placeholders: list[str]) -> str:
"""Reverse of ``protect_placeholders``. If the provider mangled a
token beyond recognition we leave the mangled form in place — the
fallback assignment (target = existing or EN) upstream catches
the worst case."""
for i, original in enumerate(placeholders):
text = text.replace(f"__PMX_PH_{i}__", original)
return text
def translate_one(
text: str,
lang: str,
provider: str,
context: str,
timeout: int,
appimage_path: Path,
) -> str:
"""Dispatch to the correct provider. Reuses the same three
implementations as build_translation_cache.py so there is exactly
one place to fix if a provider changes upstream."""
if provider == "googletrans":
raw = translate_googletrans(text, lang, context)
elif provider == "google-web":
raw = translate_google_web(text, lang, context, timeout)
elif provider == "appimage":
raw = translate_appimage(text, lang, context, timeout, appimage_path)
else:
raise ValueError(f"Unknown provider: {provider}")
return clean_translation(raw) or text
def read_json(path: Path) -> dict:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return {}
except json.JSONDecodeError as exc:
raise SystemExit(f"Invalid JSON at {path}: {exc}") from exc
def write_json(path: Path, data: dict) -> None:
"""Write with indent=2, no sort_keys — we want to keep the same
top-level ordering the maintainer uses in en/common.json so diffs
stay readable side-by-side."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(
json.dumps(data, ensure_ascii=False, indent=2, sort_keys=False) + "\n",
encoding="utf-8",
)
tmp.replace(path)
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--source",
type=Path,
default=Path("AppImage/messages/en/common.json"),
help="Path to the English source catalog.",
)
parser.add_argument(
"--messages-dir",
type=Path,
default=Path("AppImage/messages"),
help="Directory that contains per-locale subdirectories.",
)
parser.add_argument(
"--languages",
default=",".join(DEFAULT_LANGUAGES),
help=(
"Comma-separated target locales. Includes sk by default; "
"guardrail #1 never overwrites keys whose sk value differs "
"from EN, so Vaso73's curated translations are safe."
),
)
parser.add_argument(
"--provider",
choices=("appimage", "googletrans", "google-web"),
default="googletrans",
help="Translation provider. Default matches build_translation_cache.",
)
parser.add_argument(
"--appimage-path",
type=Path,
default=Path("/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage"),
)
parser.add_argument("--context", default=DEFAULT_CONTEXT)
parser.add_argument("--timeout", type=int, default=30)
parser.add_argument("--sleep", type=float, default=0.15)
parser.add_argument(
"--refresh",
action="store_true",
help=(
"Re-translate EVERY key, ignoring existing translations. "
"Dangerous: this DOES overwrite human-curated strings. "
"Use only when you know what you are doing."
),
)
parser.add_argument(
"--limit",
type=int,
default=0,
help="Only translate the first N missing keys per locale (test runs).",
)
parser.add_argument(
"--save-every",
type=int,
default=50,
help="Write the locale JSON every N translated keys so a crash mid-run leaves partial progress on disk.",
)
return parser
def main() -> int:
args = build_arg_parser().parse_args()
source = args.source.resolve()
messages_dir = args.messages_dir.resolve()
languages = [lang.strip() for lang in args.languages.split(",") if lang.strip()]
if not source.is_file():
print(f"Source not found: {source}", file=sys.stderr)
return 1
if not languages:
print("No destination languages selected.", file=sys.stderr)
return 1
en_nested = read_json(source)
en_flat = flatten(en_nested)
print(f"Source: {source}", flush=True)
print(f"EN keys: {len(en_flat)}", flush=True)
print(f"Target locales: {', '.join(languages)}", flush=True)
print(f"Provider: {args.provider}", flush=True)
print(f"Sleep between calls: {args.sleep}s", flush=True)
total_failures: list[tuple[str, str, str]] = []
for lang in languages:
locale_path = messages_dir / lang / "common.json"
target_flat = flatten(read_json(locale_path))
# Decide what needs translating.
# - refresh=True → every EN key
# - refresh=False → only keys where target is empty OR equals EN
# (i.e. "not yet translated by a human")
missing: list[str] = []
for key, en_value in en_flat.items():
if not en_value:
continue
existing = target_flat.get(key, "")
if args.refresh:
missing.append(key)
elif not existing or existing == en_value:
missing.append(key)
if args.limit > 0:
missing = missing[: args.limit]
print(f"\n=== {lang}: {len(missing)} keys to translate ===", flush=True)
if not missing:
print(f" {lang}: nothing to do", flush=True)
continue
failures_for_lang: list[tuple[str, str, str]] = []
for index, key in enumerate(missing, start=1):
en_value = en_flat[key]
protected, placeholders = protect_placeholders(en_value)
try:
translated = translate_one(
protected,
lang,
args.provider,
args.context,
args.timeout,
args.appimage_path,
)
target_flat[key] = restore_placeholders(translated, placeholders)
print(
f" [{lang} {index}/{len(missing)}] {key}: "
f"{en_value[:60]!r}{target_flat[key][:60]!r}",
flush=True,
)
except Exception as exc: # noqa: BLE001
# Fall back to whatever we already had (or EN) so the
# runtime fallback still kicks in for this key.
target_flat[key] = target_flat.get(key) or en_value
failures_for_lang.append((lang, key, str(exc)))
print(f" [{lang}] {key}: FAILED — {exc}", file=sys.stderr, flush=True)
if args.save_every > 0 and index % args.save_every == 0:
# Preserve keys already in target_flat + write partial progress.
write_json(locale_path, unflatten(target_flat))
time.sleep(args.sleep)
write_json(locale_path, unflatten(target_flat))
print(f" wrote {locale_path}", flush=True)
total_failures.extend(failures_for_lang)
if total_failures:
print(
f"\nCompleted with {len(total_failures)} translation failures.",
file=sys.stderr,
flush=True,
)
for lang, key, error in total_failures[:20]:
print(f" - {lang}: {key}{error}", file=sys.stderr, flush=True)
if len(total_failures) > 20:
print(f" ... and {len(total_failures) - 20} more.", file=sys.stderr, flush=True)
return 2
print("\ni18n messages generated successfully.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())