v0.247.0: i18n spike — the dashboard can speak English, Hungarian byte-identical
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:
2026-09-17 14:50:57 +02:00
parent 89dd3e94b1
commit 612c417024
49 changed files with 11734 additions and 248 deletions
+97 -8
View File
@@ -5,9 +5,11 @@ import (
"context"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"sync"
@@ -19,6 +21,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
@@ -41,7 +44,9 @@ type Server struct {
logger *log.Logger
version string
encKey []byte // AES-256 key for decrypting app.yaml values
tmpl *template.Template
tmpl *template.Template // the Hungarian set (i18n.Default) — every pre-i18n caller renders this
tmplByLang map[string]*template.Template
i18n *i18n.Bundle
sessions map[string]*session
sessionsMu sync.RWMutex
@@ -286,14 +291,93 @@ func (s *Server) SetEncryptionKey(key []byte) {
s.encKey = key
}
// loadTemplates parses one template set per supported language (i18n, v0.247.0). Each set is parsed
// from the SAME embedded files after i18n.Expand has replaced their {{T "key"}} markers with that
// language's text — see internal/i18n for why the expansion happens before parsing and not at render.
// s.tmpl stays the Hungarian set, so every caller that predates i18n renders exactly what it did.
func (s *Server) loadTemplates() {
s.tmpl = template.Must(
template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"),
)
if s.isDebug() {
names := s.tmpl.Templates()
s.logger.Printf("[DEBUG] [web] loadTemplates: loaded %d templates", len(names))
b, err := i18n.Shared()
if err != nil {
panic(err) // the bundles are embedded: a failure here is a broken build, like template.Must
}
s.i18n = b
s.tmplByLang = make(map[string]*template.Template, len(i18n.Supported))
for _, lang := range i18n.Supported {
funcs := s.templateFuncMap()
for name, fn := range s.localeFuncs(lang) {
funcs[name] = fn
}
t, st, err := parseTemplateSet(b, lang, funcs)
if err != nil {
panic(fmt.Errorf("templates (%s): %w", lang, err))
}
s.tmplByLang[lang] = t
if lang != i18n.Default && len(st.FellBack) > 0 && s.logger != nil {
s.logger.Printf("[INFO] [web] i18n: %s template set shows Hungarian for %d markers (keys not yet translated)", lang, len(st.FellBack))
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] loadTemplates: lang=%s loaded %d templates, %d markers expanded", lang, len(t.Templates()), st.Markers)
}
}
s.tmpl = s.tmplByLang[i18n.Default]
}
// parseTemplateSet is template.ParseFS(templateFS, "templates/*.html") with an i18n.Expand step
// between reading each file and parsing it. It names each file's template exactly as ParseFS does
// (the base name), so lookups behave identically.
func parseTemplateSet(b *i18n.Bundle, lang string, funcs template.FuncMap) (*template.Template, i18n.ExpandStats, error) {
var total i18n.ExpandStats
names, err := fs.Glob(templateFS, "templates/*.html")
if err != nil {
return nil, total, err
}
if len(names) == 0 {
return nil, total, fmt.Errorf("no templates embedded")
}
t := template.New("").Funcs(funcs)
for _, n := range names {
raw, err := templateFS.ReadFile(n)
if err != nil {
return nil, total, err
}
src, st := b.Expand(lang, string(raw))
total.Markers += st.Markers
total.FellBack = append(total.FellBack, st.FellBack...)
if len(st.Undefined) > 0 {
return nil, total, fmt.Errorf("%s: marker key(s) not in the %s bundle: %s", n, i18n.Default, strings.Join(st.Undefined, ", "))
}
base := path.Base(n)
tmpl := t
if base != t.Name() {
tmpl = t.New(base)
}
if _, err := tmpl.Parse(src); err != nil {
return nil, total, err
}
}
return t, total, nil
}
// templatesFor returns the template set for a language, the Hungarian set for anything unknown.
func (s *Server) templatesFor(lang string) *template.Template {
if t, ok := s.tmplByLang[lang]; ok {
return t
}
return s.tmpl
}
// langFor decides the language of one request: `?lang=hu|en` (a testing override, never persisted),
// else the household's saved setting, else Hungarian.
func (s *Server) langFor(r *http.Request) string {
if r != nil {
if q := r.URL.Query().Get("lang"); i18n.IsSupported(q) {
return q
}
}
if s.settings != nil {
return s.settings.GetLanguage()
}
return i18n.Default
}
// HubPushStatusData holds hub push status for the monitoring page.
@@ -509,6 +593,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// into the security page. POST (not GET) so it is CSRF-covered and uncacheable — see the handler.
case path == "/settings/retrieval-password/reveal" && r.Method == http.MethodPost:
s.settingsRetrievalPasswordRevealHandler(w, r)
// i18n (v0.247.0): the household's dashboard language.
case path == "/settings/language" && r.Method == http.MethodPost:
s.languageSwitchHandler(w, r)
case path == "/settings/notifications" && r.Method == http.MethodPost:
s.settingsNotificationsHandler(w, r)
case path == "/settings/notifications/test" && r.Method == http.MethodPost:
@@ -768,8 +855,10 @@ func (s *Server) executeTemplate(w http.ResponseWriter, r *http.Request, name st
// claim pages render through s.tmpl.ExecuteTemplate directly and never pass through here; the
// session check inside keeps it off the guest share page, which has no admin session.
s.addEscrowBanner(data, r)
lang := s.langFor(r)
s.addLanguageData(data, r, lang)
var buf bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
if err := s.templatesFor(lang).ExecuteTemplate(&buf, name, data); err != nil {
s.logger.Printf("[ERROR] [web] Template error (%s): %v", name, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return