612c417024
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
169 lines
6.0 KiB
Go
169 lines
6.0 KiB
Go
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"
|
||
}
|
||
}
|