Merge pull request #318 from MacRimi/develop

New version 1.2.5
This commit is contained in:
MacRimi
2026-09-01 19:26:35 +02:00
committed by GitHub
288 changed files with 93734 additions and 8922 deletions
+346
View File
@@ -0,0 +1,346 @@
#!/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,
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_]*\}")
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. 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).
missing: list[str] = []
for key, en_value in en_flat.items():
if 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:
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)
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.
return 0
print("\ni18n messages generated successfully.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+106 -11
View File
@@ -16,6 +16,8 @@ from __future__ import annotations
import argparse
import ast
import asyncio
import inspect
import json
import os
import subprocess
@@ -28,14 +30,77 @@ from urllib.parse import quote
from urllib.request import Request, urlopen
DEFAULT_LANGUAGES = ("es", "fr", "de", "it", "pt")
DEFAULT_LANGUAGES = ("es", "fr", "de", "it", "pt", "sk", "sv")
DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:"
# googletrans and the public Google endpoint used by this workflow do not
# support Cloud Translation glossaries. Protect product names, package names
# and command identifiers with opaque tokens before sending text to any
# provider, then restore the exact source spelling afterwards. Keep longer
# terms first so ``gasket`` cannot consume part of ``gasket-dkms``.
PROTECTED_TECHNICAL_TERMS = (
"google/gasket-driver",
"feranick/gasket-driver",
"libedgetpu1-std",
"Proxmox VE Helper-Scripts",
"Docker Compose",
"gasket-driver",
"gasket-dkms",
"libedgetpu1",
"libedgetpu",
"Google Coral",
"Edge TPU",
"ProxMenux",
"Proxmox",
"AppImage",
"smartctl",
"systemctl",
"pveproxy",
"apt-get",
"Frigate",
"Docker",
"Coral",
"gasket",
"apex",
"lspci",
"dpkg",
"DKMS",
"QEMU",
"LXC",
"ZFS",
"SSH",
"fork",
)
TECHNICAL_TERM_RE = re.compile(
"|".join(
rf"(?<![A-Za-z0-9_]){re.escape(term)}(?![A-Za-z0-9_])"
for term in sorted(PROTECTED_TECHNICAL_TERMS, key=len, reverse=True)
),
re.IGNORECASE,
)
TRANSLATE_CALL_RE = re.compile(
r"""translate\s+(?P<quote>["'])(?P<text>(?:\\.|(?! (?P=quote) ).)*?)(?P=quote)""",
re.VERBOSE | re.DOTALL,
)
def protect_technical_terms(text: str) -> tuple[str, list[str]]:
"""Replace glossary terms with stable tokens before translation."""
protected: list[str] = []
def _swap(match: re.Match[str]) -> str:
protected.append(match.group(0))
return f"__PMX_TERM_{len(protected) - 1}__"
return TECHNICAL_TERM_RE.sub(_swap, text), protected
def restore_technical_terms(text: str, protected: list[str]) -> str:
"""Restore glossary terms exactly as they appeared in the source."""
for index, original in enumerate(protected):
text = text.replace(f"__PMX_TERM_{index}__", original)
return text
def iter_script_files(
scripts_dir: Path, extra_files: Iterable[Path] = ()
) -> Iterable[Path]:
@@ -97,7 +162,10 @@ def translate_googletrans(text: str, dest_lang: str, context: str) -> str:
translator = Translator()
full_text = f"{context} {text}".strip()
return translator.translate(full_text, dest=dest_lang).text
result = translator.translate(full_text, dest=dest_lang)
if inspect.isawaitable(result):
result = asyncio.run(result)
return result.text
def translate_google_web(text: str, dest_lang: str, context: str, timeout: int) -> str:
@@ -163,8 +231,23 @@ def translate_appimage(
def clean_translation(value: str) -> str:
separator = r"[\s\u00a0]*[:]"
translate_labels = "Translate|Traducir|Traduire|Übersetzen|Tradurre|Traduci|Traduzir"
context_labels = "Context|Contexto|Contexte|Kontext|Contesto"
# `Translate` pivot in every locale currently supported. Without
# the target-language variant here, the cleaner can't locate the
# boundary between the context prompt and the real translation,
# and the whole payload leaks through as the translated context.
# Caught on the 2026-08-14 workflow run — every sk / sv key ended
# up as "Technický text používateľského rozhrania…" or
# "Teknisk gränssnittstext för en Proxmox-hanteringspanel.Övers"
# because Preložiť / Översätt / Översätta were missing.
translate_labels = (
"Translate|Traducir|Traduire|Übersetzen|Tradurre|Traduci|Traduzir"
"|Preložiť|Prelož|Preloz" # sk
"|Översätta|Översätt" # sv
)
context_labels = (
"Context|Contexto|Contexte|Kontext|Contesto"
"|Sammanhang" # sv alternate
)
value = re.sub(
rf"^.*?({translate_labels}){separator}",
"",
@@ -194,15 +277,19 @@ def translate_text(
timeout: int,
appimage_path: Path,
) -> str:
protected_text, protected_terms = protect_technical_terms(text)
if provider == "googletrans":
translated = translate_googletrans(text, dest_lang, context)
translated = translate_googletrans(protected_text, dest_lang, context)
elif provider == "google-web":
translated = translate_google_web(text, dest_lang, context, timeout)
translated = translate_google_web(protected_text, dest_lang, context, timeout)
elif provider == "appimage":
translated = translate_appimage(text, dest_lang, context, timeout, appimage_path)
translated = translate_appimage(
protected_text, dest_lang, context, timeout, appimage_path
)
else:
raise ValueError(f"Unknown provider: {provider}")
return clean_translation(translated) or text
translated = restore_technical_terms(clean_translation(translated), protected_terms)
return translated or text
def load_language_cache(path: Path) -> dict[str, str]:
@@ -260,7 +347,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--languages",
default=",".join(DEFAULT_LANGUAGES),
help="Comma-separated destination languages. Default: es,fr,de,it,pt",
help="Comma-separated destination languages. Default: es,fr,de,it,pt,sk",
)
parser.add_argument(
"--provider",
@@ -373,12 +460,20 @@ def main() -> int:
write_language_cache(output_dir / f"{lang}.json", cache)
if failures:
print(f"Completed with {len(failures)} translation failures.", file=sys.stderr, flush=True)
print(
f"Completed with {len(failures)} translation failures "
f"(partial progress persisted, next run will retry).",
file=sys.stderr, flush=True,
)
for text, lang, error in failures[:20]:
print(f"- {lang}: {text[:80]} -> {error}", file=sys.stderr, flush=True)
if len(failures) > 20:
print(f"... and {len(failures) - 20} more.", file=sys.stderr, flush=True)
return 2
# Exit 0 on partial failure so the workflow's Commit + push
# step still runs. Otherwise a couple of transient googletrans
# timeouts would fail the whole workflow and discard every
# successful translation from the same batch.
return 0
print("Translation cache generated successfully.", flush=True)
return 0
+853
View File
@@ -0,0 +1,853 @@
#!/usr/bin/env python3
"""Build missing translations for the ProxMenux documentation catalog."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
sys.path.insert(0, str(Path(__file__).parent))
from build_translation_cache import ( # noqa: E402
clean_translation,
translate_appimage,
translate_google_web,
translate_googletrans,
)
DEFAULT_LANGUAGES = ("es", "de", "fr", "it", "pt", "sk", "sv")
DEFAULT_CONTEXT = (
"Context: ProxMenux technical documentation for Proxmox VE users. "
"Preserve product names, commands, paths, variables and placeholders. Translate:"
)
TECHNICAL_TERMS = (
"Proxmox VE Helper-Scripts",
"Proxmox Backup Server",
"Proxmox Mail Gateway",
"Proxmox VE",
"ProxMenux Monitor",
"ProxMenux Scripts",
"Docker Compose",
"Docker Engine",
"Google Coral",
"Edge TPU",
"Let's Encrypt",
"Cloudflare",
"Pushover",
"Telegram",
"Discord",
"Microsoft Teams",
"GitHub",
"Gotify",
"Apprise",
"Frigate",
"Vaultwarden",
"Portainer",
"ProxMenux",
"AppImage",
"systemctl",
"journalctl",
"smartctl",
"pveproxy",
"apt-get",
"gasket-dkms",
"libedgetpu",
"QEMU",
"LXC",
"ZFS",
"Ceph",
"Docker",
"OpenAI",
"WebSocket",
"OAuth",
"DKMS",
"SSH",
"API",
)
PROTECTED_PATTERNS = (
# Keep rich-text tags visible to Google Translate. It preserves their
# structure while translating the enclosed prose, whereas replacing
# opening/closing tags with adjacent sentinels can make the provider drop
# one side of the pair. The contract is validated after translation.
re.compile(r"`[^`]+`"),
re.compile(r"https?://[^\s<>]+"),
re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}"),
re.compile(r"\$\{?[A-Za-z_][A-Za-z0-9_]*\}?"),
re.compile(r"(?<![\w-])--[A-Za-z0-9][A-Za-z0-9_-]*"),
re.compile(r"(?<![A-Za-z0-9<])/(?:[A-Za-z0-9._~:@%+=-]+/)*[A-Za-z0-9._~:@%+=-]+"),
re.compile(r"\b[A-Za-z0-9_.-]+\.(?:json|ya?ml|toml|conf|service|socket|sh|py|tsx?|jsx?|md)\b"),
)
LITERAL_TAG_RE = re.compile(
r"<(code|kbd|pre)\b[^>]*>.*?</\1>",
re.IGNORECASE | re.DOTALL,
)
TERM_RE = re.compile(
"|".join(
rf"(?<![A-Za-z0-9_]){re.escape(term)}(?![A-Za-z0-9_])"
for term in sorted(TECHNICAL_TERMS, key=len, reverse=True)
),
re.IGNORECASE,
)
TAG_RE = re.compile(r"</?([A-Za-z][A-Za-z0-9]*)>")
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}")
NON_TRANSLATABLE_KEYS = {
"command",
"code",
"href",
"icon",
"id",
"path",
"route",
"slug",
"src",
"url",
}
SOURCE_STATE_VERSION = 1
@dataclass(frozen=True)
class Leaf:
path: tuple[str | int, ...]
source: str
def read_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return None
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(value, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
temporary.replace(path)
def iter_leaves(node: Any, path: tuple[str | int, ...] = ()) -> list[Leaf]:
leaves: list[Leaf] = []
if isinstance(node, dict):
for key, value in node.items():
leaves.extend(iter_leaves(value, path + (key,)))
elif isinstance(node, list):
for index, value in enumerate(node):
leaves.extend(iter_leaves(value, path + (index,)))
elif isinstance(node, str):
leaves.append(Leaf(path, node))
return leaves
def get_at_path(node: Any, path: tuple[str | int, ...]) -> Any:
current = node
try:
for part in path:
if isinstance(part, int):
if not isinstance(current, list):
return None
current = current[part]
else:
if not isinstance(current, dict):
return None
current = current[part]
except (IndexError, KeyError, TypeError):
return None
return current
def path_key(path: tuple[str | int, ...]) -> str:
return ".".join(str(part) for part in path)
def should_copy(source: str, path: tuple[str | int, ...]) -> bool:
if not source.strip() or not re.search(r"[A-Za-z]", source):
return True
last = str(path[-1]).lower() if path else ""
if last in NON_TRANSLATABLE_KEYS or any(
last.endswith(suffix)
for suffix in ("url", "href", "path", "command", "code", "icon")
):
return True
if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/[A-Za-z0-9_./:@%+=-]+", source):
return True
if re.fullmatch(r"[A-Z0-9_.:/+-]{2,}", source):
return True
return False
def leaf_token(path: tuple[str | int, ...]) -> str:
return json.dumps(path, ensure_ascii=False, separators=(",", ":"))
def source_fingerprints(source: Any) -> dict[str, str]:
return {
leaf_token(leaf.path): hashlib.sha256(leaf.source.encode("utf-8")).hexdigest()[:20]
for leaf in iter_leaves(source)
}
def needs_translation(
source: str,
target: Any,
path: tuple[str | int, ...],
refresh: bool,
forced_tokens: set[str] | None = None,
) -> bool:
if should_copy(source, path):
return False
if refresh:
return True
if forced_tokens and leaf_token(path) in forced_tokens:
return True
return not isinstance(target, str) or not target.strip() or target == source
def protect_rich_tags(text: str) -> tuple[str, dict[str, str]]:
"""Give translatable rich-text tags opaque names during translation.
Google can remove semantic tags such as ``strong`` or ``em`` after
translating their contents. Unknown tag names are retained, so rename
non-literal tags temporarily and restore them before contract validation.
Literal ``code``, ``kbd`` and ``pre`` elements stay untouched because the
provider preserves both their markup and their contents.
"""
names: dict[str, str] = {}
reverse: dict[str, str] = {}
def replace(match: re.Match[str]) -> str:
original = match.group(1)
if original.lower() in {"code", "kbd", "pre"}:
return match.group(0)
key = original.lower()
internal = names.get(key)
if internal is None:
internal = f"pmxrich{len(names):04d}"
names[key] = internal
reverse[internal] = original
slash = "/" if match.group(0).startswith("</") else ""
return f"<{slash}{internal}>"
return TAG_RE.sub(replace, text), reverse
def restore_rich_tags(text: str, mapping: dict[str, str]) -> str:
for internal, original in mapping.items():
text = re.sub(
rf"<(/?){re.escape(internal)}>",
lambda match: f"<{match.group(1)}{original}>",
text,
flags=re.IGNORECASE,
)
return text
def protect_text(text: str) -> tuple[str, dict[str, str]]:
# Google already keeps both the markup and the contents of literal rich-
# text blocks. Replacing a filename or path inside one of these blocks can
# leave the sentinel as its only child, which the provider may discard.
literal_ranges = [(match.start(), match.end()) for match in LITERAL_TAG_RE.finditer(text)]
def overlaps_literal(start: int, end: int) -> bool:
return any(start < literal_end and end > literal_start for literal_start, literal_end in literal_ranges)
candidates: list[tuple[int, int]] = []
for pattern in PROTECTED_PATTERNS:
candidates.extend(
(match.start(), match.end())
for match in pattern.finditer(text)
if not overlaps_literal(match.start(), match.end())
)
candidates.extend(
(match.start(), match.end())
for match in TERM_RE.finditer(text)
if not overlaps_literal(match.start(), match.end())
)
candidates.sort(key=lambda item: (item[0], -(item[1] - item[0])))
selected: list[tuple[int, int]] = []
cursor = -1
for start, end in candidates:
if start >= cursor:
selected.append((start, end))
cursor = end
mapping: dict[str, str] = {}
chunks: list[str] = []
cursor = 0
for index, (start, end) in enumerate(selected):
# Google Translate can drop underscore-delimited sentinels when they
# sit directly beside inline markup (for example an opening <em>
# token followed by translated prose). Triple brackets remain opaque
# in that position and preserve the complete rich-text contract.
token = f"[[[PMXDOC{index:04d}]]]"
chunks.append(text[cursor:start])
chunks.append(token)
mapping[token] = text[start:end]
cursor = end
chunks.append(text[cursor:])
return "".join(chunks), mapping
def restore_text(text: str, mapping: dict[str, str]) -> str:
missing = [token for token in mapping if token not in text]
if missing:
raise ValueError(f"translation provider changed protected token {missing[0]}")
for token, original in mapping.items():
text = text.replace(token, original)
return text
def validate_contract(source: str, target: str) -> None:
if sorted(TAG_RE.findall(source)) != sorted(TAG_RE.findall(target)):
raise ValueError("rich-text tag contract changed")
if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)):
raise ValueError("placeholder contract changed")
def provider_function(args: argparse.Namespace) -> Callable[[str, str], str]:
def translate(text: str, language: str) -> str:
if args.provider == "googletrans":
raw = translate_googletrans(text, language, args.context)
elif args.provider == "google-web":
raw = translate_google_web(text, language, args.context, args.timeout)
else:
raw = translate_appimage(
text,
language,
args.context,
args.timeout,
args.appimage_path,
)
return clean_translation(raw).strip()
return translate
def translate_with_retry(
source: str,
language: str,
translate: Callable[[str, str], str],
retries: int,
delay: float,
) -> str:
rich_text, rich_mapping = protect_rich_tags(source)
protected, mapping = protect_text(rich_text)
last_error: Exception | None = None
for attempt in range(retries + 1):
try:
translated = translate(protected, language)
if not translated:
raise ValueError("translation provider returned an empty value")
translated = restore_text(translated, mapping)
translated = restore_rich_tags(translated, rich_mapping)
validate_contract(source, translated)
return translated
except Exception as exc: # network/provider errors are retried together
last_error = exc
if attempt < retries:
time.sleep(delay * (attempt + 1))
raise RuntimeError(str(last_error)) from last_error
def merge_tree(
source: Any,
target: Any,
translations: dict[tuple[str | int, ...], str],
path: tuple[str | int, ...] = (),
) -> Any:
if isinstance(source, dict):
target_dict = target if isinstance(target, dict) else {}
return {
key: merge_tree(value, target_dict.get(key), translations, path + (key,))
for key, value in source.items()
}
if isinstance(source, list):
target_list = target if isinstance(target, list) else []
return [
merge_tree(
value,
target_list[index] if index < len(target_list) else None,
translations,
path + (index,),
)
for index, value in enumerate(source)
]
if isinstance(source, str):
if path in translations:
return translations[path]
if should_copy(source, path):
return source
if isinstance(target, str) and target.strip():
return target
return source
return source
def source_files(source_root: Path, section: str) -> list[Path]:
scope = (source_root / section).resolve()
root = source_root.resolve()
if scope != root and root not in scope.parents:
raise ValueError("section must stay inside the English messages directory")
if scope.is_file():
if scope.suffix != ".json":
raise ValueError("section file must be JSON")
return [scope]
if not scope.is_dir():
raise ValueError(f"section does not exist: {scope}")
return sorted(
path
for path in scope.rglob("*.json")
if not any(part.startswith(".") for part in path.relative_to(root).parts)
)
def collect_memory(source_root: Path, messages_root: Path, language: str) -> dict[str, str]:
memory: dict[str, str] = {}
conflicts: set[str] = set()
for source_path in sorted(source_root.rglob("*.json")):
if any(part.startswith(".") for part in source_path.relative_to(source_root).parts):
continue
target_path = messages_root / language / source_path.relative_to(source_root)
source = read_json(source_path)
target = read_json(target_path)
if target is None:
continue
for leaf in iter_leaves(source):
translated = get_at_path(target, leaf.path)
if not isinstance(translated, str) or not translated.strip() or translated == leaf.source:
continue
previous = memory.get(leaf.source)
if previous is not None and previous != translated:
conflicts.add(leaf.source)
else:
memory[leaf.source] = translated
for source in conflicts:
memory.pop(source, None)
return memory
def pending_leaves(
source: Any,
target: Any,
refresh: bool,
forced_tokens: set[str] | None = None,
) -> list[Leaf]:
return [
leaf
for leaf in iter_leaves(source)
if needs_translation(
leaf.source,
get_at_path(target, leaf.path),
leaf.path,
refresh,
forced_tokens,
)
]
def schema_matches(source: Any, target: Any) -> bool:
if type(source) is not type(target):
return False
if isinstance(source, dict):
return list(source) == list(target) and all(
schema_matches(source[key], target[key]) for key in source
)
if isinstance(source, list):
return len(source) == len(target) and all(
schema_matches(left, right) for left, right in zip(source, target)
)
if isinstance(source, str):
# Localized strings intentionally differ from the English source.
return True
# Booleans, numbers and null values are structural data and must stay in
# sync with the English catalog instead of retaining an obsolete value.
return source == target
def empty_source_state() -> dict[str, Any]:
return {
"version": SOURCE_STATE_VERSION,
"initialized": False,
"source": {},
"pending": {},
}
def load_source_state(path: Path) -> dict[str, Any]:
if not path.exists():
return empty_source_state()
try:
value = read_json(path)
except ValueError:
return empty_source_state()
if not isinstance(value, dict) or value.get("version") != SOURCE_STATE_VERSION:
return empty_source_state()
if not isinstance(value.get("source"), dict) or not isinstance(value.get("pending"), dict):
return empty_source_state()
value.setdefault("initialized", True)
return value
def state_pending_tokens(state: dict[str, Any], language: str, relative: str) -> set[str]:
language_state = state.setdefault("pending", {}).setdefault(language, {})
values = language_state.get(relative, [])
if not isinstance(values, list):
return set()
return {str(value) for value in values}
def set_state_pending_tokens(
state: dict[str, Any], language: str, relative: str, tokens: set[str]
) -> None:
language_state = state.setdefault("pending", {}).setdefault(language, {})
if tokens:
language_state[relative] = sorted(tokens)
else:
language_state.pop(relative, None)
if not language_state:
state["pending"].pop(language, None)
def translate_file(
source: Any,
target: Any,
leaves: list[Leaf],
language: str,
memory: dict[str, str],
translate: Callable[[str, str], str],
args: argparse.Namespace,
) -> tuple[Any | None, list[str], int]:
resolved: dict[tuple[str | int, ...], str] = {}
failures: list[str] = []
jobs: dict[str, list[tuple[str | int, ...]]] = {}
for leaf in leaves:
if not args.refresh and leaf.source in memory:
resolved[leaf.path] = memory[leaf.source]
continue
jobs.setdefault(leaf.source, []).append(leaf.path)
if jobs:
with ThreadPoolExecutor(max_workers=args.workers) as executor:
futures = {
executor.submit(
translate_with_retry,
text,
language,
translate,
args.retries,
args.retry_delay,
): text
for text in jobs
}
for future in as_completed(futures):
text = futures[future]
try:
translated = future.result()
memory[text] = translated
for path in jobs[text]:
resolved[path] = translated
except Exception as exc:
failures.append(f"{text[:90]}: {exc}")
if args.sleep:
time.sleep(args.sleep)
if failures:
return None, failures, len(jobs)
return merge_tree(source, target, resolved), [], len(jobs)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source-dir", type=Path, default=Path("web/messages/en"))
parser.add_argument("--messages-dir", type=Path, default=Path("web/messages"))
parser.add_argument("--languages", default=",".join(DEFAULT_LANGUAGES))
parser.add_argument(
"--section",
default=".",
help="Relative file or directory below the English messages directory.",
)
parser.add_argument(
"--provider",
choices=("google-web", "googletrans", "appimage"),
default="googletrans",
)
parser.add_argument("--appimage-path", type=Path, default=Path("ProxMenux-Monitor.AppImage"))
parser.add_argument(
"--source-state",
type=Path,
default=None,
help=(
"Source fingerprint state used to detect changed English strings. "
"Defaults to <messages-dir>/.docs-i18n-source-state.json."
),
)
parser.add_argument("--context", default=DEFAULT_CONTEXT)
parser.add_argument("--timeout", type=int, default=30)
parser.add_argument("--workers", type=int, default=4)
parser.add_argument("--retries", type=int, default=3)
parser.add_argument("--retry-delay", type=float, default=2.0)
parser.add_argument("--sleep", type=float, default=0.0)
parser.add_argument(
"--max-files",
type=int,
default=0,
help="Maximum pending files per locale; zero processes all files.",
)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--check", action="store_true")
parser.add_argument(
"--refresh",
action="store_true",
help="Overwrite existing translations in the selected scope.",
)
parser.add_argument("--strict", action="store_true")
return parser
def main() -> int:
args = build_parser().parse_args()
if args.workers < 1 or args.retries < 0 or args.max_files < 0:
print("workers must be positive; retries and max-files cannot be negative", file=sys.stderr)
return 2
source_root = args.source_dir.resolve()
messages_root = args.messages_dir.resolve()
source_state_path = (
args.source_state.resolve()
if args.source_state is not None
else messages_root / ".docs-i18n-source-state.json"
)
languages = [item.strip() for item in args.languages.split(",") if item.strip()]
try:
files = source_files(source_root, args.section)
except ValueError as exc:
print(exc, file=sys.stderr)
return 2
if not languages:
print("No target languages selected.", file=sys.stderr)
return 2
if args.refresh:
print("WARNING: --refresh overwrites existing translations in the selected scope.")
translate = provider_function(args)
state = load_source_state(source_state_path)
state_was_initialized = bool(state.get("initialized"))
previous_source_state = state.setdefault("source", {})
file_info: dict[
str, tuple[Path, Any, dict[str, str], set[str], set[str]]
] = {}
selected_relatives: set[str] = set()
for source_path in files:
relative = source_path.relative_to(source_root).as_posix()
selected_relatives.add(relative)
source = read_json(source_path)
current_fingerprints = source_fingerprints(source)
translatable_tokens = {
leaf_token(leaf.path)
for leaf in iter_leaves(source)
if not should_copy(leaf.source, leaf.path)
}
previous_fingerprints = previous_source_state.get(relative, {})
if not isinstance(previous_fingerprints, dict):
previous_fingerprints = {}
if state_was_initialized:
changed_tokens = {
token
for token, fingerprint in current_fingerprints.items()
if token in translatable_tokens
# Existing English leaves whose text changed must be sent to
# the provider even when the target still contains the old,
# non-empty translation. New leaves are handled by the usual
# missing-target detection, preserving a translation supplied
# manually in the same commit.
and token in previous_fingerprints
and previous_fingerprints[token] != fingerprint
}
else:
# The first run establishes the baseline without replacing
# existing human/Codex translations. Missing target values are
# still discovered separately for every locale below.
changed_tokens = set()
file_info[relative] = (
source_path,
source,
current_fingerprints,
changed_tokens,
translatable_tokens,
)
# Materialize every locale's pending queue before advancing the shared
# English baseline. If the runner stops halfway through, unprocessed
# locales retain the exact changed leaf tokens for the next run.
for language in languages:
for relative, (
_,
source,
_,
changed_tokens,
translatable_tokens,
) in file_info.items():
target_path = messages_root / language / relative
target = read_json(target_path)
pending_tokens = (
state_pending_tokens(state, language, relative)
& translatable_tokens
)
pending_tokens.update(changed_tokens)
pending_tokens.update(
leaf_token(leaf.path)
for leaf in pending_leaves(source, target, args.refresh)
)
set_state_pending_tokens(state, language, relative, pending_tokens)
for relative, (_, _, current_fingerprints, _, _) in file_info.items():
previous_source_state[relative] = current_fingerprints
state["initialized"] = True
# A complete run also mirrors deletion of an English catalog. Partial
# --section runs deliberately leave unrelated paths untouched.
if args.section in (".", ""):
removed_files = set(previous_source_state) - selected_relatives
for relative in sorted(removed_files):
for language in languages:
target_path = messages_root / language / relative
if target_path.exists() and not (args.check or args.dry_run):
target_path.unlink()
print(f"[{language}] removed obsolete catalog {target_path}")
set_state_pending_tokens(state, language, relative, set())
previous_source_state.pop(relative, None)
if not (args.check or args.dry_run):
write_json(source_state_path, state)
total_failures = 0
total_written = 0
print(f"English files: {len(files)} | locales: {', '.join(languages)}")
for language in languages:
memory = collect_memory(source_root, messages_root, language)
# A target value paired with a newly changed English source is the old
# translation, not valid translation memory for the new sentence.
# Remove every queued source text before provider reuse; successful
# translations repopulate memory normally for later files.
queued_source_texts: set[str] = set()
for relative_key, (_, source, _, _, _) in file_info.items():
queued_tokens = state_pending_tokens(state, language, relative_key)
queued_source_texts.update(
leaf.source
for leaf in iter_leaves(source)
if leaf_token(leaf.path) in queued_tokens
)
for source_text in queued_source_texts:
memory.pop(source_text, None)
pending: list[
tuple[Path, Path, Any, Any, list[Leaf], set[str], bool]
] = []
total_strings = 0
missing_strings = 0
for source_path in files:
relative = source_path.relative_to(source_root)
relative_key = relative.as_posix()
target_path = messages_root / language / relative
source = file_info[relative_key][1]
target = read_json(target_path)
queued_tokens = state_pending_tokens(state, language, relative_key)
leaves = pending_leaves(source, target, args.refresh, queued_tokens)
total_strings += len(iter_leaves(source))
missing_strings += len(leaves)
schema_changed = not schema_matches(source, target)
if leaves or schema_changed:
pending.append(
(
source_path,
target_path,
source,
target,
leaves,
queued_tokens,
schema_changed,
)
)
print(
f"[{language}] {missing_strings}/{total_strings} strings pending "
f"across {len(pending)} files; reusable translations: {len(memory)}"
)
if args.check or args.dry_run:
continue
if args.max_files:
pending = pending[: args.max_files]
for index, (
source_path,
target_path,
source,
target,
leaves,
queued_tokens,
schema_changed,
) in enumerate(pending, 1):
relative = source_path.relative_to(source_root)
relative_key = relative.as_posix()
suffix = " + schema sync" if schema_changed else ""
print(
f"[{language} {index}/{len(pending)}] {relative} "
f"({len(leaves)} strings{suffix})",
flush=True,
)
built, failures, calls = translate_file(
source,
target,
leaves,
language,
memory,
translate,
args,
)
if failures:
total_failures += len(failures)
set_state_pending_tokens(state, language, relative_key, queued_tokens)
write_json(source_state_path, state)
print(
f" skipped atomically after {len(failures)} failures "
f"({calls} calls)",
file=sys.stderr,
)
for failure in failures[:5]:
print(f" - {failure}", file=sys.stderr)
continue
write_json(target_path, built)
set_state_pending_tokens(state, language, relative_key, set())
write_json(source_state_path, state)
total_written += 1
print(f" wrote {target_path} ({calls} provider calls)", flush=True)
if args.check or args.dry_run:
return 0
print(f"Completed: {total_written} files written; {total_failures} failed strings.")
return 1 if args.strict and total_failures else 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,221 @@
import importlib.util
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPT = Path(__file__).parents[1] / "build_web_docs_i18n.py"
SPEC = importlib.util.spec_from_file_location("build_web_docs_i18n", SCRIPT)
MODULE = importlib.util.module_from_spec(SPEC)
assert SPEC and SPEC.loader
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
class WebDocsI18nTests(unittest.TestCase):
def run_generator(self, root, provider):
source_root = root / "messages" / "en"
messages_root = root / "messages"
state_path = messages_root / ".docs-i18n-source-state.json"
argv = [
str(SCRIPT),
"--source-dir",
str(source_root),
"--messages-dir",
str(messages_root),
"--languages",
"de",
"--source-state",
str(state_path),
"--workers",
"1",
]
with mock.patch.object(sys, "argv", argv), mock.patch.object(
MODULE, "provider_function", return_value=provider
):
return MODULE.main()
def test_protected_contract_round_trip(self):
source = (
"Run <code>systemctl restart pveproxy</code> in Proxmox VE, "
"then open <link>Settings</link> at {host}."
)
protected, mapping = MODULE.protect_text(source)
self.assertIn("<code>systemctl restart pveproxy</code>", protected)
self.assertNotIn("Proxmox VE", protected)
self.assertIn("<code>", protected)
self.assertIn("</code>", protected)
self.assertIn("<link>", protected)
self.assertIn("</link>", protected)
self.assertNotIn("<code>", mapping.values())
self.assertNotIn("</code>", mapping.values())
self.assertNotIn("systemctl", mapping.values())
self.assertEqual(MODULE.restore_text(protected, mapping), source)
def test_human_translation_is_not_pending(self):
source = {"title": "Updates", "url": "https://example.com"}
target = {"title": "Actualizaciones", "url": "https://example.com"}
self.assertEqual(MODULE.pending_leaves(source, target, refresh=False), [])
def test_changed_source_text_is_pending_even_with_existing_translation(self):
source = {"title": "Updated installation guidance"}
target = {"title": "Vorherige Installationsanleitung"}
token = MODULE.leaf_token(("title",))
leaves = MODULE.pending_leaves(
source,
target,
refresh=False,
forced_tokens={token},
)
self.assertEqual([leaf.path for leaf in leaves], [("title",)])
def test_source_fingerprints_change_per_leaf(self):
before = MODULE.source_fingerprints({"title": "One", "body": "Same"})
after = MODULE.source_fingerprints({"title": "Two", "body": "Same"})
self.assertNotEqual(
before[MODULE.leaf_token(("title",))],
after[MODULE.leaf_token(("title",))],
)
self.assertEqual(
before[MODULE.leaf_token(("body",))],
after[MODULE.leaf_token(("body",))],
)
def test_schema_mismatch_detects_removed_keys(self):
self.assertFalse(
MODULE.schema_matches(
{"title": "Title"},
{"title": "Título", "removed": "Old"},
)
)
def test_schema_mismatch_detects_changed_structural_values(self):
self.assertFalse(
MODULE.schema_matches(
{"enabled": True, "retries": 2},
{"enabled": False, "retries": 2},
)
)
def test_schema_allows_localized_string_values(self):
self.assertTrue(
MODULE.schema_matches(
{"title": "Updates"},
{"title": "Aktualisierungen"},
)
)
def test_translatable_rich_tags_use_opaque_names(self):
source = (
"Keep <strong>this</strong>, <em>that</em> and "
"<code>systemctl restart pveproxy</code>."
)
protected, mapping = MODULE.protect_rich_tags(source)
self.assertIn("<pmxrich0000>this</pmxrich0000>", protected)
self.assertIn("<pmxrich0001>that</pmxrich0001>", protected)
self.assertIn("<code>systemctl restart pveproxy</code>", protected)
self.assertEqual(MODULE.restore_rich_tags(protected, mapping), source)
def test_source_schema_drives_output(self):
source = {"title": "Title", "items": ["One", "Two"]}
target = {
"title": "Título",
"items": ["Uno", "Dos", "Obsoleto"],
"removed": "Old",
}
translated = {
("items", 0): "Uno",
("items", 1): "Dos",
}
self.assertEqual(
MODULE.merge_tree(source, target, translated),
{"title": "Título", "items": ["Uno", "Dos"]},
)
def test_translation_memory_ignores_conflicts(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source_root = root / "messages" / "en"
target_root = root / "messages" / "es"
source_root.mkdir(parents=True)
target_root.mkdir(parents=True)
(source_root / "one.json").write_text(
json.dumps({"label": "Settings"}), encoding="utf-8"
)
(target_root / "one.json").write_text(
json.dumps({"label": "Ajustes"}), encoding="utf-8"
)
(source_root / "two.json").write_text(
json.dumps({"label": "Settings"}), encoding="utf-8"
)
(target_root / "two.json").write_text(
json.dumps({"label": "Configuración"}), encoding="utf-8"
)
memory = MODULE.collect_memory(source_root, root / "messages", "es")
self.assertNotIn("Settings", memory)
def test_source_state_retranslates_an_existing_changed_leaf(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source_path = root / "messages" / "en" / "page.json"
target_path = root / "messages" / "de" / "page.json"
source_path.parent.mkdir(parents=True)
target_path.parent.mkdir(parents=True)
source_path.write_text(json.dumps({"title": "Original"}), encoding="utf-8")
target_path.write_text(json.dumps({"title": "Ursprünglich"}), encoding="utf-8")
self.assertEqual(
self.run_generator(root, lambda *_: self.fail("provider called during baseline")),
0,
)
self.assertEqual(read_json_file(target_path), {"title": "Ursprünglich"})
source_path.write_text(json.dumps({"title": "Changed"}), encoding="utf-8")
calls = []
def provider(text, language):
calls.append((text, language))
return "Geändert"
self.assertEqual(self.run_generator(root, provider), 0)
self.assertEqual(calls, [("Changed", "de")])
self.assertEqual(read_json_file(target_path), {"title": "Geändert"})
def test_new_leaf_preserves_translation_supplied_with_source_change(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source_path = root / "messages" / "en" / "page.json"
target_path = root / "messages" / "de" / "page.json"
source_path.parent.mkdir(parents=True)
target_path.parent.mkdir(parents=True)
source_path.write_text(json.dumps({"title": "Original"}), encoding="utf-8")
target_path.write_text(json.dumps({"title": "Ursprünglich"}), encoding="utf-8")
self.assertEqual(self.run_generator(root, lambda *_: "unused"), 0)
source_path.write_text(
json.dumps({"title": "Original", "new": "New text"}),
encoding="utf-8",
)
target_path.write_text(
json.dumps({"title": "Ursprünglich", "new": "Neuer Text"}),
encoding="utf-8",
)
self.assertEqual(
self.run_generator(root, lambda *_: self.fail("manual translation overwritten")),
0,
)
self.assertEqual(
read_json_file(target_path),
{"title": "Ursprünglich", "new": "Neuer Text"},
)
def read_json_file(path):
return json.loads(path.read_text(encoding="utf-8"))
if __name__ == "__main__":
unittest.main()
+138
View File
@@ -0,0 +1,138 @@
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). This is what makes it safe to
# include sk in the default set: Vaso73's curated strings are
# protected end-to-end; auto only fills keys still on the EN
# fallback.
# - `{placeholder}` tokens are protected end-to-end.
#
# Triggers:
# - push to develop touching AppImage/messages/en/common.json
# - manual via workflow_dispatch
on:
push:
branches: [develop]
paths:
# Only en/ triggers the run — it's the source of truth. Other
# locales are destinations, and the bot auto-commits them at
# the end of every run; if they were in the trigger too, each
# auto-commit would fire another (empty) run.
# Manual edits to a curated locale that empty a key for the
# workflow to refill are the exception — dispatch this
# workflow manually from Actions in that case, or piggy-back
# a trivial en/ change onto the commit.
- '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,sv (guardrail protects Vaso73 sk).'
default: 'es,de,fr,it,pt,sk,sv'
# 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 }}"
# Keep this fallback in sync with the workflow_dispatch input
# default above AND with DEFAULT_LANGUAGES in the Python script —
# `push` triggers hit this branch (inputs are empty on push).
LANGS="${LANGS:-es,de,fr,it,pt,sk,sv}"
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 }}"
# Rebase-and-retry against develop: between the initial
# checkout and this push another workflow (e.g.
# build-translation-cache auto-commit) or a manual push can
# land on develop first, and a naked push fails with
# non-fast-forward — losing the freshly generated translation
# commit. Rebasing the local i18n commit on top of the newer
# HEAD is safe: paths don't overlap with cache/script
# workflows, and the same-branch collisions between two i18n
# runs are already blocked by the concurrency group above.
for attempt in 1 2 3 4 5; do
git fetch origin develop
if git rebase origin/develop && git push origin develop; then
echo "push succeeded on attempt ${attempt}"
exit 0
fi
echo "push attempt ${attempt} failed, retrying..."
git rebase --abort 2>/dev/null || true
sleep $(( attempt * 3 ))
done
echo "push failed after 5 attempts"
exit 1
+20 -1
View File
@@ -96,4 +96,23 @@ jobs:
Source: ${GITHUB_SHA::7}
Triggered by: ${{ github.event_name }}"
git push origin develop
# Rebase-and-retry against develop: between the initial
# checkout and this push another workflow (e.g.
# build-i18n-messages auto-commit) or a manual push can land
# on develop first, and a naked push fails with
# non-fast-forward — losing the freshly built cache commit.
# Paths don't overlap with the other workflows, and
# same-branch collisions between two cache runs are already
# blocked by the concurrency group.
for attempt in 1 2 3 4 5; do
git fetch origin develop
if git rebase origin/develop && git push origin develop; then
echo "push succeeded on attempt ${attempt}"
exit 0
fi
echo "push attempt ${attempt} failed, retrying..."
git rebase --abort 2>/dev/null || true
sleep $(( attempt * 3 ))
done
echo "push failed after 5 attempts"
exit 1
+119
View File
@@ -0,0 +1,119 @@
name: Build web documentation translations
on:
push:
branches: [develop]
paths:
- 'web/messages/en/*.json'
- 'web/messages/en/**/*.json'
- '.github/scripts/build_web_docs_i18n.py'
- '.github/scripts/build_translation_cache.py'
- '.github/workflows/build-web-docs-i18n.yml'
workflow_dispatch:
inputs:
languages:
description: 'Comma-separated locales'
default: 'es,de,fr,it,pt,sk,sv'
section:
description: 'File or directory below web/messages/en'
default: '.'
max_files:
description: 'Maximum pending files per locale; 0 means all'
default: '0'
refresh:
description: 'Overwrite existing translations in the selected scope'
type: boolean
default: false
dry_run:
description: 'Report pending coverage without writing files'
type: boolean
default: false
concurrency:
group: build-web-docs-i18n-${{ github.ref }}
cancel-in-progress: false
jobs:
translate:
runs-on: ubuntu-latest
permissions:
contents: write
timeout-minutes: 120
steps:
- name: Checkout develop
uses: actions/checkout@v4
with:
ref: develop
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install translation provider
run: |
python -m pip install --upgrade pip
pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0'
- name: Build missing translations
shell: bash
run: |
LANGUAGES="${{ github.event.inputs.languages }}"
LANGUAGES="${LANGUAGES:-es,de,fr,it,pt,sk,sv}"
SECTION="${{ github.event.inputs.section }}"
SECTION="${SECTION:-.}"
MAX_FILES="${{ github.event.inputs.max_files }}"
MAX_FILES="${MAX_FILES:-0}"
EXTRA_ARGS=()
if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then
EXTRA_ARGS+=(--refresh)
fi
if [[ "${{ github.event.inputs.dry_run }}" == "true" ]]; then
EXTRA_ARGS+=(--dry-run)
fi
python .github/scripts/build_web_docs_i18n.py \
--source-dir web/messages/en \
--messages-dir web/messages \
--languages "$LANGUAGES" \
--section "$SECTION" \
--max-files "$MAX_FILES" \
--provider googletrans \
--workers 4 \
"${EXTRA_ARGS[@]}"
- name: Validate catalogs
run: |
python .github/scripts/build_web_docs_i18n.py \
--source-dir web/messages/en \
--messages-dir web/messages \
--languages "${{ github.event.inputs.languages || 'es,de,fr,it,pt,sk,sv' }}" \
--check
- name: Commit and push changes
if: ${{ github.event.inputs.dry_run != 'true' }}
shell: bash
run: |
if git diff --quiet -- web/messages/; then
echo "No documentation translations changed."
exit 0
fi
git config user.name "ProxMenuxBot"
git config user.email "bot@proxmenux.local"
git add web/messages/
git commit -m "docs(i18n): update documentation translations"
for attempt in 1 2 3 4 5; do
git fetch origin develop
if git rebase origin/develop && git push origin develop; then
exit 0
fi
git rebase --abort 2>/dev/null || true
sleep $((attempt * 3))
done
exit 1
@@ -0,0 +1,164 @@
name: Update App Tracking Hints
on:
# Manual trigger from the Actions UI
workflow_dispatch:
# Re-merge whenever the generator, workflow, or the maintainer-
# curated runtime overrides change. `runtime_verified_overrides.json`
# is the file to edit when a real LXC reveals a canonical path the
# community-scripts helper doesn't ship (legacy /app/package.json,
# /opt/vaultwarden/bin/vaultwarden, etc.) — the generator folds it
# into the operational catalog every run.
push:
branches: [main]
paths:
- ".github/scripts/generate_app_tracking_catalog.py"
- ".github/workflows/update-app-tracking-hints.yml"
- "json/runtime_verified_overrides.json"
# Regen every 6h — picks up new community-scripts LXC apps and
# detector-relevant script edits without needing a manual trigger.
schedule:
- cron: "0 */6 * * *"
jobs:
update-hints:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: ⬇️ Checkout the repository
uses: actions/checkout@v6
- name: 🐍 Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: ⚙️ Generate app_tracking_hints.generated.json (intermediate)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The generator writes 4 files; only `.generated.json` is
# consumed downstream by the merge step. The v2 catalog and
# per-app audit are useful for local review but not kept in
# the repo — written under /tmp so they never appear as
# dirty files here.
#
# `--runtime-overrides` folds real-CT evidence into the
# operational hints (canonical paths, cross-method fallbacks
# per app) so the runtime doesn't get fed helper-marker
# false-positives. Modern helper markers are included as their
# official version contract; runtime keeps them distinguishable from
# canonical package/binary/manual detectors for legacy compatibility.
run: |
python .github/scripts/generate_app_tracking_catalog.py \
--helpers-cache json/helpers_cache.json \
--existing json/app_tracking_hints.json \
--runtime-overrides json/runtime_verified_overrides.json \
--include-helper-markers \
--output json/app_tracking_hints.generated.json \
--v2-output /tmp/app_tracking_catalog.v2.json \
--audit-output /tmp/app_tracking_hints.audit.json
- name: 🧬 Smart-merge generated into app_tracking_hints.json
# Single source of truth: `app_tracking_hints.json` is the ONE
# file. It contains 3 kinds of entries:
# 1. Auto-verified from community-scripts (the generator
# manages every "generator-owned" field on these).
# 2. User-edited additions to those entries — extra fields
# the generator doesn't touch (default_ports,
# file_fallbacks, custom logo overrides…).
# 3. User-only entries the generator can't verify (Docker,
# AdGuard, Pi-hole, WireGuard, …) — left alone.
# Merge rule: for slugs the generator produces, refresh only
# the whitelisted fields; preserve everything else. For slugs
# NOT in the generator's output, keep the existing entry
# untouched.
run: |
python - <<'PY'
import json
from pathlib import Path
GEN = Path("json/app_tracking_hints.generated.json")
OUT = Path("json/app_tracking_hints.json")
# Fields owned by the generator — refreshed on every run.
# These are all populated deterministically by the generator
# (the audit script folds `runtime_verified_overrides.json`
# in as it runs), so a local hand-edit for a generator-known
# slug would get overwritten on the next tick. To add a new
# canonical path or a cross-method fallback for a slug the
# generator already knows, edit `runtime_verified_overrides
# .json` — that file IS the maintainer-controlled input.
#
# For user-only slugs (Docker, WireGuard, Pi-hole and any
# other entry not in the generator's output) EVERY field is
# preserved verbatim by the merge below — the whitelist only
# governs generator-covered slugs.
GENERATOR_FIELDS = {
"installed_via", "package", "file_path", "file_regex",
"binary_path", "binary_args", "python_path", "distribution",
"container_name", "label", "command_argv", "installed_version",
"repo", "github_source", "tag_regex", "installed_regex",
# Upstream source discriminator + per-type fields
# (http_json + docker_hub). Kept in the whitelist so a
# curated entry in runtime_verified_overrides.json can
# supply them and the smart merge won't drop them on the
# next regeneration.
"upstream_type", "upstream_url", "upstream_json_path",
"docker_image",
"logo", "website",
"default_ports", "file_fallbacks", "alt_detectors",
}
generated = json.loads(GEN.read_text(encoding="utf-8"))
existing = {}
if OUT.is_file():
try:
existing = json.loads(OUT.read_text(encoding="utf-8"))
if not isinstance(existing, dict):
existing = {}
except json.JSONDecodeError:
existing = {}
merged = {}
for slug, gen_entry in generated.items():
base = dict(existing.get(slug) or {})
# Refresh generator-owned fields (add/update).
for k, v in gen_entry.items():
if k in GENERATOR_FIELDS:
base[k] = v
# Drop generator-owned fields that the generator no
# longer emits for this slug (e.g. path renamed away).
for k in list(base):
if k in GENERATOR_FIELDS and k not in gen_entry:
del base[k]
merged[slug] = base
# Preserve user-only entries the generator can't verify.
for slug, entry in existing.items():
if slug not in generated and isinstance(entry, dict):
merged[slug] = dict(entry)
OUT.write_text(json.dumps(merged, indent=2, sort_keys=True) + "\n", encoding="utf-8")
added = sorted(set(generated) - set(existing))
removed = sorted(set(existing) - set(generated) - {s for s, e in existing.items() if not (
set(e.keys()) - GENERATOR_FIELDS
)})
print(f"merged: {len(merged)} entries "
f"(generated={len(generated)}, existing={len(existing)})")
if added:
print(f" new from generator: {len(added)}")
# Clean up the intermediate file so it doesn't get committed.
GEN.unlink()
PY
- name: 📤 Commit + push if changed
run: |
git config user.name "ProxMenuxBot"
git config user.email "bot@proxmenux.local"
git add json/app_tracking_hints.json
git diff --cached --quiet || git commit -m "Update app tracking hints"
git push