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
},
// 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"
}
}