diff options
Diffstat (limited to 'internal/i18n/lang.go')
| -rw-r--r-- | internal/i18n/lang.go | 155 |
1 files changed, 155 insertions, 0 deletions
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 +// (<dir>/<code>.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/<code>.ini (Polish ships; other codes fall through), overlaid +// by the user's <dir>/<code>.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/<code>.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/<code>.ini files (generated from the Go tables, see the +// gen test), and any language is user-overridable at <config>/ui/<code>.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])) + } + } + } + } +} |
