Files
felhom-controller/controller/internal/web/i18n_web.go
T
admin 612c417024
gates / gates (push) Successful in 19s
v0.247.0: i18n spike — the dashboard can speak English, Hungarian byte-identical
Message bundles (internal/i18n) expanded into templates before parsing, one
template set per language. Launcher, /backups, /apps/<slug> and the layout
converted; household language setting, POST /settings/language, ?lang= override,
report field. Parity test against fixtures captured from unconverted templates;
copy gates read templates expanded; new i18n_missing_gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 14:50:57 +02:00

169 lines
6.0 KiB
Go
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.
package web
import (
"fmt"
"html/template"
"net/http"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// ── 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.
// addLanguageData puts the request's language and the switch state into a page's data.
//
// The switch is shown only when the page is NOT in Hungarian, or the request carries ?lang=. That is a
// deliberate property of this release, not an oversight: English covers three pages, so offering it to
// every household would put a half-English dashboard one click away. A Hungarian household therefore
// sees nothing new; the way back from English is always on screen. Slice 1 of the plan makes the
// switch visible to everyone once the remaining pages are converted.
func (s *Server) addLanguageData(data map[string]interface{}, r *http.Request, lang string) {
data["Lang"] = lang
show := lang != i18n.Default
if r != nil && r.URL.Query().Get("lang") != "" {
show = true
}
if show && r != nil {
data["LangSwitch"] = true
data["LangSwitchBack"] = r.URL.Path
}
// 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.
if key, ok := data["TitleKey"].(string); ok && lang != i18n.Default && s.i18n != nil {
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
},
"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"
}
}