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

268 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```yaml
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
5. **Mechanism (b2)** — §2.1.
6. **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.