fix(i18n): protect the literals a reader copies, and name the OCI category

The glossary only holds what someone listed, and what reaches the reader
as a broken command was never on it. Paths, long options and Proxmox
subcommands are now recognised by shape:

    /dev/apex_0        came back as  /dev/apex 0
    --auto-uninstall                 desinstalación automática
    pct config 110                   configuration PCT 110   (fr)
    pct enter 101                    pct inserire 101        (it)

OCI joins the glossary as well. Read as a word it became "BEC" in French
across thirty-one strings — "Vérification du BEC" named nothing — and the
menu entry read "Gestionnaire du BEC Apps". The entry itself and "beta"
are protected too, so the name stays the same in every language.

A provider can also alter a token rather than carry it through: argos
returned MPXTERM000 for PMXTERM000, the restore found nothing, and the
token shipped. "MPXTERM000 configuré" is in the French catalogue today.
The restore now verifies its own tokens and fails the string instead,
which leaves the key absent for the next run to retry.

Spanish category labels: twenty-six were wrong. Seven collapsed two
categories into one — "Backup & Recovery" read only "Recuperación",
"Network & Firewall" read "Red de cortafuegos" — six were half
translated, and the connector alternated between "&", "y" and "e". They
now use "y" or "e" throughout. "IoT & Smart Home" and
"AI / Coding & Dev-Tools" stay in English by choice.

"Messaging & Queues" becomes "Messaging & Notifications": its only
application is Apprise, a notification gateway, not a queue. The
Helper-Scripts import still maps their name, so nothing breaks upstream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MacRimi
2026-09-23 17:49:59 +02:00
co-authored by Claude Opus 5
parent 4f79144c5a
commit fe46d3d4bc
20 changed files with 125 additions and 76 deletions
+46 -1
View File
@@ -99,6 +99,11 @@ PROTECTED_TECHNICAL_TERMS = (
# and the reader is told about something Proxmox does not call that.
"CT",
"VM",
# Open Container Initiative. Read as a word it becomes "BEC" in French,
# which named nothing, across thirty-one strings of the OCI section.
"OCI manager Apps",
"OCI",
"beta",
)
TECHNICAL_TERM_RE = re.compile(
"|".join(
@@ -114,6 +119,20 @@ TRANSLATE_CALL_RE = re.compile(
# Same shape the Monitor's generator uses, so a message reads identically
# whichever of the two catalogues it comes from.
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")
# Literals a reader copies and runs. Translated as words they stop working:
# /dev/apex_0 came back as "/dev/apex 0", --auto-uninstall as "desinstalación
# automática", and "pct config 110" as "configuration PCT 110". A glossary can
# only hold what someone listed; these are recognised by shape.
STRUCTURAL_LITERAL_RE = re.compile(
r"""
/(?:dev|etc|usr|var|opt|srv|proc|sys|run|boot|tmp)/[\w./+-]*[\w/+-]
| (?<![\w-])--[A-Za-z][\w-]*
| \b[\w.-]+\.(?:sh|py|json|yml|yaml|conf|service|timer|func|cfg|list|log)\b
| \b(?:pct|qm|pvesm|pveam|pveum|pvecm|apt-get|dpkg|systemctl|journalctl
|zpool|smartctl|modprobe|blkid|lsblk|mkfs)\s+[a-z][\w-]*
""",
re.VERBOSE,
)
# Providers that answer the same thing every time for the same input, so a
# second attempt cannot produce a different result. `appimage` shells out to a
# binary that may reach a network service, so it is not on the list.
@@ -180,13 +199,20 @@ def restore_placeholders(text: str, found: list[str]) -> str:
def protect_technical_terms(text: str) -> tuple[str, list[str]]:
"""Replace glossary terms with stable tokens before translation."""
"""Replace glossary terms with stable tokens before translation.
Structural literals go first. A glossary can only list what someone
thought of, and what reaches the reader as a broken command is never on
that list: an absolute path, a long option, a filename. They are
recognised by shape instead, which also covers the next one added.
"""
protected: list[str] = []
def _swap(match: re.Match[str]) -> str:
protected.append(match.group(0))
return f"PMXTERM{len(protected) - 1:03d}"
text = STRUCTURAL_LITERAL_RE.sub(_swap, text)
return TECHNICAL_TERM_RE.sub(_swap, text), protected
@@ -197,6 +223,24 @@ def restore_technical_terms(text: str, protected: list[str]) -> str:
return text
# A provider can alter a token instead of carrying it through: argos returned
# MPXTERM000 for PMXTERM000, the replace found nothing, and the token shipped
# to the reader — "MPXTERM000 configuré" is in the French catalogue now.
SENTINEL_RESIDUE_RE = re.compile(r"[MP][MPX]X?\s?(?:TERM|PH)\s?\d{2,4}", re.IGNORECASE)
def assert_no_sentinel_residue(text: str) -> None:
"""Fail the translation when a protection token did not survive intact.
Leaving the key out is recoverable: the next run tries again. Storing a
string with a token in it is not, because nothing looks at a value that
already exists.
"""
found = SENTINEL_RESIDUE_RE.search(text)
if found:
raise RuntimeError(f"the provider altered a protection token: {found.group(0)!r}")
def iter_script_files(
scripts_dir: Path, extra_files: Iterable[Path] = ()
) -> Iterable[Path]:
@@ -490,6 +534,7 @@ def translate_text(
raise ValueError(f"Unknown provider: {provider}")
translated = restore_technical_terms(clean_translation(translated), protected_terms)
translated = restore_placeholders(translated, placeholders)
assert_no_sentinel_residue(translated)
return restore_sentence_spacing(translated) if translated else text