v0.252.0 — the sentences the program builds follow the language (R-557 slice 2 release A)
gates / gates (push) Successful in 23s

Slice 1 translated the dashboard's markup. The sentences the program BUILDS were still
Hungarian literals in Go, so an English household clicked an English button and was
answered in Hungarian. 226 of them move into the bundle here.

A flash was the hard part: it travels inside the redirect URL and is rendered by a
DIFFERENT request, so it now carries a bundle key plus its parameters. A link minted by
an older controller carries prose and is shown verbatim — never a raw key, never dropped.

Also converted: page data and view-model text, the internal/api JSON answers, the alert
banners (Alert.MessageKey, rendered on the way out of GetAlerts), 237 country names at
display, and the four page titles built around an app name (R-566 closed).

Hungarian is byte-identical, and that is measured rather than read:
scripts/i18n_go_parity.py freezes every Go literal at the base commit (7 467) and refuses
a key whose Hungarian is not that text, byte for byte. Three decoys, each seen to convict.
Its own first version filtered the capture through an ASCII-Hungarian word list and missed
seven real literals — the R-565 class. The filter is gone.

Nothing on the wire moved, and wire goldens now hold it there: the report's health
warnings and every notify event message stay Hungarian, because the hub MAILS the
controller's sentence when it has no entry of its own. Slice 3 (R-558) owns those.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-18 10:15:56 +02:00
parent 736f54b496
commit 5270bad76e
52 changed files with 10755 additions and 478 deletions
+145 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"html/template"
"net/http"
"net/url"
"strings"
"time"
@@ -18,6 +19,132 @@ import (
// was before this file existed — pinned by i18n_parity_test.go against fixtures captured from the
// unconverted templates.
// ── Go-side copy (v0.252.0, slice 2 — R-557) ───────────────────────────────────────────────────
//
// A handler builds sentences the template never sees: flash lines, page data, JSON answers. Those go
// through these three helpers, never through a Hungarian literal, so they follow the request's
// language exactly as the template does.
//
// The Hungarian text is the SAME BYTES the literal carried, which scripts/i18n_go_parity.py measures
// against a frozen capture of the base commit. So a handler that reads
//
// data["Msg"] = s.msg(r, "backup.window_updated")
//
// renders, for a Hungarian household, the literal that used to sit at that line and nothing else.
// bundle returns the message bundle for these helpers.
//
// It falls back to the process-wide i18n.Shared() when the Server has none. That is not defensive
// clutter: `s.i18n` is set by loadTemplates, so any Server built WITHOUT going through it — a test
// fixture, a future construction path — would otherwise render raw KEYS onto a page, which is the one
// outcome §4 of the localisation design forbids ("never a key, never a blank"). The bundle is
// embedded in the binary, so the fallback cannot fail for any reason a running box can reach; if it
// somehow does, the caller still gets the key rather than a panic.
func (s *Server) bundle() *i18n.Bundle {
if s.i18n != nil {
return s.i18n
}
b, err := i18n.Shared()
if err != nil {
return nil
}
return b
}
// msg returns a Go-side message in the request's language, with the message's own printf verbs
// filled in from a.
func (s *Server) msg(r *http.Request, key string, a ...interface{}) string {
return s.msgLang(s.langFor(r), key, a...)
}
// msgLang is msg for a language that is already known — a background run reading the box's setting
// (internal/settings GetLanguage), or a handler that resolved the language once for several lines.
func (s *Server) msgLang(lang, key string, a ...interface{}) string {
b := s.bundle()
if b == nil {
return key
}
if len(a) == 0 {
return b.Msg(lang, key)
}
return b.Msgf(lang, key, a...)
}
// msgN returns a count-dependent message in the request's language. Hungarian carries one form under
// the key itself; English carries key+".one" and key+".other" (i18n.Bundle.Plural).
func (s *Server) msgN(r *http.Request, key string, n int) string {
b := s.bundle()
if b == nil {
return key
}
return b.Plural(s.langFor(r), key, n)
}
// ── Flash lines (v0.252.0) ─────────────────────────────────────────────────────────────────────
//
// A flash travels to the page INSIDE THE REDIRECT URL (`?flash=…`), so it is rendered by a DIFFERENT
// request from the one that wrote it — and until now it travelled as Hungarian text, which is the
// language of whoever redirected. It now travels as a bundle KEY plus its parameters, and the reader
// renders it in its own language.
//
// Backward compatibility is not a nicety here: a customer's open tab, a bookmark or a browser's
// back-forward cache can replay a URL minted by the previous version, and a mail client can carry
// one. So the rule is: a value the bundle knows as a key is a MESSAGE; anything else is TEXT and is
// shown verbatim, exactly as it was before. That also covers a hand-typed `?flash=<script>` — which
// html/template escapes, as it always did.
// flashArgParam is the repeated query parameter carrying a flash message's parameters, in order.
const flashArgParam = "fa"
// flashQuery builds the query fragment for a flash: the key, then one `fa` per parameter.
// It returns "flash=…" (or "flash_error=…") WITHOUT a leading separator, because the callers differ
// on whether the destination already carries a query.
func flashQuery(param, key string, args ...string) string {
q := url.Values{}
q.Set(param, key)
for _, a := range args {
q.Add(flashArgParam, a)
}
return q.Encode()
}
// flashText resolves a flash query value for display.
//
// A value that names a key in the bundle is rendered in lang, with the `fa` parameters filled in.
// Anything else — a sentence minted by an older controller, or something a person typed — is
// returned unchanged. It is never dropped and never shown as a raw key.
func (s *Server) flashText(r *http.Request, v string) string {
if v == "" {
return ""
}
b := s.bundle()
if b == nil {
return v
}
lang := s.langFor(r)
if !b.Has(i18n.Default, v) {
return v // legacy text, or not ours: verbatim
}
var args []interface{}
if r != nil {
for _, a := range r.URL.Query()[flashArgParam] {
args = append(args, a)
}
}
if len(args) == 0 {
return b.Msg(lang, v)
}
return b.Msgf(lang, v, args...)
}
// flashFrom reads one flash parameter off the request and resolves it. Empty when absent.
func (s *Server) flashFrom(r *http.Request, param string) string {
if r == nil {
return ""
}
return s.flashText(r, strings.TrimSpace(r.URL.Query().Get(param)))
}
// addLanguageData puts the request's language and the switch state into a page's data.
//
// The switch is on every dashboard page, in every language (v0.250.0, slice 1 release C). Until then it
@@ -30,10 +157,27 @@ func (s *Server) addLanguageData(data map[string]interface{}, r *http.Request, l
data["LangSwitch"] = true
data["LangSwitchBack"] = r.URL.Path
}
// The alert banners were stored by a background health cycle and put into the page data by
// baseData, which has no request and therefore no language. Re-rendered here, where the language
// is known. Idempotent: an Alert keeps its key, so rendering it twice is rendering it once.
if alerts, ok := data["Alerts"].([]Alert); ok {
for i := range alerts {
alerts[i] = alerts[i].rendered(lang)
}
}
// A page title is Go-side copy. The handler names its key; the Hungarian title the handler set is
// left untouched, so a Hungarian page cannot change here.
//
// TitleArgs (v0.252.0, R-566) carries the app name for the three titles built around one: „<app>
// — Naplók", „<app> — Telepítés"/„— Beállítások" and „2. mentés beállítása — <app>". Their page
// BODIES were English from slice 1 while the browser tab stayed Hungarian, because one static
// message cannot hold a name. The message now carries a `%s` and the handler supplies the name.
if key, ok := data["TitleKey"].(string); ok && lang != i18n.Default && s.i18n != nil {
data["Title"] = s.i18n.Msg(lang, key)
if args, ok := data["TitleArgs"].([]interface{}); ok && len(args) > 0 {
data["Title"] = s.i18n.Msgf(lang, key, args...)
} else {
data["Title"] = s.i18n.Msg(lang, key)
}
}
}