mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
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
This commit is contained in:
@@ -16,6 +16,8 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
@@ -160,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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user