mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-09 01:06:22 +00:00
ci(i18n): add auto-translation workflow for Monitor messages catalog
Adds .github/scripts/build_i18n_messages.py and its companion workflow
build-i18n-messages.yml, so keys added to AppImage/messages/en/common.json
propagate automatically to es/de/fr/it/pt on push.
Guardrails:
- Never overwrites a key whose target value differs from EN, protecting
human-curated locales (Vaso73's sk is excluded from the default set
entirely).
- {placeholder} tokens are extracted to opaque tokens before the
translator sees the text and restored afterwards, so interpolation
keys stay intact regardless of provider behavior.
Mirrors the pattern of build-translation-cache.yml (CLI translations)
and reuses its providers (googletrans / google-web / appimage), so the
CI environment stays uniform between the two workflows.
First run will bootstrap ~3800 keys × 5 locales; subsequent runs only
process new keys added to en/common.json.
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
#!/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 skipped by default; override with --languages es,de,fr,it,pt,sk
|
||||
if you ever want to include it (which will only fill missing keys,
|
||||
not overwrite the existing 3632).
|
||||
|
||||
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 human-curated (Vaso73); default set excludes it so a naive
|
||||
# workflow run cannot accidentally overwrite curated strings. Users can
|
||||
# still pass --languages ...,sk if they want auto-fill for missing keys.
|
||||
DEFAULT_LANGUAGES = ("es", "de", "fr", "it", "pt")
|
||||
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. Default excludes sk "
|
||||
"(human-curated by Vaso73). Adding sk here only fills "
|
||||
"keys that are still identical to the English fallback."
|
||||
),
|
||||
)
|
||||
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())
|
||||
@@ -0,0 +1,108 @@
|
||||
name: Build i18n messages
|
||||
|
||||
# Auto-translate missing keys in AppImage/messages/<locale>/common.json
|
||||
# against the English source whenever the source changes.
|
||||
#
|
||||
# The Monitor's i18n layer (AppImage/lib/i18n/provider.tsx) does its own
|
||||
# runtime fallback (locale → en → key), so this workflow doesn't break
|
||||
# anything if it misses a key: it just eliminates the visible-English
|
||||
# blocks in non-en locales.
|
||||
#
|
||||
# Guardrails baked into build_i18n_messages.py:
|
||||
# - Never overwrites a key whose target value differs from EN (i.e.
|
||||
# already translated by a human).
|
||||
# - `{placeholder}` tokens are protected end-to-end.
|
||||
# - `sk` is excluded from the default list — Vaso73's locale is
|
||||
# human-curated and full. Add it back via workflow_dispatch inputs
|
||||
# if you ever want auto-fill for the ~200 keys added post his last
|
||||
# PR (only missing keys will be touched; existing ones are safe).
|
||||
#
|
||||
# Triggers:
|
||||
# - push to develop touching AppImage/messages/en/common.json
|
||||
# - manual via workflow_dispatch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- 'AppImage/messages/en/common.json'
|
||||
- '.github/scripts/build_i18n_messages.py'
|
||||
- '.github/workflows/build-i18n-messages.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
refresh:
|
||||
description: 'Re-translate every key (overwrites human translations!)'
|
||||
type: boolean
|
||||
default: false
|
||||
languages:
|
||||
description: 'Comma-separated locales. Default: es,de,fr,it,pt (sk skipped).'
|
||||
default: 'es,de,fr,it,pt'
|
||||
|
||||
# Prevent two runs from racing on the same branch and fighting over the
|
||||
# auto-commit. cancel-in-progress:false because a full first-run may take
|
||||
# ~30 min for the initial bootstrap and interrupting mid-flight would
|
||||
# waste the calls already made.
|
||||
concurrency:
|
||||
group: build-i18n-messages-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # auto-commit AppImage/messages/*/common.json to develop
|
||||
# First-run bootstrap of ~5 locales × 3.8k keys can take a while at
|
||||
# 0.15s/call with rate-limit backoffs. 90 min headroom.
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- name: Checkout develop
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: develop
|
||||
# Full history so the auto-commit doesn't drift when another
|
||||
# push landed between trigger and this job start.
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install googletrans
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# Same pinning as build-translation-cache.yml so the two
|
||||
# workflows share behavior. Bump both in lockstep.
|
||||
pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0'
|
||||
|
||||
- name: Translate missing keys
|
||||
run: |
|
||||
REFRESH_FLAG=""
|
||||
if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then
|
||||
REFRESH_FLAG="--refresh"
|
||||
fi
|
||||
LANGS="${{ github.event.inputs.languages }}"
|
||||
LANGS="${LANGS:-es,de,fr,it,pt}"
|
||||
python .github/scripts/build_i18n_messages.py \
|
||||
--source AppImage/messages/en/common.json \
|
||||
--messages-dir AppImage/messages \
|
||||
--languages "$LANGS" \
|
||||
--provider googletrans \
|
||||
$REFRESH_FLAG
|
||||
|
||||
- name: Commit + push if changed
|
||||
run: |
|
||||
if git diff --quiet -- AppImage/messages/; then
|
||||
echo "No translation changes — skipping commit."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "ProxMenuxBot"
|
||||
git config user.email "bot@proxmenux.local"
|
||||
git add AppImage/messages/
|
||||
git commit -m "chore(i18n): auto-fill missing translations in messages/{locale}/common.json
|
||||
|
||||
Source: ${GITHUB_SHA::7}
|
||||
Triggered by: ${{ github.event_name }}"
|
||||
git push origin develop
|
||||
Reference in New Issue
Block a user