diff --git a/CHANGELOG.md b/CHANGELOG.md index 83fbc94..b2a4d14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,45 @@ +## v0.247.0 — the dashboard can speak English: mechanism proven on three pages, Hungarian byte-identical (2026-09-17, i18n spike) + +**MinAgent: 0.131.0** (unchanged — nothing here talks to the agent). **No hub release needed:** the +report gains an additive `language` field the hub stores raw and does not read yet. + +**Nothing changes for a household.** Hungarian stays the default and the only language shown; the +switch appears only on an English page or a request carrying `?lang=`. + +- **Mechanism — a flat message bundle, expanded BEFORE the template is parsed.** + `internal/i18n/locales/{hu,en}.json` (embedded; hu authoritative). Converted templates carry + `{{T "key"}}` markers; `loadTemplates` parses one template set per language after `i18n.Expand` has + replaced them, so the Hungarian set is parsed from the same bytes, in the same escaping contexts, as + before. Chosen over `golang.org/x/text/message` because the inventory found no gender and only + count-plurals (English `key.one`/`key.other`; Hungarian has one form), and over a runtime `T` func + because the contextual escaper would change Hungarian bytes (`+`, `'`, `"`) and need per-context + handling inside ``) +var i18nMarkerRe = regexp.MustCompile(`\{\{\s*T\s+"([A-Za-z0-9_.\-]+)"\s*\}\}`) + +func templateSources(t *testing.T) map[string]string { + t.Helper() + names, err := fs.Glob(templateFS, "templates/*.html") + if err != nil || len(names) == 0 { + t.Fatalf("no templates: %v", err) + } + out := map[string]string{} + for _, n := range names { + b, err := templateFS.ReadFile(n) + if err != nil { + t.Fatal(err) + } + out[n] = string(b) + } + return out +} + +// Every key a template or the Go side names exists in Hungarian. (The loader refuses an undefined +// template key too; this test names the key instead of a parse error, and covers the Go-side keys the +// loader never sees.) +func TestBundleKeysUsedExistInHungarian(t *testing.T) { + b, err := i18n.Shared() + if err != nil { + t.Fatal(err) + } + used := 0 + for name, src := range templateSources(t) { + for _, m := range i18nMarkerRe.FindAllStringSubmatch(src, -1) { + used++ + if !b.Has(i18n.Default, m[1]) { + t.Errorf("%s: marker key %q is not in hu.json", name, m[1]) + } + } + } + if used < 200 { + t.Fatalf("only %d markers found — the scan is not reading the converted templates", used) + } + goKeys := []string{"page.title.launcher", "page.title.backups", "func.time.now", "func.time.minutes_ago", + "func.time.hours_ago", "func.time.yesterday", "func.time.days_ago", "func.time.today_at", "func.time.tomorrow_at"} + for _, st := range []string{"pending", "restoring", "done", "failed", "skipped"} { + goKeys = append(goKeys, "func.restore_status."+st) + } + for _, st := range allContainerStates() { + goKeys = append(goKeys, stateLabelKey(st)) + } + for _, k := range goKeys { + if !b.Has(i18n.Default, k) { + t.Errorf("Go-side key %q is not in hu.json", k) + } + } +} + +func allContainerStates() []stacks.ContainerState { + return []stacks.ContainerState{stacks.StateRunning, stacks.StateStarting, stacks.StateDeploying, stacks.StateUnhealthy, + stacks.StateDegraded, stacks.StateStopped, stacks.StateExited, stacks.StateRestarting, stacks.StateNotDeployed, + stacks.StatePaused, stacks.ContainerState("something-new")} +} + +// Expansion is textual, so a translation lands in the source exactly where the Hungarian was. Inside a +// JS string literal a bare quote or backslash ends or corrupts the string; inside a double-quoted +// attribute a `"` ends the attribute. Neither is caught by html/template, which sees the expanded text as +// the author's own source. So: a value may carry a quote character only where the Hungarian carries the +// same one (the author already made it safe there). +func TestI18nJSContextValuesAreSafe(t *testing.T) { + b, err := i18n.Shared() + if err != nil { + t.Fatal(err) + } + checked := 0 + check := func(where, key string, forbidden string) { + hu, _, _ := b.Text(i18n.Default, key) + for _, lang := range i18n.Supported { + for _, form := range []string{key, key + ".one", key + ".other"} { + v, fellBack, ok := b.Text(lang, form) + if !ok || fellBack { + continue + } + checked++ + for _, c := range forbidden { + if strings.ContainsRune(v, c) && !strings.ContainsRune(hu, c) { + t.Errorf("%s: %s %q contains %q, which breaks its context", where, lang, form, c) + } + } + } + } + } + for name, src := range templateSources(t) { + for _, sm := range i18nScriptRe.FindAllStringSubmatch(src, -1) { + for _, m := range i18nMarkerRe.FindAllStringSubmatch(sm[1], -1) { + check(name+" {{template "icon_sprite"}}
-
@@ -77,47 +77,47 @@ @@ -126,7 +126,7 @@
- A vezérlőpult még nincs jelszóval védve — a beállító kódot hamarosan e-mailben küldjük. + {{T "layout.a_vezerlopult_meg_nincs_jelszoval"}}
{{end}} @@ -146,28 +146,28 @@ reachable state: a fresh escrow ceremony during the window replaces the package, which is the exact act that cost both demo boxes their history on 2026-08-04. A countdown started before this shipped carries no pin and takes the cautious branch. */}} - A korábbi távoli mentéseidet {{.RecoveryAbandonDays}} nap múlva ({{.RecoveryAbandonDate}}) véglegesen töröljük, a kérésed szerint. - {{if .RecoveryAbandonRetrievalOffered}}Addig még visszaszerezheted őket a helyreállítási kóddal.{{else}}Hogy ezek még visszaszerezhetők-e a helyreállítási kóddal, azt innen nem tudjuk megállapítani — ha vissza szeretnéd kapni őket, írj nekünk a törlés előtt.{{end}} + {{T "layout.a_korabbi_tavoli_menteseidet_nap"}} + {{if .RecoveryAbandonRetrievalOffered}}{{T "layout.addig_meg_visszaszerezheted_oket_a"}}{{else}}{{T "layout.hogy_ezek_meg_visszaszerezhetok_e"}}{{end}} {{else if ge .RecoveryReminderTier 14}} - Két hete várnak rád a korábbi távoli mentéseid, és még nem adtad meg a helyreállítási kódodat. Amíg nem teszed, ezekhez a mentésekhez nem férsz hozzá. + {{T "layout.ket_hete_varnak_rad_a"}} {{else if ge .RecoveryReminderTier 7}} - Már egy hete megvannak a korábbi távoli mentéseid, de a helyreállítási kódod nélkül nem tudjuk megnyitni őket. + {{T "layout.mar_egy_hete_megvannak_a"}} {{else if ge .RecoveryReminderTier 3}} - A korábbi távoli mentéseid megvannak — a megnyitásukhoz a helyreállítási kódod szükséges. + {{T "layout.a_korabbi_tavoli_menteseid_megvannak"}} {{else}} - A korábbi távoli mentéseid megvannak, de ehhez a géphez a helyreállítási kódod szükséges. + {{T "layout.a_korabbi_tavoli_menteseid_megvannak_2"}} {{end}} - Megnézem + {{T "layout.megnezem"}}
{{.CSRFField}} - +
{{if not .RecoveryAbandonDays}}
{{.CSRFField}} - +
{{end}}
@@ -183,13 +183,12 @@
- A távoli mentés szünetel, amíg nem hozod létre a helyreállítási kódot. - Helyreállítási kód létrehozása → + {{T "layout.a_tavoli_mentes_szunetel_amig"}}
{{.CSRFField}} - +
@@ -244,7 +243,7 @@ var data = await resp.json(); if (data.ok && data.data && data.data.deployed) { e.preventDefault(); - showAlert('Ez az alkalmazás már telepítve van.'); + showAlert('{{T "layout.ez_az_alkalmazas_mar_telepitve"}}'); window.location.reload(); return false; } @@ -257,7 +256,7 @@ if (!btn) return; const origText = btn.innerHTML; btn.disabled = true; - btn.innerHTML = '↻ Frissítés...'; + btn.innerHTML = '{{T "layout.frissites"}}'; btn.classList.add('loading'); try { const resp = await fetch('/api/sync', { @@ -266,7 +265,7 @@ }); const data = await resp.json(); if (toast) { - toast.textContent = data.ok ? (data.message || 'Sablonok frissítve') : ('Hiba: ' + (data.error || 'Ismeretlen hiba')); + toast.textContent = data.ok ? (data.message || '{{T "layout.sablonok_frissitve"}}') : ('{{T "layout.hiba"}} ' + (data.error || '{{T "layout.ismeretlen_hiba"}}')); toast.className = 'sync-toast ' + (data.ok ? 'sync-toast-ok' : 'sync-toast-err'); toast.style.display = 'block'; setTimeout(function() { toast.style.display = 'none'; }, 5000); @@ -276,7 +275,7 @@ } } catch (err) { if (toast) { - toast.textContent = 'Hálózati hiba: ' + err.message; + toast.textContent = '{{T "layout.halozati_hiba"}} ' + err.message; toast.className = 'sync-toast sync-toast-err'; toast.style.display = 'block'; setTimeout(function() { toast.style.display = 'none'; }, 5000); @@ -290,7 +289,7 @@ const btn = event.currentTarget; const origText = btn.textContent; btn.disabled = true; - btn.textContent = 'Folyamatban...'; + btn.textContent = '{{T "layout.folyamatban"}}'; btn.classList.add('loading'); try { const resp = await fetch('/api/stacks/' + name + '/' + action, { @@ -299,7 +298,7 @@ }); const data = await resp.json(); if (!data.ok) { - showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba')); + showAlert('{{T "layout.hiba"}} ' + (data.error || '{{T "layout.ismeretlen_hiba"}}')); btn.textContent = origText; btn.disabled = false; btn.classList.remove('loading'); @@ -314,7 +313,7 @@ } window.location.reload(); } catch (err) { - showAlert('Hálózati hiba: ' + err.message); + showAlert('{{T "layout.halozati_hiba"}} ' + err.message); btn.textContent = origText; btn.disabled = false; btn.classList.remove('loading'); @@ -337,7 +336,7 @@ var modal = document.createElement('div'); modal.className = 'modal-overlay'; modal.id = 'delete-modal'; - modal.innerHTML = ''; + modal.innerHTML = ''; modal.addEventListener('click', function(e) { if (e.target === modal) closeDeleteModal(); }); document.body.appendChild(modal); try { @@ -346,26 +345,26 @@ var hddInfo = ''; var checkboxHTML = ''; if (data.ok && data.data && data.data.has_hdd_data) { - hddInfo = '