v0.247.0: i18n spike — the dashboard can speak English, Hungarian byte-identical
gates / gates (push) Successful in 19s
gates / gates (push) Successful in 19s
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
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user