Files
felhom-controller/controller/internal/web/i18n_web.go
T
admin be2efe5624
gates / gates (push) Successful in 20s
v0.250.0: i18n slice 1 release C — the language switch on every dashboard page (R-556)
- addLanguageData: the v0.247.0 hide condition (switch only on non-Hungarian pages or with ?lang=) is
  deleted; every template is converted, so the offer no longer leads to a half-English page.
- 89 layout parity fixtures re-captured; each equals its predecessor plus exactly one switch form after
  the version span (checked byte-for-byte, 89 of 89). The 17 standalone fixtures are unchanged.
- TestLanguageSwitch_EndToEnd: a Hungarian household with no ?lang= sees the form (red-proofed by
  restoring the condition: this test and 89 parity cases fail).
- CHANGELOG v0.250.0, README §18, CONTEXT.

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

180 lines
6.4 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 on every dashboard page, in every language (v0.250.0, slice 1 release C). Until then it
// was hidden from Hungarian pages without ?lang=, because English covered only part of the dashboard;
// every template is converted now, so the offer no longer leads to a half-English page. Pinned by
// TestLanguageSwitch_EndToEnd (a Hungarian household with no ?lang= sees the form).
func (s *Server) addLanguageData(data map[string]interface{}, r *http.Request, lang string) {
data["Lang"] = lang
if 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"
}
}