7c05b59708
gates / gates (push) Successful in 24s
179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by whoever caught them: too late to translate where they are shown, too early where they are made. Every one now carries its key across that gap. ZERO Hungarian error literals remain. util.MsgError does three things at once, each earned: - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged; - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause); - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole. A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours. 76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the household's language come from one value; UpdateRefusal gained a Cause to carry it. Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a per-call-site flag — the producer somebody forgot would read "3 app is not running". The guard caught a real key collision (alert.deadapp.one) the day the rule landed. TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could not see it, because every surviving fragment WAS a real base literal while the CALL had lost text; two behaviour tests caught it. And the counting script was case-sensitive, so it said "0 left" while five remained. 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
361 lines
14 KiB
Go
361 lines
14 KiB
Go
package web
|
||
|
||
import (
|
||
"fmt"
|
||
"html/template"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||
)
|
||
|
||
// ── i18n (v0.247.0) — the dashboard in more than one language ──────────────────────────────────
|
||
//
|
||
// Design: felhom.eu/documentation/architecture/10-localisation.md. Hungarian is the default and the
|
||
// only language a household sees unless it switches. The Hungarian page is byte-identical to what it
|
||
// 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)
|
||
}
|
||
|
||
// ── Errors on a page (v0.253.0, slice 2 release B) ─────────────────────────────────────────────
|
||
//
|
||
// An error is MADE deep in a package that has no request, and PRINTED by a handler that has one.
|
||
// util.MsgError carries the key across that gap; errText is the handler side of it.
|
||
//
|
||
// A plain error — restic, docker, ssh, the Go stdlib — prints verbatim, in both languages, because
|
||
// that sentence is not ours (10-localisation.md §9). So this is safe to put in front of EVERY
|
||
// err.Error() on a display path, converted or not, and that is exactly what release B does.
|
||
|
||
// errText renders an error in the request's language: its bundle message when it carries one, its own
|
||
// text otherwise.
|
||
func (s *Server) errText(r *http.Request, err error) string {
|
||
return util.ErrText(s.langFor(r), err)
|
||
}
|
||
|
||
// errTextLang is errText for a language already resolved (a background run, or a handler that
|
||
// resolved it once).
|
||
func (s *Server) errTextLang(lang string, err error) string {
|
||
return util.ErrText(lang, err)
|
||
}
|
||
|
||
// ── 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.
|
||
|
||
// flashForErr turns an error into what a flash carries: its bundle KEY plus its parameters when it
|
||
// has one, and its own text otherwise.
|
||
//
|
||
// The second half is what makes this safe to use everywhere: a restic, docker or ssh sentence goes
|
||
// into the URL as text and is shown verbatim by flashText, exactly as it was before v0.252.0.
|
||
func flashForErr(err error) (string, []string) {
|
||
if err == nil {
|
||
return "", nil
|
||
}
|
||
if m, ok := util.AsMsg(err); ok {
|
||
return m.Key(), m.Args()
|
||
}
|
||
return err.Error(), nil
|
||
}
|
||
|
||
// 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
|
||
// was hidden from Hungarian pages without ?lang=, because English covered only part of the dashboard;
|
||
// every template is converted now, so the offer no longer leads to a half-English page. Pinned by
|
||
// TestLanguageSwitch_EndToEnd (a Hungarian household with no ?lang= sees the form).
|
||
func (s *Server) addLanguageData(data map[string]interface{}, r *http.Request, lang string) {
|
||
data["Lang"] = lang
|
||
if r != nil {
|
||
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 {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// languageSwitchHandler stores the household's language (POST /settings/language) and goes back to
|
||
// the page it came from. CSRF is enforced by the CsrfProtect middleware wrapping "/" (main.go), as for
|
||
// every other dashboard form; the switch form carries {{.CSRFField}}.
|
||
func (s *Server) languageSwitchHandler(w http.ResponseWriter, r *http.Request) {
|
||
lang := r.FormValue("lang")
|
||
if !i18n.IsSupported(lang) {
|
||
http.Error(w, "unsupported language", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if err := s.settings.SetLanguage(lang); err != nil {
|
||
s.logger.Printf("[ERROR] [web] language switch: save failed: %v", err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
s.logger.Printf("[INFO] [web] language switch: household language set to %s", lang)
|
||
s.reportTriggerNow() // the hub learns the language on the next report, in seconds rather than minutes
|
||
http.Redirect(w, r, stripLangQuery(redirectBackTo(r, "/launcher")), http.StatusFound)
|
||
}
|
||
|
||
// stripLangQuery keeps a ?lang= override from pinning the page after the setting has been saved.
|
||
func stripLangQuery(p string) string {
|
||
if i := strings.Index(p, "?"); i >= 0 {
|
||
return p[:i]
|
||
}
|
||
return p
|
||
}
|
||
|
||
// localeFuncs returns the template funcs whose OUTPUT is copy, re-implemented from the bundle, for
|
||
// every language except Hungarian. Hungarian gets nothing here: its funcs stay the ones in
|
||
// templateFuncMap, untouched, which is the parity guarantee for Go-side helper text.
|
||
// TestLocaleFuncsHungarianBundleMatchesFuncMap pins that hu.json carries the same words the Hungarian
|
||
// funcs return, so the bundle can become the single source in slice 1 without a byte moving.
|
||
func (s *Server) localeFuncs(lang string) template.FuncMap {
|
||
if lang == i18n.Default {
|
||
return nil
|
||
}
|
||
b, err := i18n.Shared()
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
loc := getTimezone()
|
||
ago := func(d time.Duration) string {
|
||
switch {
|
||
case d < time.Minute:
|
||
return b.Msg(lang, "func.time.now")
|
||
case d < time.Hour:
|
||
return b.Plural(lang, "func.time.minutes_ago", int(d.Minutes()))
|
||
case d < 24*time.Hour:
|
||
return b.Plural(lang, "func.time.hours_ago", int(d.Hours()))
|
||
case d < 48*time.Hour:
|
||
return b.Msg(lang, "func.time.yesterday")
|
||
default:
|
||
return b.Plural(lang, "func.time.days_ago", int(d.Hours()/24))
|
||
}
|
||
}
|
||
return template.FuncMap{
|
||
"stateLabel": func(state stacks.ContainerState) string {
|
||
return b.Msg(lang, stateLabelKey(state))
|
||
},
|
||
"timeAgo": func(t time.Time) string {
|
||
if t.IsZero() {
|
||
return "–"
|
||
}
|
||
return ago(time.Now().In(loc).Sub(t.In(loc)))
|
||
},
|
||
"timeAgoStr": func(str string) string {
|
||
t, err := time.Parse(time.RFC3339, str)
|
||
if err != nil {
|
||
return str
|
||
}
|
||
return ago(time.Now().In(loc).Sub(t.In(loc)))
|
||
},
|
||
"nextRunLabel": func(t time.Time) string {
|
||
if t.IsZero() {
|
||
return "–"
|
||
}
|
||
lt := t.In(loc)
|
||
now := time.Now().In(loc)
|
||
hm := lt.Format("15:04")
|
||
if lt.Year() == now.Year() && lt.YearDay() == now.YearDay() {
|
||
return fmt.Sprintf(b.Msg(lang, "func.time.today_at"), hm)
|
||
}
|
||
if lt.Year() == now.Year() && lt.YearDay() == now.YearDay()+1 {
|
||
return fmt.Sprintf(b.Msg(lang, "func.time.tomorrow_at"), hm)
|
||
}
|
||
return lt.Format("2006-01-02") + " " + hm
|
||
},
|
||
// infraMeta: the protected infra stacks' curated identity (inframeta.go). Names that are product
|
||
// names stay; descriptions — and samba's display name — come from the bundle.
|
||
"infraMeta": func(name string) *InfraMeta {
|
||
m := infraMetaFor(name)
|
||
if m == nil {
|
||
return nil
|
||
}
|
||
out := *m
|
||
if v, _, ok := b.Text(lang, "func.infra."+name+".description"); ok {
|
||
out.Description = v
|
||
}
|
||
if v, _, ok := b.Text(lang, "func.infra."+name+".display"); ok {
|
||
out.DisplayName = v
|
||
}
|
||
return &out
|
||
},
|
||
"statusText": func(status string) string {
|
||
switch status {
|
||
case "pending", "restoring", "done", "failed", "skipped":
|
||
return b.Msg(lang, "func.restore_status."+status)
|
||
default:
|
||
return status
|
||
}
|
||
},
|
||
}
|
||
}
|
||
|
||
// stateLabelKey maps a container state to its bundle key. The default arm mirrors templateFuncMap's
|
||
// "Ismeretlen" default, so an unmapped state is labelled unknown in every language.
|
||
func stateLabelKey(state stacks.ContainerState) string {
|
||
switch state {
|
||
case stacks.StateRunning:
|
||
return "func.state.running"
|
||
case stacks.StateStarting:
|
||
return "func.state.starting"
|
||
case stacks.StateDeploying:
|
||
return "func.state.deploying"
|
||
case stacks.StateUnhealthy:
|
||
return "func.state.unhealthy"
|
||
case stacks.StateDegraded:
|
||
return "func.state.degraded"
|
||
case stacks.StateStopped, stacks.StateExited:
|
||
return "func.state.stopped"
|
||
case stacks.StateRestarting:
|
||
return "func.state.restarting"
|
||
case stacks.StateNotDeployed:
|
||
return "func.state.not_deployed"
|
||
case stacks.StatePaused:
|
||
return "func.state.paused"
|
||
default:
|
||
return "func.state.unknown"
|
||
}
|
||
}
|