v0.247.0: i18n spike — the dashboard can speak English, Hungarian byte-identical
gates / gates (push) Successful in 19s
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user