Files
ProxMenux/.github/scripts/build_web_docs_i18n.py
T
MacRimi fcfe8da765 refine post-install and hardware GPU docs, Monitor UX and CLI styling
- 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
2026-08-26 17:23:09 +02:00

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())