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])) } } } } }