Files
felhom.eu/documentation/architecture/10-localisation.md
T

17 KiB
Raw Blame History

10 — The product in more than one language

LIVING DOCUMENT. Every localisation slice updates this file in the same session. Opened 2026-09-17 by the localisation starter, AFTER its spike ran (controller v0.247.0). Before it, no architecture document mentioned localisation at all (a grep of architecture/*.md for i18n|locali|language found one incidental line in 07).

Marks, as in 07 and 02: [DESIGN] — a decision taken, not derived from code. [FACT] — observed, with a citation. Unmarked means not yet classified.

This file carries the REASONING. The register (backlog/OPEN-ITEMS.md, R-553..R-562) carries the work. The numbers are in audits/I18N-INVENTORY-2026-09-17.md. The source is the truth.


1. The rule that makes this safe

[DESIGN] The Hungarian product must render byte-for-byte the same before and after every slice — measured page by page, never by reading. A household that never switches must not be able to tell a localisation release happened. English goes on top of an unchanged Hungarian product.

[FACT] It is enforceable, and enforced for the three converted pages: TestI18nParity (felhom-controller/controller/internal/web/i18n_parity_test.go) renders 14 fixture states of the launcher, /backups and /apps/<slug> in Hungarian and compares them with HTML captured from a clean worktree of origin/main 89dd3e94b1de before any template carried a marker. One changed byte in hu.json fails it naming the page and the line (red-proof, felhom-controller/REPORT.md). Fixtures are captured from unconverted templates and never regenerated to make a conversion pass — that is the release gate for every slice.

The one normalisation: relative ages („3 napja") become „# napja", because they come from the wall clock.

[FACT] And live, on demo-hp guest 9201 (2026-09-17, endpoint-level): the launcher, /backups and /apps/privatebin fetched in Hungarian on 0.246.0 and again on 0.247.0 are identical apart from the version string and the CSRF token, with equal raw byte counts (44 462 / 46 250 / 42 081), and again after a round trip through English (audits/i18n-2026-09-17/live/).


2. The mechanism, as measured

2.1 What was chosen, and why

[DESIGN] A flat message bundle per language, expanded into the template BEFORE html/template parses it. Decided by CC in the spike (the starter delegated the choice, §4 1.1 of the task).

option cost verdict
(a) golang.org/x/text/message catalogs + a T func a new external dependency; its value is gender/plural/ordinal selection not needed: the inventory found no gender agreement and only count plurals; Hungarian does not inflect after a numeral
(b1) flat bundle, runtime T template func every string passes through the contextual escaper: +, ', " change bytes; inside <script> a string becomes a quoted JS literal — 618 of the 1 867 template strings are in <script> rejected: breaks byte parity by construction
(b2) flat bundle, expanded at template LOAD one parsed template set per language (startup parses every template once per language — not measured); a translation is raw source text, so its context safety must be tested chosen: the Hungarian set is parsed from the same bytes, in the same escaping contexts — parity holds by construction and is then measured

2.2 How it works

  • [FACT] Bundles: controller/internal/i18n/locales/hu.json (authoritative — every key) and en.json, embedded (internal/i18n/i18n.go). Flat key → text. A value may carry template actions ({{.RecoveryAbandonDate}}) and inline markup (<strong>, <a>): those are the message's parameters, kept inside one message so a translator can move them. English plurals are key.one / key.other (Bundle.Plural), and a template value may also branch ({{if eq $n 1}}file{{else}}files{{end}}).
  • [FACT] A converted template carries {{T "key"}}. Server.loadTemplates parses one set per supported language through parseTemplateSet = ParseFS + i18n.Expand per file, naming templates exactly as ParseFS does. s.tmpl stays the Hungarian set, so every renderer that predates i18n is unchanged.
  • [FACT] An undefined key is left in place, so the set fails to load („function T not defined") — loud at startup and in every render test (TestExpandLeavesUndefinedMarker).
  • [FACT] Only executeTemplate is language-aware (server.go). Login, claim, recovery and the guest share pages render through s.tmpl directly and stay Hungarian until their slice.
  • [FACT] Go-side copy on a converted page: the handler names its title key (data["TitleKey"]); the non-Hungarian sets override the copy-producing funcs stateLabel, timeAgo, timeAgoStr, nextRunLabel, statusText from the bundle (web/i18n_web.go localeFuncs). The Hungarian funcs are not touched; TestLocaleFuncsHungarianBundleMatchesFuncMap pins that hu.json carries the same words they return.

2.3 What a translation must not do (pinned by tests)

  • Carry different parameters. Same {{.Field}} set and same printf verbs as the Hungarian (TestBundleParametersMatchAcrossLanguages).
  • Break its context. Expansion is textual; inside a JS string a bare '/"/\ or newline ends it, inside a double-quoted attribute a " ends it — and html/template cannot see this, because it reads the expansion as the author's own source. A value may carry a quote only where the Hungarian carries the same one (TestI18nJSContextValuesAreSafe, red-proofed with Copied'). English therefore avoids apostrophes in JS contexts („do not", not „don't"); elsewhere it uses ’.
  • Render blank or raw. No raw key, no marker, no element empty in English that is not empty in Hungarian (TestI18nEnglishPages).

[FACT] What stays Hungarian on the English pages, live: only Go view-model text (the backup-target banner and offer, the whole-guest tier labels, the update badge „Naprakész") and catalog copy (the app's tagline, use cases, first steps) — slices 2 and 5. No template copy.

[FACT] Measured limit of the English page test: it subtracts every string the fixture DATA carries (catalog copy, handler messages) before looking for Hungarian — so a template word identical to a data string is masked (the launcher cases pass the nav's „Indítópult" as the page title). The backups and app cases cover the nav; the limitation is recorded, not fixed.


3. Who picks the language, and how it flows

[DESIGN] Operator decision 2 (2026-09-17, agreed): the operator sets it per customer on the hub at creation (default Hungarian); the household can switch it on their dashboard; the box reports it so the hub's e-mails follow.

  • [FACT] Built in v0.247.0: settings.json language (hu|en; empty reads hu, Settings.GetLanguage); POST /settings/language (session CSRF like every form; lang, back; redirect drops the query); ?lang=hu|en per-request override, never persisted; the hub report's "language" field, always present, set at all four report build sites in cmd/controller/main.go.
  • [FACT] Live 2026-09-17 on demo-hp: POST /settings/language lang=en → 302, settings.json "language": "en", the three pages English; the hub's stored reports read no field (0.246.0), hu, en at 12:54:43Z, hu at 12:55:22Z after switching back; without _csrf → 403 and nothing changed (audits/i18n-2026-09-17/live/README.md).
  • [DESIGN] Not built — slice 3: the hub stores a per-customer language, renders it into controller.yaml next to customer.id/name/domain/email (hub/internal/configgen/configgen.go), and the box uses it only while the household has never chosen (settings.json empty). The household's own choice always wins; the hub's e-mails follow the language the box reports, which is therefore the household's choice. The hub decodes the report with independent json.Unmarshal calls and no DisallowUnknownFields (hub/internal/api/handler.go handleReport), so the field was additive.

[DESIGN] Decided by CC in the spike — operator may reverse: the switch is SHOWN only on a page that is not Hungarian, or on a request carrying ?lang=. One answerable sentence: should a Hungarian household see an English switch while only three pages are English? Options: (1) show it to everyone now — cost: one click from a half-English dashboard, and every page but three keeps a Hungarian body; (2) hide it until slice 1 converts the remaining pages — cost: a household cannot discover English yet, which no household has asked for. Chosen (2): it follows rule §1 (nothing visible changes) and is reversed by deleting one condition in addLanguageData. The way back from English is always on screen.


4. Fallback

[DESIGN] Operator decision 3 (2026-09-17, agreed): a missing English line shows the Hungarian one and is counted by a gate; a missing line never shows a key or an empty box.

  • [FACT] Bundle.Text falls back to Hungarian and flags it; the loader logs i18n: en template set shows Hungarian for N markers; Bundle.Msg of a key absent everywhere returns the key (visible, never blank) and TestBundleKeysUsedExistInHungarian refuses to ship one.
  • [FACT] controller/scripts/i18n_missing_gate.py (in controller_gates.py, pre-push): keys exist, no orphans, the English gap is a ratchet — EN_MISSING_CEILING (0 today) convicts above AND below, so it can only be lowered deliberately. Raising it is a decision recorded here.

5. The gates, per language

[FACT] Found in the spike: moving copy out of the templates blinded four gates and staled one. The retrieval-promise gate went red on STALE allowlist entries the moment the first page converted; the emoji, native-confirm and secret-in-markup gates silently stopped seeing the moved copy. The HEAD versions of the emoji and retrieval gates PASSED a planted emoji and a planted retrieval promise in the bundles. They now judge the page as rendered in each language (controller/scripts/i18n_bundle.py read_template(path, lang)); mojibake scans the bundles; decoys for each are in controller/scripts/test_gate_decoys.py.

[DESIGN] Voice, per language:

  • hu — the product speaks in „te". The converted copy carries 6 formal („ön") forms (R-516). They are not fixed by a localisation release (§1), so the gate counts them against HU_FORMAL_CEILING = 6 as a ratchet: a new one convicts, fixing one lowers the ceiling.
  • en — second person, plain: no „please", no „kindly".

[FACT] Not yet per-language: the retrieval-promise gate's stems are Hungarian only — an English retrieval promise is unscanned (in R-556). The hub copy gate and the catalog have no language concept (scripts/hub_copy_gate.py stems end in a Hungarian character class; catalog_gates.py checks no copy).


6. Deliberately out

  • [DESIGN] The hub's operator pages — the operator reads them; they stay English (decision 1).
  • [DESIGN] The first-boot wizard (controller/internal/setup/, 8 pages, 95 strings) — out of scope and obsolete (decision 4, 2026-09-17; 02-controller-module-map.md L56). A household still reaches it when bootstrap ingestion leaves customer.id empty (inventory §2.7). Its deletion is R-554.
  • [FACT] App containers' own UIs (Uptime Kuma, PrivateBin…) — not Felhom's copy (R-516 items 5-6).

7. The catalog's copy model

[DESIGN, proposed — slice 5 decides after seeing it work] A sibling block per language inside .felhom.yml, not a sibling file:

description: Titkosított jegyzetek…
app_info:
  first_steps: [...]
i18n:
  en:
    description: Encrypted notes…
    app_info:
      first_steps: [...]

Why: one app's copy stays in one file a reviewer sees whole; the controller's yaml.Unmarshal ignores unknown keys (internal/stacks/metadata.go L317), so older controllers are unaffected; a missing English field falls back field by field. Cost: 835 strings, ~4 984 words; the catalog copy gates need an English rule. Read on privatebin, nextcloud and immich (inventory §2.5).


8. Dates, numbers, plurals — as found

  • [FACT] Plurals: 42 Go format strings with %d and 7 template runs with a numeric parameter (inventory §2.2/2.1). Hungarian needs one form; English two.
  • [FACT] Dates: 10 layout literals in internal/web Go, 2 in templates that disagree with each other (2006. 01. 02. 15:04 vs 2006-01-02 15:04), 25 more in the controller; 9 copy-producing helpers (timeAgo „%d perce", nextRunLabel „ma"/„holnap", pruneLabel „vasárnap"…).
  • [FACT] Sizes print a decimal POINT (%.1f GB) — Hungarian convention is a comma. The Hungarian pages are already un-Hungarian there; §1 keeps it that way until someone decides otherwise (R-562).
  • [FACT] Word order: 237 Go format strings and 111 concatenations; in templates, JS sentences split around + name + („Az alkalmazás (" + name + ") törölve lett.") — translatable but fragile.
  • [FACT] Flash messages travel inside the redirect URL (?flash=<text>) in the language of the handler that redirected — slice 2 moves them to keys.

9. Compared, not shown — latent bugs a translation would trigger

[FACT] Four places decide behaviour by matching Hungarian wording (inventory §3): an HTTP status (api/router.go:478), an off-site quota classification (backup/offbox.go:186), an alert's placement (web/alerts.go:208), and a persisted warning compared by marker (web/handlers.go:927). Each breaks the day the words change in either language. R-553 fixes them first, before slice 2 touches a single Go string.


10. The plan

Costs are CC-hours, estimated from the spike (mechanism + three pages + layout + gates ≈ one working session). Each slice ends with the parity gate green for every page it touched.

slice row what proves cost
0 (done) — mechanism, 3 pages + layout, setting, report field, gates Hungarian unchanged by measurement; English reachable spent
1 R-556 the other 31 dashboard templates (~1 500 strings, 600 of them JS), parity fixtures per page; switch shown to everyone; English retrieval stems; the extractor's ASCII word list reviewed per page the whole dashboard in English with Hungarian byte-identical 12–16 h, three releases
2 R-557 (after R-553) Go-side customer strings: 947 shown + 184 errors; flash-in-URL → keys; country names; alert texts a page's server messages follow the language 16–20 h
3 R-558 hub: per-customer language at creation; configgen renders it; customerMessages (39), the 5 lifecycle mails and the self-bind page in English; dispatcher reads the reported language a household's e-mails arrive in its language 6–8 h, one hub + one controller release
4 R-559 console banner (34 lines, console-font limits) and the download page an English household meets English from the first boot screen (in scope — ruling 1b) 4–6 h + an ISO train
5 R-560 catalog: §7 format, controller reads it, 835 strings / ~5 000 words, catalog copy gates per language an app card, its settings and its first steps in English 12–16 h
6 R-561 the volunteer guide in English (~1 600 words); then a stranger's first hour in English, the 2026-09-14 walk; closes R-516 against the inventory a newcomer can set up and use a box in English 6–8 h

11. Operator decisions

These are rulings, not proposals. Anything specced against a different assumption is wrong.

2026-09-17 (on the starter)

  1. Scope: what the household sees — the controller, its e-mails, the guide, the app catalog. Not the hub's operator pages.
  2. Who picks: the operator per customer at creation (default Hungarian); the household switches on the dashboard; the box reports it so the hub's e-mails follow.
  3. Fallback: a missing English line shows the Hungarian one and is counted; never a key or a blank.
  4. The first-boot wizard: out of scope, obsolete — a separate row to delete it (R-554).

Decided by CC in the spike — operator may reverse

  1. Mechanism (b2) — §2.1.
  2. Switch hidden while only three pages are English — §3.

2026-09-17 (evening) — the two open questions, ruled

1b. The console banner and the download page ARE in scope (operator: „yes"). Slice 4 (R-559) is unblocked; it stays late in the plan and rides an ISO release train. 7. Interface nouns translate (operator: „translate"). „Indítópult" → Launcher, „Vezérlőpult" → Dashboard, „Biztonsági mentés" → Backup, as the spike built. App names and „Felhom" stay as they are. Every slice follows this.