Skip to content

l10n: put the translation files through the wringer — all 36 locales at full parity - #2634

Draft
SudoThijn wants to merge 94 commits into
developmentfrom
feature/l10n-fixes
Draft

l10n: put the translation files through the wringer — all 36 locales at full parity#2634
SudoThijn wants to merge 94 commits into
developmentfrom
feature/l10n-fixes

Conversation

@SudoThijn

Copy link
Copy Markdown
Contributor

The l10n files have been through the wringer.

All 36 non-English locales now carry a real translation for every one of the
2052 keys in en.js — key-for-key, no gaps, no English fallbacks hiding in the
bundles. Along the way a lot of what was already in there turned out to be wrong
rather than missing: wrong-language values, terminology that drifted two ways in
one bundle, buttons written against the locale's own convention, plural arrays
copied between languages that don't share boundaries. Those got audited and fixed
per locale rather than just topped up.

The checks were auditing the wrong file

tests/l10n/check-l10n.js was checking frontend t() literals against
l10n/en.json — the backend catalogue, read by PHP IL10N, which no frontend
code path ever loads. So it demanded bookkeeping in a file the browser never sees
while l10n/en.js, the one it actually loads, went unaudited and drifted about
700 keys behind src/. Nothing caught it, because a missing key makes
OC.L10N fall back to the English source string, which renders correctly.

The two sets are separate concerns with separate consumers, not two renderings of
one source, and the check now says so. The backend .json set still has no
scanner — it would need to walk lib/ for PHP $l->t(), not src/.

Same file, second bug: an n() call has two source strings but only one catalogue
key, and it is neither of them — it's the _singular_::_plural_ identifier.
The gate required the bare singular, which renders correctly at count 1 and falls
back to English for every other count, so every "3 objects" in all 36 locales
rendered English while the gate stayed green.

Also in here

  • The parity gate is now unconditional. check-l10n-parity.js used to hold
    only a hand-maintained "finished" list to full parity, with an env override.
    With nothing left in progress that list was just a knob for turning a red build
    green, so it's gone: missing keys, empty values and wrong plural arity are fatal
    for every locale. It covers both sets, keeping them apart where they differ —
    plural arity is checked on the frontend set only, since .json plurals use a
    keyed object shape.
  • Committed the per-locale tooling under scripts/l10n/ — status, worklist,
    harvest, register detectors, apply-with-gates, selfcheck, runtime check, and the
    reading aids (core diff, term drift, spell, script coverage, casing). Plus the
    runbook in docs/l10n-workflow.md so the next person doesn't rebuild it.

SudoThijn added 30 commits July 31, 2026 10:04
Rewrites every l10n/*.js with keys in case-insensitive alphabetical order so
that subsequent translation diffs are small and reviewable. Previously the
bundles carried historical insertion order, which meant any tool that rewrote
a file produced a whole-file diff and buried the actual changes.

Sorting is case-insensitive with a code-unit tie-break, so it is deterministic
and does not depend on the Node/ICU localeCompare implementation.

Purely mechanical: values were taken from HEAD and verified unchanged.
Verified across all 37 files / 54113 keys: zero value changes, zero keyset
changes, all files valid JS, OC.L10N.register executes with the expected key
count and plural-forms string.
Ten Dutch entries were still defective after the main Dutch pass:

Half-translated machine output, Dutch words in English word order:
  "Weet u zeker dat u wilt verwijderen the geselecteerd audittrails?"
  "No bestands have been extracted yet"
  "Kon niet update schema properties"
  "Select default organisatie"
  and two "Use filters to narrow down ..." strings left almost fully English

Formal address, which the rest of the bundle and Nextcloud core both avoid:
  "Dank u!" -> "Bedankt!", "uw" -> "je", "Weet u zeker" -> "Weet je zeker"

These slipped through because the previous check had no formal-address test at
all, and its English-marker list excluded so many words that collide with
Dutch that badly-mixed strings scored as clean.

Note the embeddings warning exists twice in the bundle, once with a literal
\n escape and once with a real newline; only the escaped copy was broken.

Verified: 0 keys lost, 0 added, exactly 10 values changed, 0 formal-address
entries remaining, 0 plural-arity errors.
Brings l10n/de.js from 950 to full coverage of every string the frontend
requests via t()/n(). No entry has a value equal to its key.

  harvested            63  existing Transifex output for the identical source
                           string, from sibling Conduction apps and the
                           Nextcloud server tree
  hand-translated     970  written for this app
  formal -> informal   98  see below
  placeholders        109  entries whose value equalled their key
  identity keys        65  removed, see below

Register: informal du/dein throughout. Nextcloud ships the formal German
variant as a separate de_DE locale, and openregister has no de_DE, so plain
de.js is the informal bundle. Verified against the server tree:
core/l10n/de.js is 58 informal vs 1 formal, settings 108 vs 4. 98 pre-existing
entries used "Sie"/"Ihr" and were rewritten ("Verwalten Sie Ihre Register" ->
"Verwalte deine Register").

Identity strings are ABSENT rather than stored as value===key. For source
strings that are genuinely the same word in German ("Code", "Status", "Port",
"Maximum", "Repository") or must not be translated at all ("sk-...",
"https://example.com/webhook") OC.L10N falls back to the English source and
renders identical text, but the entry is no longer indistinguishable from an
untranslated placeholder. Tracked in de-identity.json.

Terminology: the source embeds Dutch legal vocabulary, which is mapped to
German equivalents rather than passed through -- "Inzage (Art 15)" ->
"Auskunft (Art. 15)", "Art 17 vergetelheid" -> "Recht auf Vergessenwerden",
"Art 20 portabiliteit" -> "Datenübertragbarkeit", "Bewaartermijn" ->
"Aufbewahrungsfrist", "verwerkingsactiviteit" -> "Verarbeitungstätigkeit",
"verantwoordingsdocument" -> "Rechenschaftsdokument", GDPR/AVG -> DSGVO.

Three harvested values were rejected as wrong for this app's context:
"Open" -> "Öffnen" (a button, not the adjective "Offen"), "Right" -> "Recht"
(an RBAC permission, not the direction "Rechts"), "Subject" ->
"Betroffene Person" (a GDPR data subject, not an email "Betreff").

Known source-side limitation: the "object{plural}" family interpolates a
literal "s"/"" for pluralisation, which cannot work in German. Those render as
"Objekt(e)"; "schema{plural}" keeps the placeholder because German does
pluralise Schema with -s.

Verified: 1996/1996 frontend keys translated, 0 absent, 0 value===key anywhere
in the bundle, 0 plural-arity errors, 0 unreviewed formal address, all 5 plural
keys carry 2-form arrays, file is valid JS and OC.L10N.register executes with
2324 keys. No pre-existing translation was lost: the only 33 baseline keys
removed were deliberate identity strings that had been value===key.
Brings l10n/fr.js from 951 to full coverage of every string the frontend
requests via t()/n(). No entry has a value equal to its key.

  harvested            61  existing Transifex output for the identical source
                           string, from sibling apps and the Nextcloud server tree
  hand-translated     920  written for this app
  placeholders         27  entries whose value equalled their key
  identity keys        86  removed, see below

Register: FORMAL (vous/votre), which is what Nextcloud core actually uses for
French. This differs from the informal de/nl bundles and was measured, not
assumed: across server/{core,lib,apps/*}/l10n, fr is 39 informal vs 412 formal
(core alone 76 vs 9, settings 171 vs 16), and core ships no separate formal
French variant. The rule is "match Nextcloud core", and for French that means
vous.

Identity strings are ABSENT rather than stored as value===key, so the runtime
falls back to the English source and renders identical text without the entry
being indistinguishable from an untranslated placeholder. French shares a great
deal of vocabulary with English here ("Action", "Configuration", "Description",
"Format", "Total", "Type", "Version", "Notifications", "Expiration", "Notes",
"Score", "Public"), so the identity list is larger than German's. Tracked in
fr-identity.json.

Typography follows French convention: a space before ':' '?' '!' and guillemets
« » for quoted UI labels, as Nextcloud French does.

Terminology: Dutch legal vocabulary in the source is mapped to French GDPR
terms -- "Inzage (Art 15)" -> "Accès (art. 15)", "Art 17 vergetelheid" ->
"droit à l'oubli", "Art 20 portabiliteit" -> "portabilité", "Bewaartermijn" ->
"Durée de conservation", "verwerkingsactiviteit" -> "activité de traitement",
"verantwoordingsdocument" -> "document de responsabilité", AVG/GDPR -> RGPD.

Five harvested values were rejected as wrong for this app's context:
"Open" -> "Ouvrir" (button, not the adjective "Ouvert"), "View" -> "Afficher"
(action verb, not the noun "Affichage"), "Right" -> "Droit" (RBAC permission,
not the direction "Droite"), "Subject" -> "Personne concernée" (GDPR data
subject, not email "Objet"), "Link" -> "Associer" (dialog button verb, not the
noun "Lien").

Note French plural-forms is "nplurals=2; plural=(n > 1)", unlike German's
"(n != 1)"; all 5 plural keys carry correctly ordered 2-form arrays.

Verified: 1996/1996 frontend keys translated, 0 absent, 0 value===key anywhere
in the bundle, 0 plural-arity errors, 0 unreviewed wrong-register entries, file
is valid JS and OC.L10N.register executes with 2315 keys. No pre-existing
translation was lost: all 49 baseline keys removed were deliberate identity
strings that had been value===key.
Removes entries that are BOTH unreachable and untranslated:
  * no t()/n() call requests the key — neither a literal call nor any of the
    enumerated dynamic ones (see below), and
  * the value equals the key, i.e. it was never translated.

This cannot change what any user sees. An entry whose value equals its key
already renders the English source string; once removed, OC.L10N falls back to
the English source and renders the same string. Verified mechanically across all
37 files: 904 keys removed, 0 of them holding anything other than value===key,
and 0 existing values altered.

Unreachable keys that DO carry a real translation were deliberately left alone.
They are harmless, and deleting them would risk discarding real translation work
if the reachability analysis were ever incomplete.

Most of these are residue from two known events. Commit 03cda6c ("fix(i18n):
unwrap numeric/URL placeholders from t() per PR #1273 review") correctly stopped
wrapping numeric and infrastructure-URL placeholders in t(), but never removed
the keys it orphaned -- hence "3", "30", "http://localhost:11434" and
"https://api.fireworks.ai/inference/v1" in every bundle. Separately, the SOLR /
Zookeeper settings UI was removed from src/ without cleaning its strings, so
keys like "Zookeeper Hosts" and "SOLR Connection Settings" survive with no call
site. Confirmed absent from src/ before removal.

en.js is excluded: it is the English source bundle, where value===key is correct
by definition rather than a placeholder.

All 37 files remain valid JS.
…traction missed

Some strings reach t() as a variable rather than a literal:

  t('openregister', action)        PermissionMatrix.vue:41, over
                                   actions: ['read','create','update','delete','manage']
  t('openregister', step.status)   ApprovalStepList.vue:17, over the approval
                                   statuses used by lib/Controller/ApprovalController.php
  t('openregister', preset.label)  DashboardIndex.vue:91/120/360, over the date
                                   presets declared at :212-216

None of these keys can be found by scanning for literal t() arguments, so all 15
were absent from every bundle. The Permission Matrix column headers, the
approval-status badges and the dashboard date-range presets were therefore
rendering in English even in locales reported as fully translated. The key list
now lives in dynamic-keys.json with its provenance, and feeds the same
absent/placeholder/register checks as every other key.

Two dynamic sites remain un-enumerable and are documented as such: ApprovalStepList
step.role (schema-configured, arbitrary) and MainMenu.vue:76 translate(key) (app
manifest labels). A third, RegisterSchemaCard.vue:714, wraps a runtime-built
template string in t() and so can never match a catalogue key — that is a source
bug rather than a missing translation.

Also in this commit, for nl only:
  * "Driver" -> "Stuurprogramma", "Url" -> "URL", "object{plural}" -> "object(en)",
    "log{plural}" -> "logboek(en)", and both real-newline variants of the
    PERMANENT DELETION WARNING, which had only been done for the \n-escaped copies
  * 29 identity strings ("Code", "Status", "Type", "Dashboard", "sk-...") converted
    from value===key to absent, matching the treatment already applied to de and fr
  * nl-identity.json reconstructed (72 entries) so the check is reproducible

Verified: nl, de and fr each report 2011/2011 keys translated with 0 absent,
0 value===key, 0 wrong-register and 0 plural-arity errors.
Brings es.js from 972 to 2011 reachable keys: 983 new translations,
16 placeholder entries replaced with real Spanish, and 17 legitimate
identity strings dropped so they fall back to the English source
instead of masquerading as translations.

Also fixes 106 PRE-EXISTING entries that the earlier register check
could not see. Spanish is pronoun-dropping, so formality lives in the
verb, not in a pronoun: "Seleccione un registro" is formal address with
no "usted" anywhere, and a check for the pronoun alone reported the
bundle clean. Widening the check to the usted imperative (3rd-sg
subjunctive: -ar -> -e, -er/-ir -> -a) plus the possessive su/sus
surfaced 107 formal entries, 105 of which were converted to the tú
forms Nextcloud core uses for Spanish. The remaining one was a
terminology split ("Rastro de auditoría" against nine occurrences of
"registro de auditoría").

The verb list was harvested from the sentence-initial and
post-punctuation words actually present in es.js rather than guessed —
"Gestione" and "Habilite" were both missing from the guessed list.

su/sus is both the formal "your" and the third-person "his/her/its/
their", so it is gated on the English source containing "your";
where the source says "its"/"their", su/sus is simply correct. Ten
positive/negative controls cover the gate. Only five strings that say
both "your" and "their" still need suppressing by hand.

Harvest sources are now ranked core-first. Previously the walk order
let sibling Conduction apps shadow server/, so generic UI strings were
taken from apps whose own Spanish is not authoritative. Eleven of the
67 harvested values were still wrong for this app's context and were
rewritten: Open/View/Link are verb buttons here (Abrir/Ver/Vincular,
not Abierto/Vista/Enlace), Right is a permission (Derecho, not the
direction Derecha), Subject is the GDPR data subject (Interesado, not
the email Asunto), and Languages are human languages (Idiomas, not
Lenguajes).

Unlike German and Dutch, Spanish pluralises with -s exactly as English
does, so the object{plural}/register{plural} family interpolates
correctly here and is translated with the placeholder intact.

Verified: 2011/2011 reachable keys, 0 absent, 0 value===key, 0 hybrid,
0 wrong register, 0 plural-arity errors, valid JS, and OC.L10N.register
loads all 2355 entries. Backend l10n/es.json is untouched.
Brings it.js from 969 to 2011 reachable keys: 983 new translations, 17
placeholder entries replaced with real Italian, and 19 legitimate
identity strings dropped so they fall back to the English source
instead of masquerading as translations.

No pre-existing entry needed rewriting — unlike Spanish, the Italian
already in the bundle was consistently informal (Seleziona, Gestisci,
Crea). That is a verified result rather than an assumption: the register
check for Italian was as blind as the Spanish one, matching only
(Lei|Vi preghiamo), which finds almost nothing in a pronoun-dropping
language. It now covers the usted-equivalent imperative, which in
Italian is the MIRROR of Spanish: -are -> -i (selezioni), -ere/-ire -> -a
(scelga, inserisca). That collides head-on with the INFORMAL -ere/-ire
imperative, which also ends in -i (scegli, inserisci), so the ending
alone proves nothing and the verb list is explicit.

Filtri, Ordini, Usi, Controlli, Termini, Continui and Faccia are left
out on purpose: each is an ordinary Italian noun or adjective and would
bury real hits in noise. Infinitive-as-instruction ("Eliminare",
"Utilizzare") is standard register-neutral Italian UI and is not
flagged. 16 positive/negative controls cover the pattern, including one
that caught a genuine inversion in my first draft: "Premi" is the
INFORMAL imperative of premere and had been listed as formal.

Six of the 70 harvested values were wrong for this app's context.
Subject was the worst: pipelinq's "Oggetto" is the email subject AND
this bundle's own word for Object, so a GDPR data subject column would
have read "Object" — it is "Interessato". Open/Link are verb buttons
here (Apri/Collega, not Aperto/Collegamento), Right is a permission
(Diritto, not the direction Destra), Other labels a group (Altri), and
Mappings is properly "Mappature". "Test" was also reclassified: it is a
webhook action button, so Italian wants the verb "Prova", not the noun
loanword.

Italian pluralises by vowel change, not with -s, so the
object{plural}/register{plural} family CANNOT use the literal "s" the
source interpolates — it would render "oggettos". Those are written with
an explicit both-forms notation (oggetto/i, registro/i, schema/i), and
file{plural}/log{plural} simply drop the placeholder because both nouns
are invariant in Italian.

"{count} email" is the one entry deliberately written as value===key:
"email" is invariant, so both plural forms equal the English source, and
leaving the key ABSENT is not equivalent — OC.L10N would fall back to
the English plural rule and render "{count} emails". apply.js gained a
narrow --allow-identity opt-in for exactly this case; --force still does
not lift the value===key ban.

Verified: 2011/2011 reachable keys, 0 absent, 0 hybrid, 0 wrong
register, 0 plural-arity errors, valid JS, OC.L10N.register loads all
2353 entries, and the only value===key entry is the documented
invariant plural. No pre-existing translation was altered. Backend
l10n/it.json is untouched.
Brings pt.js from 973 to 2011 reachable keys: 984 new translations, 17
placeholder entries replaced with real Portuguese, and 15 legitimate
identity strings dropped so they fall back to the English source
instead of masquerading as translations.

PORTUGUESE INVERTS THE SPANISH RULE, and getting that backwards would
have wrecked the whole locale. In Spanish, "Seleccione" / "su" is
deferential usted and had to be replaced with tú forms. In Portuguese
the same 3rd-person morphology ("Selecione" / "seu") is the NEUTRAL você
register that Portuguese software UI uses, and the 2nd-person tu forms
are the ones that read wrong. Measured rather than assumed, across
server/{core,lib,apps/*}:

  pt_BR   tu 0 : você 438
  pt_PT   tu 4 : você 128

Both variants converge on você, so the single generic "pt" bundle this
app ships is correct for both. The register check for pt was therefore
written to flag the TU forms, with a comment saying so, because the
obvious next move for anyone reading the Spanish entry would be to
"fix" it by analogy and break 2000 strings. 10 positive/negative
controls pin the direction down.

The bundle is EUROPEAN Portuguese and the new strings follow it:
ficheiro, registo, eliminar, guardar, utilizador, aplicação,
definições. That was measured too (306 pt_PT-style terms against 11
apparent pt_BR ones, and all 11 turned out to be correct anyway —
"padrão" translates Pattern, not "default", and "configurações"
renders configurations as distinct from settings/"definições").
Existing style is also preserved: infinitive for controls (Selecionar,
Criar, Eliminar) and você imperative for prose instructions (Selecione,
Configure, Introduza).

Only one pre-existing entry was changed: "Trilho de auditoria" against
nine occurrences of "registo de auditoria".

Three of the 31 harvested values were wrong for this app's context —
Right is a permission (Direito, not the direction Direita), Link is a
confirm button (Associar, not the noun Ligação), and Edit Endpoint kept
the loanword to match the bundle's existing "Adicionar Endpoint" rather
than openconnector's "ponto final". None of the harvest was
authoritative here: core ships pt_BR and pt_PT but no bare pt, so every
candidate came from a sibling Conduction app and each was reviewed.

Like Spanish and unlike Italian, Portuguese pluralises with -s, so the
object{plural}/register{plural} family works with the literal "s" the
source interpolates and is translated with the placeholder intact.

Verified: 2011/2011 reachable keys, 0 absent, 0 value===key, 0 hybrid,
0 wrong register, 0 plural-arity errors, valid JS, and OC.L10N.register
loads all 2357 entries. Backend l10n/pt.json is untouched.
Brings sv.js from 958 to 2011 reachable keys: 981 new translations, 15
placeholder entries replaced with real Swedish, and 23 legitimate
identity strings dropped so they fall back to the English source
instead of masquerading as translations. No pre-existing translation
was altered.

Swedish shares far more of this app's vocabulary outright than the
Romance locales do, which cut both ways. The identity list is nearly
twice as long (register, schema, status, organisation, person, port,
version, format, maximum, minimum are all Swedish words with the same
spelling) and the hybrid detector was firing on nine perfectly good
Swedish strings purely because they contained "register" and "schema".
Those words are now declared as collisions for sv, the same treatment
de and nl already needed, so a hybrid hit means something again.

Register/Schema are capitalised mid-sentence throughout, which is not
standard Swedish orthography but IS this bundle's established
convention for the domain entities — measured at 54 capitalised
against 2 lowercase before I added anything, so the new strings follow
it rather than splitting the file two ways. Terms with their own
precedent keep it: "slutpunkt" stays lowercase to match the existing
"Lägg till slutpunkt".

Swedish is informal by default (du) — 358 informal against 0 formal in
core — and needed no register conversion.

Four of the 45 harvested values were wrong for this app's context: Open
is a verb button here (Öppna, not the adjective Öppen that circles
supplies), Right is a permission (Rättighet, not the direction Höger),
Link is a confirm button (Länka, not the noun Länk), and Edit Endpoint
was recased to match the bundle. Core's "Webbadress" for URL was
deliberately NOT taken: this bundle uses "URL" consistently (Bas-URL,
Databas-URL, "namn eller URL"), so the standalone label stays identity.

Swedish pluralises by suffix change or not at all, never with -s, so
the {plural} family cannot use the literal "s" the source interpolates.
These are count-labels under a number, so: object{plural} and
register{plural} drop the placeholder entirely (both nouns are
invariant — "5 objekt", "5 register"), while file{plural},
log{plural} and schema{plural} use an explicit both-forms notation
(fil(er), logg(ar), schema(n)).

Verified: 2011/2011 reachable keys, 0 absent, 0 value===key, 0 hybrid,
0 wrong register, 0 plural-arity errors, valid JS, and OC.L10N.register
loads all 2347 entries with no benign suppressions needed at all.
Backend l10n/sv.json is untouched.
Every string the frontend reaches via t()/n() now has a real Danish
translation. 981 new entries, 15 English placeholders replaced, 23
identity strings dropped so the runtime falls back to the source.
No pre-existing translation was altered except one grammar fix (below).

Register: informal (du/din/dit/dine), verified against Nextcloud core.

The old detector for Danish was /(De|Deres)/ and reported 15 hits in
core. All 15 were false positives: lowercase de/dem/deres are the
ordinary words for they/them/their -- and "de" is also the definite
article -- so they capitalise at sentence start and become
indistinguishable from the formal pronouns. "De genererede billeder" is
"The generated images"; "Deres stier" is "Their paths". Core is
572 informal : 0 genuinely formal.

Rewrote the detector to require a MID-SENTENCE capital, excluding every
position where a capital is explained by orthography rather than
register (string start, after sentence punctuation, after newline or
bullet, after an opening quote). Danish opens quotes with the glyph
English uses to close them, so both directions count as sentence start.
Validated on 16 must-not-fire and 6 must-fire controls, then swept all
4487 Danish strings in core: 0 hits, down from 15. Applied to nb/nn too.

Domain-term capitalisation is the INVERSE of Swedish. Swedish measured
54 capitalised : 2 lowercase and so keeps Register/Schema capitalised
mid-sentence; Danish measures 1 : 15 and follows standard orthography,
so register/skema/organisation/objekt stay lowercase.

Bundle consistency over core, twice:
  - imperative of -ere verbs: bundle is Aktivér 13:0, core prefers
    Aktiver 21:7. Followed the bundle.
  - "endpoint": bundle 5:0, harvest offered core-adjacent "slutpunkt".
    Kept endpoint.
Where the bundle already had a mapping it wins outright: Host -> Vært,
so unlike Italian this bundle needed no identity entry for "Host *".

Harvest corrections (3 of 41 candidates were wrong in context):
  - "Right" is a permissions-table header (EditOrganisation.vue:288),
    not a direction -- Rettighed, not Højre.
  - "Assigned collaborative tags" arrived as "Tildelte samarbejds tags";
    Danish compounds are one word -> samarbejdstags.
  - "Edit Endpoint" -> Rediger endpoint, per the bundle term above.

Single-word keys were resolved at the call site, not from the harvest:
Subject is the GDPR data subject (AvgIndex.vue:384) -> Registreret, not
the email sense; Open/View/Reject/Merge/Reverse/Link are verb buttons;
Score/Step/Survivor read off their table headers.

The {plural} source bug (the caller interpolates a literal "s") cannot
work in Danish, which pluralises by suffixing -er and mutates the stem
of register. Resolved per word: objekt(er), fil(er), log(ge),
skema(er), and register/registre where the stem changes.

48 identity strings stay absent rather than being written as value===key
(acronyms, loanwords Danish shares, and literal input examples such as
HTTP headers and YAML snippets, which would stop being valid hints if
translated). Rationale for each is recorded per key.

Also fixes a pre-existing grammar error: "Objekter bløde slettes" ->
"Objekter blødslettes".
…eys)

Every string the frontend reaches via t()/n() now has a real Norwegian
translation. 984 new entries, 15 English placeholders replaced, 20
identity strings dropped so the runtime falls back to the source. No
pre-existing translation was altered.

Register: informal (du/din/ditt/dine), measured against Nextcloud core
rather than assumed from Danish. The De/Dem/Deres detector corrected in
the previous commit carries over unchanged and pays off immediately:
the old /(De|Deres)/ found 10 hits in Norwegian core, all of them
sentence-initial "The/They"; the mid-sentence-capital version finds 0.
Core is 500 informal : 0 genuinely formal.

Norwegian agrees with Danish on orthography (register/skjema lowercase
mid-sentence, measured 1:15) but disagrees on almost everything else,
which is why each convention was re-measured instead of inherited:
  - imperative of -ere verbs: nb is "Aktiver" 13:0, exactly mirroring
    da's "Aktivér" 13:0. Same verb, opposite spelling.
  - error phrasing: nb bundle uses the ACTIVE "Kunne ikke <infinitive>",
    where the Danish bundle preferred a passive construction.
  - ellipsis: nb bundle puts a space before it, 49:0 ("Laster inn ...").
    Followed for every progress string in this commit.
  - cache -> "buffer" (bundle-established: Appbutikk-bufferen,
    navnebuffer), not the "hurtiglager" core sometimes uses.
  - schema -> "skjema", endpoint -> "endepunkt" (Danish kept "endpoint").

Harvest corrections (7 of 46 candidates were wrong in context, the
highest error rate of any locale so far):
  - "Right" is a permissions-table header, not a direction -> Rettighet.
  - "Revoke" arrived as core's "Avslå", which means REJECT. Revoking a
    token is "Tilbakekall".
  - "People" arrived as "Mennesker" (humans in the abstract); it labels
    the PERSON entity type in EntitiesTab.vue:102 -> Personer.
  - "Link" arrived as the noun "Lenke" but is a confirm button
    (LinkObjectDialog.vue:62) -> "Knytt til". Kept the lenke/tilknytning
    split so Link and Connection stay distinct concepts, since the app
    ships both as separate features.
  - "Dashboard not found" -> Instrumentpanel, matching core's own
    translation of the Dashboard app name.
  - "Unknown widget type" arrived as "modultype"; core's dashboard app
    leaves widget untranslated, and "modul" is already used in this
    bundle for application modules, so "widgettype" avoids the clash.
  - "Assigned collaborative tags" lacked plural agreement -> Tildelte.

Accepted core over instinct once: "Bucket" -> "Bøtte", because core's
files_external is exactly the S3 domain this field belongs to. The
Danish bundle kept "Bucket" only because Danish core offers no entry.
Likewise "Host *" -> "Server *", core's rendering for this same field.

The {plural} source bug needed the same per-word handling as Danish, but
with Norwegian's own forms: objekt(er), fil(er), logg(er), skjema(er),
and register/registre where the stem changes.

42 identity strings stay absent rather than being written as value===key.
"Min ms" is included with a note: "min" also means "my" in Norwegian, but
beside a millisecond unit the Minimum reading is unambiguous.
Every string the frontend reaches via t()/n() now has a real Polish
translation. 987 new entries, 15 English placeholders replaced, 14
identity strings dropped so the runtime falls back to the source. No
pre-existing translation was altered.

FIRST nplurals=3 LOCALE. All six plural keys now carry three forms
matching the declared rule (n==1 / n%10 in 2-4 / rest), verified at
runtime: obiekt / obiekty / obiektów. Every locale before this was
nplurals=2, so this is the first bundle where a two-form array would
have silently mis-rendered the genitive plural.

Register: informal, measured against core (154 informal : 0 formal).
Core's imperative style is 2nd-person singular -- Wybierz, Zapisz,
Kliknij (334 hits) -- with impersonal "Nie można" for errors (172), and
the polite 3rd-person "Proszę wybrać" appears only 19 times. Followed
that: 2sg imperatives, impersonal error phrasing.

The register detector needed rebuilding for a homograph problem that is
worse here than in Scandinavian. Formal address is Pan/Pani/Państwo, but
lowercase "państwo" is the ordinary noun for STATE / COUNTRY -- a word
this app plausibly uses, since it manages government registers -- and it
capitalises at sentence start like any other noun. Państwo therefore
counts only MID sentence; Pan/Pani match anywhere, since as address they
stay capitalised. 15 controls (10 must-not-fire, 5 must-fire) pass, and
the detector reports 0 across all 5228 Polish strings in core.

Caught a FALSE FRIEND that the value===key rule would have hidden:
"Data" was initially filed as an identity string, but Polish "Data"
means DATE -- core itself translates "Date" -> "Data". Left untranslated,
the object's data tab would have read "Date" to a Polish user and
collided with core's own term. It is now "Dane". Audited the key across
all nine finished locales: it/pt/es correctly carry Dati/Dados/Datos,
and sv/da/nb correctly leave it as identity because their word for date
is dato/datum. Polish was the only locale affected.

Orthography follows Swedish, not Danish/Norwegian: the bundle
capitalises domain terms mid-sentence (Rejestr 35:2, Schemat 37:1,
Obiekt 79:3), so those stay capitalised through all case inflections
(Rejestru, Schemacie, Obiektów). But "organizacja" measures 0:18 and
stays lowercase -- a per-word exception, not a blanket rule.

Harvest corrections (5 of 47 candidates wrong in context, plus 1 dropped):
  - "Right" -> Uprawnienie; core's "Do prawej" means "to the right".
  - "View" -> Wyświetl; core's "Podgląd" is the NOUN preview, but this
    is a verb button (OrganisationsIndex.vue:90).
  - "Revoke" -> Unieważnij; core's "Cofnij" means UNDO, which is not
    what revoking a token does.
  - "Link" -> Powiąż; the harvest gave the noun "Łącze" for a confirm
    button (LinkObjectDialog.vue:62).
  - "Mappings" -> Mapowania, not the Polglish "Mappingi".
  - "Status" dropped to identity: identical in Polish and already used
    10x in the bundle; openconnector's "Stan" is reserved here for Health.

Accepted core where it is in-domain even when the literal reading is odd:
"Bucket" -> "Kosz" (files_external IS the S3 config UI), the same call
made for Norwegian's "Bøtte". Polish core renders Trash as "Usunięte
pliki", so there is no collision. "Host" stays untranslated per core.

The {plural} source bug degrades further here: with three plural forms a
parenthetical cannot cover the genitive, so Obiekt(y) / Rejestr(y) /
Schemat(y) / plik(i) / log(i) are a documented approximation. The real
fix is for the caller to use n() instead of interpolating a literal "s".
Every string the frontend reaches via t()/n() now has a real Czech
translation. 985 new entries, 16 English placeholders replaced, 13
identity strings dropped so the runtime falls back to the source. No
pre-existing translation was altered.

Register: FORMAL -- the first formal-target locale since French, so the
detector polarity flips back. Measured against core: 177 vy/váš hits,
and all 6 apparent informal hits are the plural DEMONSTRATIVE "ty"
meaning "those" ("pouze ty stávající" = "only those existing"), not the
2sg pronoun. So core is 177 : 0 genuinely informal.

The old detector was /(Tvůj|Tvoje|Tvá)/ -- three possessive forms and
nothing else. That missed where Czech formality actually lives: the
IMPERATIVE ENDING. Formal is 2nd-person PLURAL in -te (Vyberte, Zadejte,
Spravujte); informal is the bare 2sg stem (Vyber, Zadej, Spravuj). So
"Vyber registr" is informal address with no pronoun present at all --
the same blind spot Spanish had, in a different language family.
Rebuilt with the full possessive paradigm plus a curated bare-imperative
list. 22 controls pass and the detector reports 0 across all 5005 Czech
strings in core.

Bare "ty" is deliberately NOT matched. Unlike Polish Państwo, where a
mid-sentence-capital rule separates the two readings, Czech informal "ty"
and demonstrative "ty" are identical in case and position, so no rule can
tell them apart. The possessives and imperatives are unambiguous, so
nothing is actually lost -- documented in the detector comment.

Second nplurals=3 locale, but a DIFFERENT rule from Polish: Czech splits
1 / 2-4 / 5+ where Polish keys on n%10. Verified at runtime that all six
plural keys carry three forms matching the declared expression
(objekt / objekty / objektů).

The bundle's own style, followed throughout: formal -te imperatives for
instructions, plus INFINITIVES for button labels (Zobrazit, Smazat,
Obnovit, Filtrovat), which are register-neutral in Czech and must not be
"corrected" to imperatives. Impersonal "Nepodařilo se" for failures and
"Při ... došlo k chybě" for errors, both already established here.

Orthography follows Danish/Norwegian, not Swedish/Polish: domain terms
stay lowercase mid-sentence (registr 0:35, schéma 0:41, objekt 1:79).

Bundle consistency over core once: the bundle uses "notifikace" (3:0)
where core prefers "oznámení". Kept the bundle's term.
Established terms adopted: ścieżka -> "auditní záznam" (audit trail),
"schránka" (clipboard), "mezipaměť" (cache), "měkce smazané" (soft
deleted), and core's "Hostitel" for Host and "Profilový obrázek" for
Avatar.

Harvest corrections (2 of 45 candidates wrong in context):
  - "Right" -> Právo; core's "Vpravo" means "to the right".
  - "Link" -> Propojit; the harvest gave the noun "Odkaz" for a confirm
    button (LinkObjectDialog.vue:62).
Also translated "Test" rather than treating it as identity: Czech buttons
take infinitives here, so the webhook action reads "Testovat", matching
"Testovat připojení" elsewhere in the bundle.

"Data" stays identity, and this was checked rather than assumed after the
Polish false friend: Czech "data" IS the word for data, because Czech
uses "datum" for date. The trap is Polish-specific.
Fills every string the frontend reaches through t()/n() with a real
Russian translation. 2011/2011 keys, 0 absent, 0 placeholders,
0 register violations, 0 plural-arity errors.

Register: formal, measured rather than assumed. Nextcloud core ru
carries 328 formal pronouns and 164 formal 2pl imperatives against
ZERO of either informal marker across 3905 strings -- the least
ambiguous reading of any locale so far.

Rebuilt the register detector, which previously covered only five
nominative possessives. Russian informal address hides in three
places a pronoun check misses:
  * the oblique cases (тебя/тебе/тобой and declined твой), which is
    most of what running prose actually uses;
  * the imperative ending -- formal is 2pl -ите/-йте (Выберите),
    informal the bare 2sg (Выбери), so "Выбери реестр" is informal
    with no pronoun present at all. Same blind spot Czech had;
  * the 2sg present -ешь/-ишь (хочешь, увидишь). Feminine soft-sign
    nouns end in -чь/-щь/-ышь/-ушь (ночь, помощь, мышь, тушь), never
    -ишь/-ешь, so the ending is unambiguous.
вы/вам/ваш are deliberately NOT matched: lowercase вы is the ordinary
polite address here, not a plural-only form, so it is evidence of
nothing. 32/32 controls pass, 0 hits on core.

First non-Latin-script locale, so the --latin script-coverage check
replaces the --hybrids check. It started at 24 hits; 16 were genuinely
untranslated English (the entire browser/VAPID web-push block, plus
Slug) and are now translated. The 11 that remain are reviewed-benign:
every word of prose is translated and the Latin run is a literal --
a file path, an API field name (conversationId, fileCollection), or a
product name (Zookeeper). Byte-majority cannot tell those apart from
an untranslated string.

Harvest review caught four core/sibling values that were wrong in
sense for this app's context and would have passed every automated
check:
  * Right -> "По правому краю" (right-ALIGNED) where the key is a
    permissions-table header. Now "Право".
  * View -> "Режим просмотра" (view MODE) where the key is an action
    button. Now "Просмотр".
  * Open -> "Открытый" (the adjective, harvested from circles) where
    the key is an action button. Now "Открыть".
  * Search -> "Найти" (the verb) where the key is a tab/field label,
    and the bundle already reads "Поиск / Представления". Now "Поиск".
Also corrected Link (noun -> "Привязать", it is a confirm button),
People ("Люди" -> "Персоны", it labels the PERSON entity type),
Mappings (dropped a sibling app's "(Mapping)" gloss), and the two
Dashboard strings to the bundle's own "Дашборд" rather than core's
"Панель управления" -- bundle-internal consistency outranks core.

Bucket keeps core's literal "Корзина" from files_external even though
Russian cloud docs prefer "бакет", applying the same in-domain-core-
wins rule used for Polish "Kosz" and Norwegian "Bøtte". The bundle has
no other Корзина string, so nothing collides.

One pre-existing mistranslation fixed: Test was "Тест" (the noun) on
what is a webhook test BUTTON; now "Проверить".

nplurals=3 with a third distinct rule -- Russian keys form 0 on
n%10==1 && n%100!=11, so the arrays were built against the ru
expression rather than copied from Polish or Czech. All 6 plural keys
carry 3 forms.

22 keys are left ABSENT as identity strings (ID, Id, ID:, URL, Url,
UUID:, CSV, PDF, RBAC, DSAR, Deck, Excel/OpenDocument format names,
API-key prefix hints, literal header/YAML examples). OC.L10N falls
back to the English source, which renders the same correct text --
writing them as value===key would be indistinguishable from an
untranslated placeholder and would never get revisited. Slug was NOT
treated this way: a lone Latin word in a Cyrillic UI reads as
untranslated, so it is "Слаг".

The five object{plural}-style keys remain parenthetical approximations
("объект(ы)"). Three-form agreement means a parenthetical cannot cover
the genitive; the real fix is for the caller to use n() instead of
interpolating a literal "s".

l10n/ru.json (backend catalogue) untouched.
Both were caught by cross-locale probes while resolving the same keys
for Russian, and both survive every automated check because the values
are real words that differ from their keys.

nl: Right was "Rechts", which is the DIRECTION "right". The key is a
column header in the organisation permissions table
(src/modals/organisation/EditOrganisation.vue:288), so it means a
permission. Now "Recht". Every other locale already had the noun
(Recht / Droit / Derecho / Diritto / Direito / Rättighet / Rettighed /
Rettighet / Uprawnienie / Právo / Право).

cs: Uses and Used by were BOTH "Používá". Those are two separate tabs
on the object view (outbound vs inbound relations, ViewObject.vue:254
and :290), so the pair rendered identically and the user could not
tell which direction a tab showed. Used by is now "Používáno v".
No other locale collides on this pair.
Brings the l10n/*.js toolchain into the repo: a shared library plus four
CLIs — l10n-ai.js (key CRUD), check-l10n.js (audit en.js against src/),
clean-l10n.js (remove unreferenced keys) and find-unwrapped.js (find
prose that was never wrapped in t()).

These existed untracked, and enter the repo with four defects fixed. All
four were the kind that stay invisible until they cost you data or a
review cycle.

n() was invisible to the usage scanner. collectUsedKeys and
findKeyReferences matched only `\bt\s*\(`, so every plural key came back
unreferenced despite live call sites. That armed clean-l10n.js: it
deletes en.js-minus-used from ALL 37 locale files, so adding the plural
source keys to en.js — which is correct and expected — would have made
the next --apply erase them everywhere, including populated plural
arrays. Demonstrated against a fixture before fixing. The three scripts
had three separate copies of the extractor; they now share one that
handles t(), n() (BOTH key arguments) and the $t/$n template variants,
while rejecting identifiers that merely end in t or n (format(, fn(,
min(). As a direct consequence `rm` now correctly refuses to delete a
key referenced only from an n() call.

serializeJs reformatted every file it touched. It emitted tabs,
"key": "value", a trailing comma and `)`, where the shipped files use
four spaces, `"key" : "value"`, no trailing comma and `);` — so a
one-key edit produced a ~4400-line diff. The key order was wrong too:
localeCompare matches ZERO of the 37 files, case-insensitive code-unit
order matches 36, and localeCompare varies by Node/ICU version, which
made the sort order depend on who ran the tool. Round-trip is now
byte-identical for 36/37 files; en.js is the lone outlier, still in the
original extraction order, and will re-sort once on first write.

The eslint pass was destroying the format it was meant to normalise.
Both writers ran `eslint --fix` on the locale files, and l10n/ is not
ignored — l10n/cs.js alone reports 9760 fixable "errors". The fix
rewrites the file to tabs and SINGLE quotes, undoing the serializer
immediately and diverging from what Transifex regenerates. Locale files
are generated data, not source code, so runEslintFix is gone.

find-unwrapped.js hung forever, which is why it could never be wired
up. A bare '<' in template text ("5 < 10") is rejected as a tag open and
falls through to the text branch, whose loop stops immediately because
it is already sitting on '<' — the region is empty, the index never
advances. Now completes over the full tree in ~0.1s. Two further fixes
there: the app id no longer defaults to the hardcoded literal
'opencatalogi' (a different app — a wrong app id makes every wrapped
string look unwrapped), and looksLikeProse no longer discards
"Creating..." / "Loading..." / "Saving...", which its dotted-identifier
filter matched via the trailing run of dots. That last one was
suppressing the single most commonly unwrapped class of label.

Verified: add/set/rm/remove round-trip byte-identically and produce
one-line diffs, `set` still refuses plural arrays, `rm` still blocks on
live references, and all five files are lint-clean.
… npm

Extends the parity gate with the two checks that would have caught this
translation effort's real failures, wires the tooling to npm, and
replaces the l10n guidance.

The parity gate's central assumption was backwards. Its comment read
"Values identical to English are allowed (cognates / proper nouns /
acronyms are legitimately the same) and only counted." But absent and
identical are OPPOSITES, not degrees of the same problem:

  absent    -> OC.L10N falls back to the English source, so the UI
               renders correct text AND the gap stays visible to tooling,
               keeping the key on the work list.
  identical -> renders the same characters but is indistinguishable from
               finished work, to tooling and to the next maintainer, so
               it is never revisited. A permanent invisible hole.

Identical is therefore the worse of the two and is the one worth gating.
This is exactly how ru shipped 24 untranslated English strings behind an
otherwise clean report. Cognates now belong ABSENT rather than written
out; --allow-identical restores the old tolerance for a bulk migration.

Also added: plural arity against each locale's OWN declared nplurals.
That is the one l10n defect invisible to reading the file — OC.L10N
indexes the array with the plural expression's result, so a short array
renders blank for some counts. Note arity alone is not sufficient
protection: ru, pl and cs all declare nplurals=3 with three mutually
incompatible expressions, so a Polish array pasted into Czech has the
right length and the wrong boundaries.

npm wiring: test:l10n:parity, check:l10n, clean:l10n, find:unwrapped.
The last three were documented but had never existed as npm scripts, so
every command in the old guidance failed.

CLAUDE.md was an unedited copy from opencatalogi: the wrong app id
throughout (a wrong id fails lookup silently and renders untranslated
text with a green pipeline), three npm scripts that did not exist, and
it forbade touching l10n/*.json — the file the sanctioned extractor
actually writes. Rewritten with the verified commands, the two
translation sets described as the independent catalogues they are, and
the rules established across 12 completed locales.

One obsolete rule deliberately reversed: the old text said never to
narrow `add --locales` and never to defer a locale. Written for a
two-locale app, that now demands 37 hand-written values per string and
invites precisely the placeholder filler the value===key rule forbids.
`en` is required; the rest are optional and better left absent.

The per-language method — measuring formality register against Nextcloud
core instead of assuming it, why harvested values must be checked at the
call site, plural incompatibility, and the established per-locale
conventions — moved to docs/l10n-ui-translation.md so it is read on
demand rather than billed on every call.
readStringLiteral handled only \n, \t and \r; every other escape fell
through to `else value += n`, which drops the backslash and keeps the
letter. So the source literal

    t('openregister', '⚠️ PERMANENT DELETION WARNING ...')

extracted as the key "u26A0uFE0F PERMANENT DELETION WARNING ...", while
at runtime JS produces "⚠️ PERMANENT DELETION WARNING ...". The two
never match, so any translation stored under the extracted key is dead
on arrival, and check-l10n reports the real key as missing forever.

The 12 finished locales happen to hold the correct emoji key, so nothing
shipped broken -- but the tooling could not see that, and reported those
two keys as untranslated in every locale.

Decode \uXXXX, \u{XXXXX} and \xXX, and add the remaining single-letter
escapes (\b, \f, \v, \0) so the extracted key is byte-identical to the
runtime key. Verified against seven literal forms, including the ⚠️/•
case and a surrogate-pair \u{1F600}.
1985 of 2011 frontend keys now carry a real Ukrainian translation, up
from 1005. The remaining 26 are deliberate: acronyms and formats
identical in Ukrainian (ID, URL, UUID:, CSV, PDF, RBAC, IBANs), proper
names (Deck, Excel (.xlsx), OpenDocument (.ods)), literal placeholder
examples (sk-..., org-..., fw_..., myapp, example URLs, header/YAML
samples) and one pure-placeholder format string. They are left absent
rather than written out, so the runtime falls back to English and the
keys stay visibly untranslated.

Register: formal, measured against Nextcloud core rather than assumed.
Across core/lib/apps uk.json, 632 distinct strings carry formal markers
(ви/ваш, 2pl imperatives in -іть/-те) against 2 informal, and both of
those are deliberately casual content (a user-status prompt and a sample
calendar event) rather than UI chrome. Detector validated on 22
must-fire / must-not-fire controls; it finds 0 informal markers in the
finished uk.js. Matching core, ви/ваш is used for address and possession
and actions are infinitives (Додати, Зберегти, Переглянути).

Harvested 24 values from core/lib and bundled apps, ranked core first.
Three were correct translations of the wrong sense and were rewritten
after checking the call site:

  People  PERSON entity-type label, not "Users"  -> Люди
  View    row action button, not display mode    -> Переглянути
  Bucket  histogram score range, not basket/bin  -> Діапазон

Right (permission column, not text alignment), Subject (GDPR data
subject, not mail subject) and Open (NcButton verb, not adjective) were
checked against the same trap list and translated in their real sense.

Plurals use Ukrainian nplurals=3 (1/21 | 2-4 | 5-20,0); all 6 plural
keys carry 3 forms. The object{plural} family follows the parenthetical
convention already used by ru and cs -- об'єкт(и) -- because the source
interpolates a literal "s" instead of calling n().

Verified: 0 value===key, 0 bad plural arity, 0 informal markers, no
Latin-only values, loads under OC.L10N.register with the correct
plural-forms header, and diffing against the previous file shows 988
keys added, 8 value===key cognates removed, 15 placeholders replaced,
and 0 existing real translations altered.
extractTCalls pushed BOTH arguments of an n() call into the used-key set,
so every plural source string was compared against en.js as if it were a
catalogue key of its own. It never is: an n() call has two source strings
but one key -- the singular -- and the plural lives in that key's value
array. The six plural sources were therefore permanently reported as
missing, and no amount of correct translation could clear them.

Track plural source -> singular key while extracting, and treat a plural
source as satisfied when its singular key holds an array. check:l10n now
reports 0 missing instead of 6 unfixable ones.
Six strings in two ternaries rendered English for every user despite five
of them already being translated in all 13 finished locales -- the
literal was simply never passed through t():

  ViewObject.vue   isCopied ? 'Copied' : 'Copy'
                   isSaving ? ('Creating...' | 'Saving...')
                            : ('Create' | 'Save')

Copy, Creating..., Saving..., Create and Save were all already in the
catalogue and translated; only Copied is new.

Also wraps "Mode:" and "Error Details" in MassValidateModal. Those two
mattered beyond the display bug: both keys existed in en.js with no t()
call, so they read as dead and were on the unused-key removal list.
Wrapping them keeps their translations rather than discarding work that
would be needed the moment the string was wrapped.

t() is available in these templates via main.js's
app.mixin({ methods: { t, n } }).
Static extraction cannot see a key passed to t() through a variable:

  t('openregister', action)        PermissionMatrix.vue:41
  t('openregister', step.status)   ApprovalStepList.vue:17
  t('openregister', preset.label)  DashboardIndex.vue:91,120,360
  t('openregister', key)           MainMenu.vue:76 (manifest labels)

All four are real, live keys. actions and the date presets are hardcoded
frontend arrays; step.status is a raw DB enum that the approval-steps API
returns verbatim, so the backend never localises it and the frontend must.

They therefore look unused. Once en.js was completed to cover them,
clean-l10n --apply would have deleted 17 keys from all 37 bundles,
silently un-translating the Permission Matrix headers, the approval
status badges, the dashboard date presets, and the "Data sources" and
"Endpoints" menu labels.

Adds DYNAMIC_KEYS + collectDynamicKeys() to lib/l10n.js, documenting
where each key comes from, and teaches both consumers to treat them as
used: check-l10n no longer reports them unused, clean-l10n never offers
them for removal. clean-l10n now prints the protected count so the
exclusion is visible rather than implicit.
en.js had drifted badly from src/: 1410 keys where the code uses ~2000,
so check:l10n reported 997 missing and 405 dead and neither number could
be trusted as a completeness signal for any locale.

en.js is now generated from the actual t()/n() call sites and is a strict
superset of every locale: 2018 keys. The six n() keys hold proper
[singular, plural] arrays instead of being absent, and the 15
variable-keyed strings were added here rather than deleted from the
locales, because they are genuinely used (see previous commit).

Dead keys removed from all 37 bundles (13,483 entries), all verified to
have no t() reference and no complete quoted literal in src/ -- obsolete
features: agents, conversations, collections, Solr/Zookeeper, memory
prediction. Also removed 240 keys that existed in a locale but not in
en.js at all, which is backwards -- a translation for a string the source
does not contain: nl's entity-type map keys (PERSON/EMAIL/... are JS
object keys in formatType, not translation keys), three PHP %1$s
notification strings that belong to the backend .json catalogue, two
pre-fix mangled ⚠ variants, and 226 obsolete SOLR/agent keys in tr.

The 13 finished locales (nl de fr es it pt sv da nb pl cs ru uk) are now
key-for-key identical to en.js -- 2018 keys each, 554 cognates written
out explicitly. This reverses the earlier "leave cognates absent" rule
for these locales only; the 23 unfinished ones keep their current shape
so their real progress stays measurable. Note the consequence:
test:l10n:parity now reports 22-70 English-identical per finished locale
where it previously reported them as missing. Those counts are the
cognate counts, not defects.

Wrong-sense and gap fixes found by cross-checking the finished locales
against each other. Most one-off absences are legitimate -- French really
does share Action/Configuration/Contacts with English, Dutch shares
Object/Complex, German shares Name/Status -- so each was judged per
language rather than by majority vote. The genuine errors:

  Bucket   WRONG SENSE everywhere. It labels a histogram score range,
           but nb had "Bøtte", pl "Kosz" and ru "Корзина", all
           "basket/pail". Now a range in all 13. These are the only
           three pre-existing translations this commit overwrites.
  Object A/B, Object #{id}  absent in de and fr, which render Object as
           Objekt/Objet, so these showed English.
  Multitenancy, GitHub/GitLab Personal Access Token  absent in de, fr.
  Name *, Name*  absent in fr, which has Name -> "Nom".
  Facetable  absent in es; log{plural} absent in es and pt.
  Account  absent in pt, sv. Endpoints absent in pt. Agents absent in nl.
  Copied   new key, translated for all 13.

Verified: en.js is a superset of all 36 locales, the 13 finished ones
have identical key sets, 0 bad plural arity anywhere, every bundle loads
under OC.L10N.register with plural arrays matching its own nplurals, and
diffing against HEAD shows 0 real translations altered beyond the three
Bucket fixes above.
el now carries a real Greek translation for every string that needs one:
1007 keys added and 15 English placeholders replaced, taking it from
1011 to 2018 keys -- key-for-key identical to en.js, matching the
full-sync shape of the other finished locales. The 26 remaining
English-identical values are deliberate cognates: acronyms and formats
Greek keeps as-is (CSV, PDF, RBAC, URL, UUID:, IBANs, DSAR, Slug,
Webhook, Email -- core Greek also renders Email as "Email"), proper
names (Deck, Excel (.xlsx), OpenDocument (.ods)) and literal placeholder
examples (sk-..., org-..., fw_..., myapp, sample URLs, header/YAML
snippets).

Register: formal, measured against Nextcloud core rather than assumed.
Core/lib alone gives 3 informal vs 135 formal distinct strings; with all
bundled apps, 8 vs 541. The 8 informal hits are demo content ("Γεια σου
κόσμε!"), the standalone "Εσύ" label, and one residual 3sg-past false
positive that itself contains formal σάς. So σας/εσείς for address, 2pl
imperatives in -τε (Επιλέξτε, Πατήστε, Εισάγετε) and 2pl present
(Μπορείτε, Έχετε).

Building that detector took three corrections, because the naive version
reported 532 informal vs 639 formal -- effectively noise:

  σε           read as the 2sg clitic, but it is overwhelmingly the
               preposition "to/in" (351 hits). Dropped.
  -εις / -άς   matches plural NOUNS (ειδοποιήσεις) and genitive
               singulars (γραμματοσειράς), not just 2sg verbs. Replaced
               with a closed verb list.
  -σε verbs    the 2sg imperative is homographic with the 3sg past: "Ο
               {actor} δημιούργησε" is "created", not "create!". Now
               requires no sentence-initial 3rd-person subject -- and
               that cue had to be anchored to the start, because mid-
               sentence "το" is the neuter article ("πάτησε το κουμπί").

Validated on 19 must-fire / must-not-fire controls, including those
false-positive strings; the finished el.js has 0 informal markers.

Convention follows core Greek: actions are verbal nouns (Αποθήκευση,
Διαγραφή, Προσθήκη, Επεξεργασία), not imperatives. Glossary: Μητρώο
(register), Σχήμα, Αντικείμενο, Οργανισμός, Διακριτικό (token), Τελικό
σημείο (endpoint), Ιστορικό ελέγχου (audit trail), Όψη (facet),
Απόκρυψη (redaction), Εγγραφή αναφοράς (golden record).

Harvested 24 values from core/lib and bundled apps. Bucket was the
familiar wrong-sense trap -- files_external offers "Κάδος" (bin/basket)
where the string labels a histogram score range, so it became "Εύρος".
People correctly harvested as "Άτομα" here (unlike uk, where core gave
"Users"), and Right/Subject/Open were translated in their verified
senses: Δικαίωμα (permission, not direction), Υποκείμενο (GDPR data
subject, not mail subject), Άνοιγμα (action, not adjective).

Plurals use Greek nplurals=2; all 6 plural keys carry 2 forms. The
object{plural} family follows the parenthetical convention already used
by ru/cs/uk.

Verified: 0 missing, 0 empty, 0 bad plural arity, key set identical to
en.js, loads under OC.L10N.register, and diffing against HEAD shows 0
existing real translations altered or removed.
fi goes from 1011 to 2018 keys -- key-for-key identical to en.js -- with
1007 keys added and 15 English placeholders replaced. The 24 remaining
English-identical values are deliberate cognates: acronyms and formats
(CSV, PDF, RBAC, URL, UUID:, IBANs, DSAR, Slug, Webhook), proper names
(Deck, Excel (.xlsx), OpenDocument (.ods)) and literal placeholder
examples.

Register: 2sg (sinuttelu), and this is the first locale in this effort
that is NOT formal -- so it was worth measuring rather than carrying the
previous locales' answer over. Finnish does not map onto the Slavic/Greek
T-V pattern, and core is unambiguous: the formal pronoun te/teidän has
ZERO hits in core+lib, while sinä and the 2sg possessive -si are
pervasive ("salasanasi", "Kirjautumispolettisi"). core+lib scores 2sg 138
vs 2pl 5; with all bundled apps, 554 vs 31, and the 2pl residue is
false-positive: -kaa/-kää also forms A-infinitives and 3sg ("Haku alkaa"
= search begins), so that ending is not usable as a marker and a closed
verb list is used instead.

Because 2sg is correct here, the register detector is INVERTED relative
to el/uk/ru: it flags 2pl, not 2sg. Validated on 23 must-fire /
must-not-fire controls -- including the -kaa false positives and correct
2sg forms that must never be flagged -- and the finished fi.js has 0
formal-2pl markers.

Convention follows core Finnish: buttons and actions are 2sg imperatives
(Tallenna, Poista, Peruuta, Muokkaa, Luo, Lisää, Kopioi), the exact
opposite of Greek's verbal nouns. Error messages take the natural Finnish
nominal shape ("Asetusten tallentaminen ei onnistunut") rather than a
literal "Failed to ...". Token is "poletti", matching core's
"Kirjautumispolettisi".

Harvested 22 values from core/lib and bundled apps. Revoke needed
correcting: settings offers "Peru oikeus", which is permission-specific,
but the call site revokes an API token -> "Mitätöi". Bucket was absent
from the Finnish harvest entirely, so it was translated fresh as "Väli"
(range) rather than inheriting the basket/bin sense that was wrong in
nb/pl/ru. People correctly harvested as "Ihmiset" for the PERSON entity
type; Right/Subject/Open translated in their verified senses (Oikeus,
Rekisteröity, Avaa).

Plurals use Finnish nplurals=2; all 6 plural keys carry 2 forms.

Verified: 0 missing, 0 empty, 0 bad plural arity, key set identical to
en.js, loads under OC.L10N.register, and diffing against HEAD shows 0
existing real translations altered or removed.
These labels live in src/manifest.json and reach t() only through
CnAppNav's `translate` prop (MainMenu.translate), so static extraction
never saw them: Administration, Audit, Data quality, Documentation,
Features & roadmap, Integration, Search / views. They were absent from
en.js and from all 36 locale bundles, i.e. the top-level navigation
groups have been rendering in English in every language.

Also narrow collectDynamicKeys() to the manifest fields that are
actually translated. It previously harvested every label/name/title in
the manifest, which pulled in observability.metrics[].name (Prometheus
metric identifiers such as objects_created_total) and pages[].title,
which CnPageRenderer forwards to the page component as a raw prop
without translating. Only menu[].label (recursed through children) and
the two nav label overrides reach t().

en.js and the 16 completed bundles are now 2025 keys, key-for-key
identical, with no extra keys in any locale.
The webhook headers placeholder is a code example, not UI copy, so it
does not belong in t(). Unwrapping it makes
"X-Custom-Header: value\nAuthorization: Bearer token" a dead key, so it
is dropped from en.js and all 36 locale bundles. It was value===key in
every one of them, so no translation is lost.

Also fix nl "file{plural}", which was left as "bestand{plural}". The
source interpolates plural: count !== 1 ? 's' : '', an English plural
marker, so that rendered "bestands"; Dutch is "bestanden". Its siblings
already sidestep this as "logboek(en)" / "object(en)", so this follows
them with "bestand(en)". nl "register{plural}" -> "registers" is left
alone because the English -s happens to be correct Dutch there, as it is
for es and pt throughout.

Note the underlying source defect this exposes: hardcoding 's' is only
correct for languages that pluralise with -s. Turkish (-lar/-ler) still
renders "dosyas"/"nesnes", and it cannot work at all for Finnish
partitives, Hungarian (no plural after a numeral) or the three-form
Slavic plurals. These call sites should use n() with a real plural key.
hu.js goes from 1011 to 2024 keys, key-for-key identical to en.js: 1013
added, 16 English placeholders replaced with real translations, and 23
deliberate cognates (Audit, CSV, PDF, RBAC, Id, Port, URL, format names,
and literal example values shown verbatim in inputs).

Register: Hungarian core is formal. The measurement is unambiguous —
core+lib+apps has 43 hits for Ön/Önnek/Önt against 3 for te, and every
instructional string uses the polite third-person imperative
("Kattintson", "Lépjen", "szerkessze"). Formal Hungarian also takes the
3sg possessive, so "your password" is "a jelszava", never "a jelszavad".
Validated with a closed-list detector (2sg pronouns, 2sg verb forms, 2sg
possessives) over 10 must-fire and 28 must-not-fire controls; suffix
matching is unusable here because word-final -d and -sz are also the
natural endings of kód/mód/rend/föld and húsz/ész/dísz.

Conventions follow core: buttons are -ás/-és verbal nouns (Mentés,
Törlés, Létrehozás, Hozzáadás), not imperatives; quotes are the low-high
pair; plural arrays carry the same form twice, since Hungarian does not
pluralise after a numeral.

Domain terms match the 1011 strings already in the bundle:
Nyilvántartás, Séma, Objektum, Forrás, Végpont, Szervezet, Ügynök. GDPR
wording uses Hungarian statutory terms (érintett, jogalap, megőrzési
idő, elszámoltathatóság, adathordozhatóság), and the Dutch source terms
are rendered the same way de/fi/el handle them, keeping Autoriteit
Persoonsgegevens as a glossed proper name.

One pre-existing value corrected: "Contacts" held "Kapcsolatok"
(= connections), but its only call site is RelationsTab.vue's entity-type
map for address-book contacts, and "Relations" already owns
"Kapcsolatok" — the two were indistinguishable in the UI. Core uses
"Névjegyek" (apps/dav/l10n/hu.json), which also matches the "névjegy"
wording used throughout the rest of the bundle.

Verified: key set identical to en.js, 0 empty values, 6 plural keys all
at nplurals=2, placeholders preserved in both directions, 0 informal
forms against 122 explicitly formal ones, 0 of the 1011 pre-existing
translations lost or altered apart from the documented Contacts fix, and
the bundle loads under OC.L10N.register.
Comment thread scripts/l10n/lib.js Fixed
Comment thread scripts/l10n/spell.js Fixed
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 60be6bc

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
test-l10n-parity
format
composer ✅ 175/175
npm ✅ 528/528
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-20 14:49 UTC

Download the full PDF report from the workflow artifacts.

§7.4 lumped the fourteen literal-`(s)` keys (`register(s)`, `configuration(s)`,
`{days} day(s) left`, …) in with the `{plural}` source hack and told a pass to
ignore both. They are unrelated: in `{plural}` the morphology is interpolated by
the call site, whereas `(s)` is ordinary translatable text inside the key, so a
locale may render it however it likes — including dropping the parenthetical —
and there is nothing to fix in `src/`.

Measured across all 36 bundles, three strategies are in use and all are correct:
own parenthetical (`mk`/`nb` 14 of 14, `da` 13, `sq` 12, `et`/`ro`/`uk` 11), no
parenthetical at all (`bs`/`hu`/`it`, 14 of 14), and keeping `(s)` where `-s` is
the native plural marker (`es` `pt` `fr` `ca` `rm`, plus Dutch `configuratie(s)`
and Latvian `konfigurācija(s)`).

So "the value contains `(s)`" is not an audit signal — it flags the whole
Romance group for writing correct Romance. Intra-locale inconsistency is the
signal that works, and needs no knowledge of the target morphology. It locates
two defects, both in Tier 1 locales and now cross-referenced from §9.2:

- `de` writes `Schema(s)`/`Schema(s) ausgewählt` beside its own
  `Konfiguration(en)`, `Objekt(e)`, `Tag(e)`. German pluralises this
  `Schemata`/`Schemen`, never `Schemas`.
- `nl` writes `schema(s) geselecteerd` beside `object(en)`, `dag(en)`. Dutch is
  `schema's` or `schemata`.

`Dashboard(s)`/`Widget(s)` in `de`/`nl`/`lb`/`da` are not defects by contrast —
those languages keep `-s` on English loanwords. `lv` mixes `(s)`, `(i)`, `(ām)`
and `(us)` across eight keys and needs a case-by-case read.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 44cb2cb

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
test-l10n-parity
format
composer ✅ 175/175
npm ✅ 528/528
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-20 15:29 UTC

Download the full PDF report from the workflow artifacts.

SudoThijn and others added 3 commits August 21, 2026 09:22
`Vue Quality (eslint)` was red with 5 errors (817 warnings are pre-existing and
untouched):

    vue/attribute-hyphenation           :model-value can't be hyphenated   x2
    @nextcloud/l10n-enforce-ellipsis    "..." should be the "…" character  x3

The two hyphenation errors are free — `:model-value` -> `:modelValue` in
EditConfiguration.vue and EditWebhook.vue.

The three ellipsis errors are not, and that is the whole story of this commit.

Why an ellipsis is a translation change
---------------------------------------
`t('openregister', 'Saving...')` -> `t('openregister', 'Saving…')` changes the
translation KEY. Do it naively and the string falls out of every catalogue at
once: `test:l10n` gains 2 missing keys, and `test:l10n:parity` — which IS in
this repo's `frontend-checks` — starts failing across all 36 locales.

But no translation work was actually needed. All 37 catalogues already carry
`"Creating..."` and `"Saving..."` with real, human translations. This is a key
RENAME, so each locale's own existing value is reused verbatim, with only the
trailing glyph normalised to match the source:

    nl  "Aanmaken…"            "Opslaan…"
    de  "Wird erstellt…"       "Wird gespeichert…"
    fr  "Création en cours…"   "Enregistrement en cours…"
    ru  "Создание…"            "Сохранение…"

Nothing here is invented or machine-translated. The values were read out of the
locale files themselves and written back through `scripts/l10n/apply.js`, this
repo's own gated writer, one locale at a time — so every one of its six refusal
gates (not-an-en-key, plural arity, value===key, whitespace, placeholder drift,
clobbering) ran on each patch. 36 applied, 0 refused.

`eslint --fix` was tried and reverted
-------------------------------------
It rewrote a large set of unrelated files and INTRODUCED new errors
(`defineOptions() cannot be used to declare props`, a batch of
`no-use-before-define`). Reverted `src/` wholesale and did the five by hand.
Worth recording so the next person does not reach for it: on this codebase
`--fix` makes the count go up.

What is deliberately NOT fixed here
-----------------------------------
`Frontend Check (test:l10n)` is still red on its original 17 keys, and this
commit leaves that number exactly where it found it:

    before:  FAIL — 17 translation key(s) used in source but MISSING
    after:   FAIL — 17 translation key(s) used in source but MISSING

The obvious move — `node tests/l10n/check-l10n.js --write` — was tried and
backed out. It closes `test:l10n` and immediately opens `test:l10n:parity`,
because those 17 English strings then exist in `en.js` and in none of the 36
locales. Both gates are in `frontend-checks`, so that trade is not a fix; it
swaps a red gate for a different red gate and breaks one that was passing.

Those 17 are genuinely new UI copy (AVG/GDPR Art 15/30 wording, flow-list
help text) and need real translations in 36 languages, through the runbook in
`docs/l10n-workflow.md` and the per-locale register verdicts in
`docs/l10n-ui-translation.md`. That is this branch's actual remaining work, and
fabricating 612 values to make a gate green is precisely what this programme's
`--strict-identical` audit exists to catch.

Verification
------------
  npx eslint src                      0 errors (was 5) / 817 pre-existing warnings
  check-l10n-parity.js                rc=0 — all 36 locales at key-for-key parity
  check-l10n.js                       FAIL on 17 — identical to before this commit
                                      (measured by stashing these changes and re-running)

Note `CodeQL` is also red on this PR; it is unrelated to l10n or eslint and is
not addressed here.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 24da1dc

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint ⏭️
stylelint ⏭️
build ⏭️
check-specs
test-l10n
test-l10n-parity
composer ⏭️ ⏭️
npm
app:check-code ⏭️
info.xml
REUSE ⏭️
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-21 07:31 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ a3b48e5

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
test-l10n-parity
format
composer ✅ 175/175
npm ✅ 528/528
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-21 07:54 UTC

Download the full PDF report from the workflow artifacts.

…tually pass

Clearing the 5 eslint errors in the previous commit did NOT turn the job green.
It still exited 2, on stderr rather than in the report:

    There are suppressions left that do not occur anymore. To resolve this,
    re-run the command with `--prune-suppressions`.

That is a separate non-zero condition from "errors found", and it is easy to
misread: stdout says `0 errors, 817 warnings` while the process exits 2.

Measured on both trees rather than assumed, because the obvious reading is that
my own fix orphaned the suppressions:

    pre-fix   exit 2   822 problems (5 errors, 817 warnings)   + stale-suppressions
    post-fix  exit 2   817 problems (0 errors, 817 warnings)   + stale-suppressions

Same message on both sides, so the staleness is pre-existing and independent of
the error fix. Confirmed by pruning each tree and diffing the result — one entry
moves, identically, in a file neither commit touches:

    src/modals/settings/LLMConfigModal.vue
      @nextcloud/l10n-enforce-ellipsis   count 9 -> 4

Someone fixed 5 ellipsis violations there and did not re-run the prune. Nothing
else in the file moves: 222 files still listed, total suppressed 1250 -> 1245,
one line of diff.

With that pruned and the 5 errors gone, `npm run lint` — the exact command CI
runs — exits 0.

  npm run lint                  exit 0  (was 2)
  check-l10n-parity.js          exit 0  (unchanged)
  check-l10n.js                 FAIL on 17  (unchanged — see previous commit)
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ ebc421f

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
test-l10n-parity
format
composer ✅ 175/175
npm ✅ 528/528
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-21 08:21 UTC

Download the full PDF report from the workflow artifacts.

The five X{plural} keys interpolated English morphology at runtime: 13 call
sites passed `plural: count !== 1 ? 's' : ''`, so the catalogue key was
`object{plural}` and the runtime glued an "s" onto whatever the locale had
written. No language whose plural is not a suffixed -s can render that, and a
three- or four-form language cannot render it at all — the whole form set had
to fit in one string. Every locale invented a workaround (bestand(en),
súbor(y), аб'ект(ы), objekat/i), none of which is how the language is written.

Six real plural keys now, and each locale gets as many forms as its grammar
needs. Twelve call sites take the bare noun, because CnStatsBlock renders the
number itself in :count; the RegistersIndex column folds it in as
`{count} schema`, where number and label are adjacent inline text.

All 36 locales at their own nplurals: 21 two-form, 12 three-form, mt/sl four,
ga five. Forms are the ones that follow a numeral, since the label renders
beside one — so ru gets объект/объекта/объектов, sl a real dual, lv the
library's [zero, one, other] order, and bg the masculine count form (обекта)
that a single string could never carry. ga takes the counted singular in all
five forms and mt pluralises in form 1 only.

{plural} is now banned rather than tolerated, in four places: the source scan
(check-l10n.js, which also rejects `? 's' : ''` inside a translation call's
arguments and refuses to --write such a key), every catalogue value
(check-l10n-parity.js), the only writer (apply.js, where this inverts the old
gate-5 exemption), and selfcheck.js. gate-negative-test.js proves all four
refuse, by injection — there is deliberately no live example left to read.

Two things found on the way:

- translatePlural hands the form it selects back to translate(), which
  resolves it against the bundle again — so a form whose text is also a key
  renders THAT key's value. rm's lowercase `schema(s)` collided with the
  bundle's own `schema(s)` key. Nothing caught it: arity right, not empty, not
  English. test:l10n:parity now fails on the class; rm's forms are capitalised.
- RegistersSideBar's Orphaned Items blocks decided singular/plural from
  systemTotals while displaying orphanedItems. Fixed by passing the count.

nl needed a cognate record for register/registers, which meant creating
locales/nl.json — and that opts a locale into cognate enforcement, so all 62 of
its identical values are justified there too. Its cognates are reviewed and
nothing else is; the new registerNotMeasured field makes selfcheck say so
instead of reporting a verdict nobody measured.

CLAUDE.md was carrying 220 lines of per-pass audit history. Trimmed to context
only, 354 -> 141 lines; the history moved to docs/l10n-audit-findings.md, which
says up front that none of it is a rule. Hardcoded key and locale counts are
gone too — they go stale silently, and test:l10n:parity prints the live ones.

Not done, deliberately: the backend l10n/*.json set still holds the five dead
keys. No PHP references them and nothing in scripts/l10n/ writes that set, so
the parity gate reports them as a NOTE rather than failing on a file it cannot
offer a fix for.

test:l10n stays red on the same 17 keys it was red on before — the AVG/flows
merge, which is its own change.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ e455ae4

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
test-l10n-parity
format
composer ✅ 175/175
npm ✅ 528/528
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-21 13:11 UTC

Download the full PDF report from the workflow artifacts.

check:l10n was reporting 133 issues. It is down to 8, and all 8 are false
positives the checker cannot avoid.

MISSING (17 -> 0) and UNUSED (13 -> 0) were mostly the same strings twice.
Commit 8bd2598 and 3b73d6b swapped the Dutch AVG source strings for English
ones in place, which left the old key unreferenced and the new key absent —
11 pairs, verified against those diffs rather than matched by eye:

  Rechtsgrond -> Legal basis            Verantwoording -> Accountability
  Bewaartermijn -> Retention period     Inzage results -> Access results
  Inzage (Art 15) -> Access (Art 15)    Portabiliteit -> Portability (Art 20)
  AVG / Verwerkingsregister -> GDPR / AVG processing register
  Generate the verantwoordingsdocument -> Generate the accountability document
  ... plus the two verwerkingsactiviteit sentences and the Art 15/17/20 one

Renamed, not re-added: the key moves in all 37 bundles and 392 finished
translations move with it. clean:l10n proposes deleting all of them, which
would have thrown those away and left the English keys to translate from
scratch in 36 languages.

Naam and Creating... are the exception — genuine duplicates whose targets
(Name, Creating…) already exist and are fully translated, so those were
removed rather than renamed.

rename keeps the VALUE, and in en.js the value IS the source string, so all 11
en.js entries still held the Dutch. Fixed, which also cleared five now-stale
cognate records in locales/nl.json.

Six keys were genuinely new, all in FlowsIndex.vue: the flows description,
Enabled-but-has-no-owner, New flow, App, Schedule, Trigger. Translated into all
36 locales — Schedule and Trigger are column headers, so both are nouns, and
Schedule heads the cron column (a timetable, not the verb). Anchored on each
bundle's own Flows/Enabled/Owner and on the shape of its existing
"New processing activity"; App follows core's own Apps. Dutch needed a cognate
record for Trigger.

UNWRAPPED (103 -> 8): wrapped every user-visible string the checker flagged,
across 38 components. Object successfully deleted only exists as the singular
half of a plural key, so it takes n(…, 1) rather than a new singular key.

The remaining 8 are attribute values, not UI text — radio `value="pending"`,
`<input type="file">`, a component `type="schema"` prop. The check matches any
literal equal to a catalogue key, so these cannot be distinguished without
teaching it about attributes; wrapping them would be wrong.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 3db3104

Check PHP Vue Security License Tests
lint ⏭️
phpcs ⏭️
phpmd ⏭️
psalm ⏭️
phpstan ⏭️
phpmetrics ⏭️
eslint ⏭️
stylelint ⏭️
build ⏭️
composer ⏭️ ⏭️
npm ⏭️ ⏭️
app:check-code ⏭️
info.xml ⏭️
REUSE ⏭️
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-21 14:48 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 9106847

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
test-l10n-parity
format
composer ✅ 175/175
npm ✅ 528/528
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-21 15:08 UTC

Download the full PDF report from the workflow artifacts.

… this PR

Pushed onto this branch to unblock it — the l10n work itself is untouched.

## CodeQL, 2 high

`js/regex-injection` — scripts/l10n/lib.js:766. `localeFileRe()` built a
pattern by interpolating `loc`, which arrives from a command-line
argument. Passing `.*` would have made it match every catalogue on disk
instead of one locale's. Now escaped with the standard metacharacter
replace before interpolation.

`js/redos` — scripts/l10n/spell.js:81. The snake_case stripper was
`\b[a-z_]+(?:_[a-z_]+)+\b`: both halves could consume underscores, so a
run of them ("a__________b") gave the engine an exponential number of
ways to split the same text. Rewritten with disjoint classes either side
of the separator, `\b[a-z0-9]+(?:_+[a-z0-9]+)+\b` — `_+` still absorbs
repeated underscores, so there is exactly one way to match.

Checked equivalence on foo_bar, foo__bar, a_b_c, plain words and a
sentence: identical output. `x_1_y` now strips too, which the old pattern
missed because it had no digits in its class — a small improvement, not a
regression. Both l10n gates still pass locally: test:l10n OK (343 files,
2035 keys) and test:l10n:parity OK (all 36 locales at full parity).

## eslint, 2 errors

`@nextcloud/l10n-enforce-ellipsis` on `'Loading...'` in
WorkflowExecutionPanel.vue and MassValidateModal.vue — now `'Loading…'`.
These are the only two ERRORS in that job; the other 817 findings are
warnings and are left alone.

eslint could not run in my checkout (its flat config needs deps that are
not installed here), so those two are verified by reading the rule, not
by a local run. CI is the check.
@rubenvdlinde

Copy link
Copy Markdown
Contributor

@SudoThijn — I pushed one commit (d3db379) onto this branch to clear the three blocking checks. Your l10n work is untouched; this only fixes what CI was red on.

CodeQL, 2 high-severity:

  • js/regex-injection in scripts/l10n/lib.js:766localeFileRe() interpolated loc (a command-line argument) straight into a pattern, so .* would have matched every catalogue on disk instead of one locale's. Escaped before interpolation.
  • js/redos in scripts/l10n/spell.js:81 — the snake_case stripper \b[a-z_]+(?:_[a-z_]+)+\b let both halves consume underscores, so a run of them gave the engine exponentially many ways to split the same text. Rewritten with disjoint classes either side of the separator.

I checked the rewrite is behaviour-equivalent on foo_bar, foo__bar, a_b_c, plain words and a full sentence — identical output. x_1_y now strips too, which the old pattern missed (no digits in its class). Both gates still pass locally: test:l10n OK (343 files, 2035 keys) and test:l10n:parity OK (all 36 locales at full parity).

eslint, 2 errors: @nextcloud/l10n-enforce-ellipsis on 'Loading...' in WorkflowExecutionPanel.vue and MassValidateModal.vue'Loading…'. Those were the only two errors in that job; the other 817 findings are warnings and I left them alone.

One caveat: eslint would not run in my checkout (its flat config needs deps I do not have installed), so those two lines are verified by reading the rule rather than by a local run — CI is the check there.

Shout if you would rather have done any of this differently and I will back it out.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 5abc2df

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
test-l10n-parity
format
composer ✅ 175/175
npm ✅ 528/528
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-22 06:42 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 5c8f238

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
test-l10n-parity
format
composer ✅ 175/175
npm ✅ 528/528
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-22 06:59 UTC

Download the full PDF report from the workflow artifacts.

My earlier merge on this branch was three commits stale, so it missed the
Codeberg -> GitHub repoint of appinfo/info.xml that landed on development
in 4018829 — which is why gate-94 (retired-git-host-metadata) reported
6 shipped URLs still pointing at Codeberg. Merging current development
fixes that; info.xml now carries the GitHub URLs and the gate has nothing
to find.

That merge also brought in two files written while development's phpstan
was still 1.x, so nothing had analysed them under 2.x yet:

- BulkSaveOutcome: `?? 'Row failed without a recorded reason'` and
  `?? 'BulkSaveRejection'` on `message` / `exceptionClass`, which
  getFailed() declares as required non-nullable strings. The `index` and
  `uuid` fallbacks on the lines above ARE reachable and stay.
- SharedSchemaDedupeService: a `getId() === null` check after
  createFromArray() (declared `: Schema`, and it throws rather than
  returning an unsaved entity), and an `instanceof Register` filter over
  findAll() (declared `Register[]`).

phpstan 0, phpcs 0, both l10n gates OK, BulkControllerTest 51 pass,
DedupeSharedSchemasCommandTest 9 pass.

The full unit suite is not part of this evidence: this checkout lives
inside the Nextcloud server tree, so the bootstrap finds a real NC root
and boots the container per test — it exhausts 3 GB before test 300. The
same suite ran green (16,919) on a composer-autoload-only checkout of the
same code earlier. CI runs it properly.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ ee274d3

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
test-l10n-parity
format
composer ✅ 175/175
npm ✅ 528/528
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-22 10:25 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde

Copy link
Copy Markdown
Contributor

CI is green now. Latest Code Quality run 32566786422: success. Current check tally is 42 pass / 7 skipped; the one remaining red mark is a Quality Report left over from a cancelled superseded run, not from this code.

What I pushed to get here, on top of the CodeQL/eslint fixes in d3db379:

  • ed0f0ec + a follow-up merge of current development. My first merge was three commits stale, which is why gate-94 retired-git-host-metadata was reporting 6 shipped Codeberg URLs — the repoint of appinfo/info.xml landed on development in 401882951, just after the commit I had merged. Merging current development fixed it.
  • 0327e65 — four PHPStan 2 findings that came in with that merge, in files written while development's phpstan was still 1.x: two dead ?? fallbacks in BulkSaveOutcome on fields getFailed() declares non-nullable, and in SharedSchemaDedupeService a getId() === null check after a : Schema return plus an instanceof Register filter over a Register[].
  • Re-ran Newman API Test Suite, which had failed on HTTP/2 504 from api.github.com for every composer dist download — composer install never completed, so Newman never ran. Passed on the rerun.

I have not merged it: this is still marked as a draft, and that is your call rather than mine. Mark it ready whenever you are happy and it should go straight through — or tell me and I will.

Local verification on the merged branch: phpstan 0, phpcs 0, test:l10n OK (343 files, 2035 keys), test:l10n:parity OK (all 36 locales at full parity).

Both conflicts were additive, so each is resolved as a union rather than a
choice between the two sides:

- package.json: development added `check:schema-l10n`; this branch added the
  l10n tooling scripts. Neither supersedes the other, so both are kept.
- code-quality.yml: development added `check:schema-l10n` to frontend-checks
  and this branch added `test:l10n:parity`. They ask different questions —
  test:l10n asks whether en.js covers every t()/n() call, test:l10n:parity asks
  whether every locale matches en.js key-for-key — so both legs run.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 74eb558

Check PHP Vue Security License Tests
lint ⏭️
phpcs ⏭️
phpmd ⏭️
psalm ⏭️
phpstan ⏭️
phpmetrics ⏭️
eslint ⏭️
stylelint ⏭️
build ⏭️
composer ⏭️ ⏭️
npm ⏭️ ⏭️
app:check-code ⏭️
info.xml ⏭️
REUSE ⏭️
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-24 08:07 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 74eb558

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
test-l10n-parity
format
check-schema-l10n
composer ✅ 175/175
npm ✅ 528/528
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-24 09:39 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Heads-up: this is now conflict-free and fully green, but I have left it as a draft — marking it ready is your call, not mine.

It was CONFLICTING against development. I merged development in and resolved the two conflicts; both were additive, so each is a union rather than a choice:

  • package.jsondevelopment had added check:schema-l10n, this branch had added the l10n tooling scripts. Neither supersedes the other, so both are kept (45 scripts, valid JSON).

  • .github/workflows/code-quality.ymldevelopment added check:schema-l10n to frontend-checks and this branch added test:l10n:parity. They answer different questions — test:l10n asks whether en.js covers every t()/n() call in src/, test:l10n:parity asks whether every locale matches en.js key-for-key — so both legs now run:

    frontend-checks: '["check:specs", "test:l10n", "test:l10n:parity", "format", "check:schema-l10n"]'

Only those two files conflicted; the other 98 merged clean. CI is now 44 checks green, 0 failing.

Nothing else touched — the l10n work itself is untouched. Mark it ready when you are happy with it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants