#!/usr/bin/env python3 """ Auto-translate missing keys in AppImage/messages//common.json against the English source (AppImage/messages/en/common.json). Guardrails: - Non-empty string translations are PRESERVED, including values equal to English. --refresh explicitly opts into replacing valid strings. - Arrays, objects and non-string leaves retain their JSON types. Array entries are translated individually, never as serialized Python text. - Preflight all locales; skip locales with existing type mismatches without provider calls or writes, including with --refresh. Valid locales proceed, but any skipped locale makes the run exit 1 (partial failure). The diagnostic identifies the locale and JSON Pointer requiring manual reconciliation. No automatic migration of serialized arrays is attempted: parseability or equal length cannot prove correspondence to current English indices. Recover human text only after checking its original source/order; do not delete it to force refill. Null string leaves remain eligible for filling, as in earlier versions. - `{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. Workflow limitation: build-i18n-messages.yml runs Commit + push only after successful generation. Exit 1 for skipped locales prevents that step, even when valid locales made local progress. This does not restore automatic publication for the default locale set until mismatches are reconciled or workflow policy is separately changed. Invalid JSON syntax still aborts preflight globally; per-locale skipping covers parsed catalog type mismatches. 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, protect_technical_terms, restore_technical_terms, 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_]*\}") CatalogPath = tuple[str | int, ...] def flatten(node: object, prefix: CatalogPath = ()) -> dict[CatalogPath, object]: """Keep typed path segments and empty container markers, without coercion.""" if isinstance(node, dict): out = {prefix: {}} children = node.items() elif isinstance(node, list): out = {prefix: []} children = enumerate(node) else: return {prefix: node} for key, value in children: out.update(flatten(value, prefix + (key,))) return out def unflatten(flat: dict[CatalogPath, object]) -> object: """Rebuild a tree from typed paths; parents precede children by depth.""" nodes = {} for path in sorted(flat, key=len): value = flat[path] nodes[path] = {} if isinstance(value, dict) else [] if isinstance(value, list) else value if path: parent = nodes[path[:-1]] key = path[-1] if isinstance(parent, list): while len(parent) <= key: parent.append(None) parent[key] = nodes[path] return nodes[()] 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, CatalogPath, str]] = [] # Preflight every locale before any provider call or write. Serialized # arrays have no trustworthy index correspondence, even if JSON parses. # Never guess, evaluate Python repr, or discard human text on --refresh. targets = {} skipped = set() for lang in languages: locale_path = messages_dir / lang / "common.json" target_flat = flatten(read_json(locale_path)) targets[lang] = target_flat for key, en_value in en_flat.items(): if key not in target_flat: continue existing = target_flat[key] if isinstance(en_value, str) and existing is None: continue # Historical null string leaves are treated as empty. if type(existing) is not type(en_value): pointer = "".join("/" + str(part).replace("~", "~0").replace("/", "~1") for part in key) location = f"JSON Pointer {json.dumps(pointer)}" + (" (root)" if not key else "") print( f"{locale_path}: {location}: expected {type(en_value).__name__}, " f"found {type(existing).__name__}; manual reconciliation required. " "Locale skipped; its catalog will not be written.", file=sys.stderr, ) skipped.add(lang) for lang in languages: if lang in skipped: continue locale_path = messages_dir / lang / "common.json" target_flat = targets[lang] # Decide what needs translating. Same rule as # build_translation_cache.py: only touch keys whose target # value is empty. Never overwrite an existing value — that # covers three legitimate cases in one line: # 1. Human-curated translations (protected trivially). # 2. Universal tokens the maintainer left equal to EN on # purpose (Hardware, Terminal, Normal, SMART, OK, CPU %, # {count}h, product names, etc.). Around 120 keys in # es/common.json — the old `existing == en_value` rule # kept resending these to Google every run, and the # provider sometimes mangled them (`Terminal` → # `terminal`, `CPU %` → `% de CPU`, ...). # 3. Auto-fills from prior runs whose output happened to # match EN — leaving them alone is the intended # steady-state, not a bug. # When someone genuinely wants to redo everything, --refresh # is still available (and is destructive by design). # Seed structural nodes and non-text leaves before any checkpoint. # Only strings are sent to a translation provider. original_flat = target_flat.copy() for key, en_value in en_flat.items(): if not isinstance(en_value, str) or not en_value: target_flat.setdefault(key, en_value) elif key and isinstance(key[-1], int): # Null keeps an untranslated array position addressable while # allowing the runtime English fallback and a later retry. target_flat.setdefault(key, None) missing: list[CatalogPath] = [] for key, en_value in en_flat.items(): if not isinstance(en_value, str) or not en_value: continue existing = target_flat.get(key, "") if args.refresh or not existing: 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: if target_flat != original_flat: write_json(locale_path, unflatten(target_flat)) print(f" {lang}: nothing to translate", flush=True) continue failures_for_lang: list[tuple[str, CatalogPath, str]] = [] for index, key in enumerate(missing, start=1): en_value = en_flat[key] protected, placeholders = protect_placeholders(en_value) protected, technical_terms = protect_technical_terms(protected) try: translated = translate_one( protected, lang, args.provider, args.context, args.timeout, args.appimage_path, ) translated = restore_technical_terms(translated, technical_terms) 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 " f"(partial progress persisted, next run will retry).", 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) # Exit 0 on partial failure so the workflow's Commit + push # step still runs and the keys that DID translate reach # develop. The un-translated keys stay empty and the next # workflow tick (or a manual dispatch) retries them. # Previously we returned 2, which failed the whole run and # discarded 36 out of 38 successful translations because # 2 googletrans timeouts hit sv at the start of the burst. if not skipped: return 0 if skipped: names = ', '.join(dict.fromkeys(lang for lang in languages if lang in skipped)) print( f"\nPartial failure: skipped {len(skipped)} locale(s): {names}. " "Valid locales processed; skipped catalogs unchanged. " "Manual reconciliation required; exiting 1.", file=sys.stderr, flush=True, ) return 1 print("\ni18n messages generated successfully.", flush=True) return 0 if __name__ == "__main__": raise SystemExit(main())