Every rule so far named a construct to protect, and every run found one that
had been forgotten: paths, underscores, bracketed URLs, then the bracket
itself. 7338 sources still reached the translator carrying markup, and one in
eight came back broken.
Mask the delimiters themselves, so nothing a translator would reformat is left
in the text: emphasis, backticks, brackets, braces, parentheses, double quotes,
quoted identifiers and digits. Single quotes stay exposed on purpose, because
1419 of the 1494 sources that carry one are English contractions, and masking
those would hand over fragments instead of words.
Costs nothing: the 2606 entries that end up without translatable text were
already empty under the old rules, and the newly masked ones are galaxy_tags
lists and JSON fragments.
Two defects survive masking because they alter no protected span, so unmask
repairs them against the source: a translator reading a trailing span as
sentence-final swallows the boundary behind it, gluing the next sentence to a
code span, and it spaces out emphasis, which markdown then renders literally.
The standing checks assert the property rather than enumerate constructs, and
cover core and docs, where before only the span-parity check did, which is why
none of this was ever caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The target span started at the closing bracket, so masking [RSA 4096-bit](url)
left an unbalanced [RSA 4096-bit in the translatable text. The translator
closed it, and restoring the span produced [RSA 4096-bit]](url). Parity never
noticed, because the extra bracket sits outside every protected span, and
1248 German entries carried it.
A lookbehind now keeps the bracket in the text and masks the target alone.
The existing damage is repaired in place rather than discarded: the sources
hold no ]]( at all, so every occurrence in a translation was spurious, and
the German around it was sound.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The URL pattern stopped at the first closing parenthesis, so a Wikipedia
link such as .../Social_engineering_(security) was only masked up to the
bracket. The translator then glued the remainder to the following word and
produced .../Social_engineering_(security)auf, which the external URL check
caught as HTTP 404. Markdown targets clipped the same way, and such targets
exist in several role READMEs.
Both patterns now accept a balanced parenthesis group, so the whole link
becomes one protected span. Prose in parentheses still masks to nothing.
Pruning both domains against the stricter protection emptied nine German
entries, four in core and five in docs, including the two glued links. They
refill on the next translation run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LibreTranslate rewrote anything that read like prose: assets/img/ became
Vermoegenswerte/img/ and ](path) collapsed to )path). Mask multi-segment
repository paths, bare filenames and markdown link targets alongside the
spans that were already protected, so the translator never sees them.
The catalogs still carry the earlier damage, so add a prune subcommand. It
compares the protected spans of source and translation and empties every
entry whose translation altered them, which lets the next run refill it. A
pass over the German docs catalog cleared 85 entries without losing a single
intact translation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ephemeral LibreTranslate container starts against the -cuda image with
--gpus all wherever docker registers an nvidia runtime, and keeps the CPU
image everywhere else, so runners without the container toolkit behave as
before. Measured on an RTX 3080 Ti: 44% utilisation, 1744 MiB resident,
482 sentences per 30 seconds.
Only the tool derives the suffix. web-svc-libretranslate stays pinned to the
CPU image, so no deploy host is required to install the toolkit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The header test writes a .po file into its tempdir and reads it back. The
shared cache is keyed by path, so a cached read answers with the content
from before the write. The marker sits on the call's own line because the
lint accepts it nowhere else, and the shorter comment survives the
formatter that had detached it once already.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
port-ui reads per-language content catalogs from i18n/content/<code>.yaml,
mapping each English text to its translation. The new i18n_content lookup
builds them from the core catalogs for every message whose msgctxt starts
with role: or menu:, for each language that translates at least one of
them. The role renders one file per language through
render_replicated_templates, so swarm peers carry the same catalogs, and
mounts the directory at /app/i18n/content.
The German Playwright test compares / with /de/ and expects at least one
translated card description and one translated menu category. Its expected
cards are the deployed roles (group_names) rather than
roles_with_service('dashboard'): the minimal variant switches the dashboard
flag off on the apps, which emptied the expectation while the page still
showed their cards.
to_nice_yaml already passes allow_unicode=True to yaml.dump; passing it
again raised "got multiple values for keyword argument 'allow_unicode'".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
locale/<code>/LC_MESSAGES/core.po now exists for all 183 non-source
languages with the 407 core messages. German is machine-translated in full;
the 29 languages the logout panel shipped are carried over from
logout_i18n.yml.
The keycloak logout panel reads every language but English from core.po
through the new i18n_catalogue lookup, so logout_i18n.yml keeps only the
English source strings. Each language takes its text direction from
meta/languages.yml. The Playwright spec asserts the German catalogue on the
served logout page.
Catalogs no longer carry Babel's placeholders: render() writes the author,
kevinveenbirkenbach@infinito.nexus, https://infinito.nexus, the Infinito.Nexus
Community License and contact@infinito.nexus as the bug address into every
header it writes, from constants in utils/software.py.
tests/lint/repository/test_i18n_catalogs.py holds that every language has a
core catalog, none exists for an unknown language, the core catalogs hold
exactly the current sources, and every translation keeps its protected
spans.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Core and the documentation get gettext catalogs for all 184 ISO 639-1
languages listed in meta/languages.yml, 47 of which LibreTranslate can fill.
`make i18n-extract` merges the current sources into locale/<code>/
LC_MESSAGES/{core,docs}.po: role descriptions, category and menu titles and
the logout strings for the core domain, the Sphinx gettext output for the
docs domain. A changed source keeps its previous translation as a fuzzy
entry, and catalogs are written deterministically so an unchanged merge
leaves the files untouched.
`make i18n-translate domain=<core|docs> [languages=<codes>]` fills only the
empty and fuzzy entries with a short-lived LibreTranslate container (the
image web-svc-libretranslate pins) and removes it afterwards. It starts the
container, and loads models, only for languages that still have pending
entries. Code spans, placeholders, URLs, tags and dotted names such as
Infinito.Nexus are masked for the request and a translation that damages
one is discarded. An unreachable server stops the run instead of
discarding every remaining entry, so a rerun continues where it stopped;
machine translations carry the translator comment `libretranslate`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>