From 9100973576f87adcadaf6c8b331df7e729aeb117 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 28 Jul 2026 19:03:15 +0200 Subject: i18n: make all UI chrome user-translatable via ui/.ini The interface chrome (version labels, TUI keybar/messages, CLI banner words, web control labels, section-heading words) was English/Polish hardcoded in Go. It is now data: the English and Polish tables ship as embedded lang/en.ini and lang/pl.ini, and any language is user-overridable at /ui/.ini, resolved per key with an English fallback. With ui_language = plus a names/.ini, the WHOLE interface -- chrome, day names, saint names -- is translatable without a rebuild. - i18n.Get loads the embedded English baseline, overlays the embedded/user language file, caches per lang; field <-> INI-key mapping is by reflection (lang.go) so the tables and files stay in sync automatically. Edge-space values (e.g. "error: ") are double-quoted so the INI trim keeps them. The Go tables move to golden_test.go; TestGoldenMatchesEmbedded asserts the embedded files still parse back to them, and TestGenerateLangFiles regenerates them (LECTIO_GEN=1). - config.UIDir(); wire i18n.SetUserDir in the CLI, TUI and web entry points. - README/config: document ui/.ini alongside names/.ini. --- internal/i18n/lang.go | 155 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 internal/i18n/lang.go (limited to 'internal/i18n/lang.go') diff --git a/internal/i18n/lang.go b/internal/i18n/lang.go new file mode 100644 index 0000000..8ce429c --- /dev/null +++ b/internal/i18n/lang.go @@ -0,0 +1,155 @@ +package i18n + +import ( + "embed" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "unicode" + + "github.com/lukaszkasprzak/lectio/internal/ini" +) + +//go:embed lang +var langFS embed.FS + +var ( + uiMu sync.Mutex + uiCache = map[string]UI{} + userDir string +) + +// SetUserDir points i18n at the drop-in dir holding user chrome files +// (/.ini) and invalidates the cache. Empty string disables it. +func SetUserDir(dir string) { + uiMu.Lock() + userDir = dir + uiCache = map[string]UI{} + uiMu.Unlock() +} + +// Get returns lang's chrome strings: the embedded English baseline, overlaid by +// the embedded lang/.ini (Polish ships; other codes fall through), overlaid +// by the user's /.ini if present. Every lookup falls back to English +// per key, so an unknown language or a partial translation still renders. +func Get(lang string) UI { + uiMu.Lock() + defer uiMu.Unlock() + if u, ok := uiCache[lang]; ok { + return u + } + var u UI + applyEmbedded(&u, "en") // complete English baseline + if lang != "" && lang != "en" { + applyEmbedded(&u, lang) + } + if userDir != "" && lang != "" { + if data, err := os.ReadFile(filepath.Join(userDir, lang+".ini")); err == nil { + applyUI(&u, data) + } + } + uiCache[lang] = u + return u +} + +// applyEmbedded overlays the embedded lang/.ini onto u, if lectio ships +// one for that code. +func applyEmbedded(u *UI, code string) { + if data, err := langFS.ReadFile("lang/" + code + ".ini"); err == nil { + applyUI(u, data) + } +} + +// This file makes the UI chrome data-driven: the English and Polish tables ship +// as embedded lang/.ini files (generated from the Go tables, see the +// gen test), and any language is user-overridable at /ui/.ini. +// Field <-> INI-key mapping is by reflection so the two stay in sync +// automatically: a struct field FooterKeys is the key footer_keys; the +// Version/PartLabel maps are dotted keys (version.wuj, part_label.ewangelia); +// Months is a comma-joined list. Values with meaningful leading/trailing space +// (e.g. "error: ") are double-quoted in the file so the INI reader's trim keeps +// them. + +// iniKey converts a Go field name to its snake_case INI key. Acronym runs break +// at the last capital before a lower-case letter, so WebUILang -> web_ui_lang +// and NoVersionPartial -> no_version_partial. +func iniKey(name string) string { + runes := []rune(name) + var b strings.Builder + for i, r := range runes { + if unicode.IsUpper(r) { + if i > 0 && (unicode.IsLower(runes[i-1]) || + (i+1 < len(runes) && unicode.IsLower(runes[i+1]))) { + b.WriteByte('_') + } + b.WriteRune(unicode.ToLower(r)) + } else { + b.WriteRune(r) + } + } + return b.String() +} + +// unquote strips a single pair of surrounding double quotes (used to preserve +// edge whitespace through the INI reader's trimming). +func unquote(s string) string { + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + return s[1 : len(s)-1] + } + return s +} + +// applyUI overlays the strings parsed from one language INI onto u, setting only +// the fields the file provides -- so a partial user file overrides just those +// keys and everything else keeps the English baseline. +func applyUI(u *UI, data []byte) { + secs, err := ini.Parse(data) + if err != nil { + return + } + flat := map[string]string{} + nested := map[string]map[string]string{} // fieldKey -> mapKey -> value + for _, s := range secs { + for _, p := range s.Pairs { + if i := strings.IndexByte(p.Key, '.'); i >= 0 { + fk, mk := p.Key[:i], p.Key[i+1:] + if nested[fk] == nil { + nested[fk] = map[string]string{} + } + nested[fk][mk] = unquote(p.Val) + } else { + flat[p.Key] = unquote(p.Val) + } + } + } + v := reflect.ValueOf(u).Elem() + t := v.Type() + for i := 0; i < t.NumField(); i++ { + key := iniKey(t.Field(i).Name) + fv := v.Field(i) + switch fv.Kind() { + case reflect.String: + if val, ok := flat[key]; ok { + fv.SetString(val) + } + case reflect.Map: + if m, ok := nested[key]; ok { + if fv.IsNil() { + fv.Set(reflect.MakeMap(fv.Type())) + } + for mk, mv := range m { + fv.SetMapIndex(reflect.ValueOf(mk), reflect.ValueOf(mv)) + } + } + case reflect.Array: // Months [12]string + if val, ok := flat[key]; ok { + parts := strings.Split(val, ",") + for j := 0; j < fv.Len() && j < len(parts); j++ { + fv.Index(j).SetString(strings.TrimSpace(parts[j])) + } + } + } + } +} -- cgit v1.3