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