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