mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
- rewrite the 15 post-install pages and the 3 hardware GPU pages so they reflect the current scripts (reversibility, tracked-tool counts, kernel parameters, per-tool commands, Alpine LXC propagation flow) - migrate the legacy step-badge helper on post-install/optional and create-vm/synology to the canonical pill component, with the stepLabel key added in each locale - fix rich-text i18n calls missing helpers across network, automated, optional, security, customization and the post-install landing pages, and escape the `<iface>` placeholder in automated so intl no longer parses it as a tag - remove the mouse-follow blue overlay from the docs landing layout - reposition the App-tab Edit button and stack the Search and Register controls vertically on mobile - move the Bulk update Configure/Edit control into the section header so it behaves the same on desktop and mobile - show a spinner during the final autoremove/autoclean pass of update-pve-safe so the cleanup step reads as active instead of silent - restyle the shell spinner and msg_info in a distinctive purple and drop the unused msg_lang duplicate - add a web-docs i18n build script and its CI workflow, plus tests for the pushover notification channel
854 lines
29 KiB
Python
854 lines
29 KiB
Python
#!/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())
|