go-i18n/loader.go

474 lines
12 KiB
Go
Raw Normal View History

package i18n
import (
"errors"
"io/fs"
"math"
"path"
"slices"
"sync"
"dappco.re/go/core"
log "dappco.re/go/core/log"
)
// FSLoader loads translations from a filesystem (embedded or disk).
type FSLoader struct {
fsys fs.FS
dir string
languages []string
langOnce sync.Once
langErr error
}
// NewFSLoader creates a loader for the given filesystem and directory.
func NewFSLoader(fsys fs.FS, dir string) *FSLoader {
return &FSLoader{fsys: fsys, dir: dir}
}
// Load implements Loader.Load.
func (l *FSLoader) Load(lang string) (map[string]Message, *GrammarData, error) {
variants := localeFilenameCandidates(lang)
var data []byte
var err error
var firstNonMissingErr error
for _, filename := range variants {
filePath := path.Join(l.dir, filename)
data, err = fs.ReadFile(l.fsys, filePath)
if err == nil {
break
}
if firstNonMissingErr == nil && !errors.Is(err, fs.ErrNotExist) {
firstNonMissingErr = err
}
}
if err != nil {
if firstNonMissingErr != nil {
err = firstNonMissingErr
}
return nil, nil, log.E("FSLoader.Load", "locale not found: "+lang, err)
}
var raw map[string]any
if r := core.JSONUnmarshal(data, &raw); !r.OK {
return nil, nil, log.E("FSLoader.Load", "invalid JSON in locale: "+lang, r.Value.(error))
}
messages := make(map[string]Message)
grammar := &GrammarData{
Verbs: make(map[string]VerbForms),
Nouns: make(map[string]NounForms),
Words: make(map[string]string),
}
flattenWithGrammar("", raw, messages, grammar)
return messages, grammar, nil
}
func localeFilenameCandidates(lang string) []string {
// Preserve the documented lookup order: exact tag first, then underscore /
// hyphen variants, then the base language tag.
variants := make([]string, 0, 4)
addVariant := func(candidate string) {
for _, existing := range variants {
if existing == candidate {
return
}
}
variants = append(variants, candidate)
}
canonical := normalizeLanguageTag(lang)
addTag := func(tag string) {
if tag == "" {
return
}
addVariant(tag + ".json")
addVariant(core.Replace(tag, "-", "_") + ".json")
addVariant(core.Replace(tag, "_", "-") + ".json")
}
addTag(lang)
if canonical != "" && canonical != lang {
addTag(canonical)
}
if base := baseLanguageTag(canonical); base != "" && base != canonical {
addTag(base)
}
return variants
}
// Languages implements Loader.Languages.
func (l *FSLoader) Languages() []string {
l.langOnce.Do(func() {
entries, err := fs.ReadDir(l.fsys, l.dir)
if err != nil {
l.langErr = log.E("FSLoader.Languages", "read locale directory: "+l.dir, err)
return
}
seen := make(map[string]struct{}, len(entries))
for _, entry := range entries {
if entry.IsDir() || !core.HasSuffix(entry.Name(), ".json") {
continue
}
lang := core.TrimSuffix(entry.Name(), ".json")
lang = normalizeLanguageTag(core.Replace(lang, "_", "-"))
if lang == "" {
continue
}
if _, ok := seen[lang]; ok {
continue
}
seen[lang] = struct{}{}
l.languages = append(l.languages, lang)
}
slices.Sort(l.languages)
})
return append([]string(nil), l.languages...)
}
// LanguagesErr returns any error from the directory scan.
func (l *FSLoader) LanguagesErr() error {
l.Languages()
return l.langErr
}
var _ Loader = (*FSLoader)(nil)
// --- Flatten helpers ---
func flatten(prefix string, data map[string]any, out map[string]Message) {
flattenWithGrammar(prefix, data, out, nil)
}
func flattenWithGrammar(prefix string, data map[string]any, out map[string]Message, grammar *GrammarData) {
for key, value := range data {
fullKey := key
if prefix != "" {
fullKey = prefix + "." + key
}
switch v := value.(type) {
case string:
if grammar != nil && loadGrammarWord(fullKey, v, grammar) {
continue
}
out[fullKey] = Message{Text: v}
case map[string]any:
if grammar != nil && loadGrammarVerb(fullKey, key, v, grammar) {
continue
}
if grammar != nil && loadGrammarNoun(fullKey, key, v, grammar) {
continue
}
if grammar != nil && loadGrammarSignals(fullKey, v, grammar) {
continue
}
if grammar != nil && loadGrammarArticle(fullKey, v, grammar) {
continue
}
if grammar != nil && loadGrammarPunctuation(fullKey, v, grammar) {
continue
}
if grammar != nil && loadGrammarNumber(fullKey, v, grammar) {
continue
}
// CLDR plural object
if isPluralObject(v) {
msg := Message{}
if zero, ok := v["zero"].(string); ok {
msg.Zero = zero
}
if one, ok := v["one"].(string); ok {
msg.One = one
}
if two, ok := v["two"].(string); ok {
msg.Two = two
}
if few, ok := v["few"].(string); ok {
msg.Few = few
}
if many, ok := v["many"].(string); ok {
msg.Many = many
}
if other, ok := v["other"].(string); ok {
msg.Other = other
}
out[fullKey] = msg
} else {
flattenWithGrammar(fullKey, v, out, grammar)
}
}
}
}
func loadGrammarWord(fullKey, value string, grammar *GrammarData) bool {
if grammar == nil || !core.HasPrefix(fullKey, "gram.word.") {
return false
}
wordKey := core.TrimPrefix(fullKey, "gram.word.")
if shouldSkipDeprecatedEnglishGrammarEntry(fullKey) {
return true
}
grammar.Words[core.Lower(wordKey)] = value
return true
}
func loadGrammarVerb(fullKey, key string, v map[string]any, grammar *GrammarData) bool {
if grammar == nil || !isVerbFormObject(v) {
return false
}
verbName := key
if base, ok := v["base"].(string); ok && base != "" {
verbName = base
}
if core.HasPrefix(fullKey, "gram.verb.") {
after := core.TrimPrefix(fullKey, "gram.verb.")
if base, ok := v["base"].(string); !ok || base == "" {
verbName = after
}
}
forms := VerbForms{}
if past, ok := v["past"].(string); ok {
forms.Past = past
}
if gerund, ok := v["gerund"].(string); ok {
forms.Gerund = gerund
}
grammar.Verbs[core.Lower(verbName)] = forms
return true
}
func loadGrammarNoun(fullKey, key string, v map[string]any, grammar *GrammarData) bool {
if grammar == nil || !(core.HasPrefix(fullKey, "gram.noun.") || isNounFormObject(v)) {
return false
}
nounName := key
if core.HasPrefix(fullKey, "gram.noun.") {
nounName = core.TrimPrefix(fullKey, "gram.noun.")
}
if shouldSkipDeprecatedEnglishGrammarEntry(fullKey) {
return true
}
_, hasOne := v["one"]
_, hasOther := v["other"]
if !hasOne || !hasOther {
return false
}
forms := NounForms{}
if one, ok := v["one"].(string); ok {
forms.One = one
}
if other, ok := v["other"].(string); ok {
forms.Other = other
}
if gender, ok := v["gender"].(string); ok {
forms.Gender = gender
}
grammar.Nouns[core.Lower(nounName)] = forms
return true
}
func loadGrammarSignals(fullKey string, v map[string]any, grammar *GrammarData) bool {
2026-04-03 07:51:09 +00:00
if grammar == nil || (fullKey != "gram.signal" && fullKey != "gram.signals") {
return false
}
loadSignalStringList := func(dst *[]string, raw any) {
arr, ok := raw.([]any)
if !ok {
return
}
for _, item := range arr {
if s, ok := item.(string); ok {
*dst = append(*dst, core.Lower(s))
}
}
}
loadSignalStringList(&grammar.Signals.NounDeterminers, v["noun_determiner"])
loadSignalStringList(&grammar.Signals.VerbAuxiliaries, v["verb_auxiliary"])
loadSignalStringList(&grammar.Signals.VerbInfinitive, v["verb_infinitive"])
loadSignalStringList(&grammar.Signals.VerbNegation, v["verb_negation"])
if priors, ok := v["prior"].(map[string]any); ok {
loadSignalPriors(grammar, priors)
}
if priors, ok := v["priors"].(map[string]any); ok {
loadSignalPriors(grammar, priors)
}
return true
}
func loadGrammarArticle(fullKey string, v map[string]any, grammar *GrammarData) bool {
if grammar == nil || fullKey != "gram.article" {
return false
}
if indef, ok := v["indefinite"].(map[string]any); ok {
if def, ok := indef["default"].(string); ok {
grammar.Articles.IndefiniteDefault = def
}
if vowel, ok := indef["vowel"].(string); ok {
grammar.Articles.IndefiniteVowel = vowel
}
}
if def, ok := v["definite"].(string); ok {
grammar.Articles.Definite = def
}
if bg, ok := v["by_gender"].(map[string]any); ok {
grammar.Articles.ByGender = make(map[string]string, len(bg))
for g, art := range bg {
if s, ok := art.(string); ok {
grammar.Articles.ByGender[g] = s
}
}
}
2026-04-03 07:51:09 +00:00
if bg, ok := v["byGender"].(map[string]any); ok {
grammar.Articles.ByGender = make(map[string]string, len(bg))
for g, art := range bg {
if s, ok := art.(string); ok {
grammar.Articles.ByGender[g] = s
}
}
}
return true
}
func loadGrammarPunctuation(fullKey string, v map[string]any, grammar *GrammarData) bool {
if grammar == nil || fullKey != "gram.punct" {
return false
}
if label, ok := v["label"].(string); ok {
grammar.Punct.LabelSuffix = label
}
if progress, ok := v["progress"].(string); ok {
grammar.Punct.ProgressSuffix = progress
}
return true
}
func loadGrammarNumber(fullKey string, v map[string]any, grammar *GrammarData) bool {
if grammar == nil || fullKey != "gram.number" {
return false
}
if thousands, ok := v["thousands"].(string); ok {
grammar.Number.ThousandsSep = thousands
}
if decimal, ok := v["decimal"].(string); ok {
grammar.Number.DecimalSep = decimal
}
if percent, ok := v["percent"].(string); ok {
grammar.Number.PercentFmt = percent
}
return true
}
func isVerbFormObject(m map[string]any) bool {
_, hasPast := m["past"]
_, hasGerund := m["gerund"]
// Verb objects are identified by their inflected forms. A bare "base"
// field is metadata, not enough to claim the object is a verb table.
return (hasPast || hasGerund) && !isPluralObject(m)
}
func isNounFormObject(m map[string]any) bool {
_, hasGender := m["gender"]
return hasGender
}
func isPluralObject(m map[string]any) bool {
_, hasZero := m["zero"]
_, hasOne := m["one"]
_, hasTwo := m["two"]
_, hasFew := m["few"]
_, hasMany := m["many"]
_, hasOther := m["other"]
if !hasZero && !hasOne && !hasTwo && !hasFew && !hasMany && !hasOther {
return false
}
for _, v := range m {
if _, isMap := v.(map[string]any); isMap {
return false
}
}
return true
}
func loadSignalPriors(grammar *GrammarData, priors map[string]any) {
if grammar == nil || len(priors) == 0 {
return
}
if grammar.Signals.Priors == nil {
grammar.Signals.Priors = make(map[string]map[string]float64, len(priors))
}
for word, raw := range priors {
bucket, ok := raw.(map[string]any)
if !ok || len(bucket) == 0 {
continue
}
key := core.Lower(word)
if grammar.Signals.Priors[key] == nil {
grammar.Signals.Priors[key] = make(map[string]float64, len(bucket))
}
for role, value := range bucket {
score, ok := float64Value(value)
if !ok || !validSignalPriorScore(score) {
continue
}
grammar.Signals.Priors[key][core.Lower(role)] = score
}
}
}
func validSignalPriorScore(score float64) bool {
return !math.IsNaN(score) && !math.IsInf(score, 0) && score >= 0
}
func float64Value(v any) (float64, bool) {
if v == nil {
return 0, false
}
switch n := v.(type) {
case float64:
return n, true
case float32:
return float64(n), true
case int:
return float64(n), true
case int64:
return float64(n), true
case int32:
return float64(n), true
case int16:
return float64(n), true
case int8:
return float64(n), true
case uint:
return float64(n), true
case uint64:
return float64(n), true
case uint32:
return float64(n), true
case uint16:
return float64(n), true
case uint8:
return float64(n), true
default:
return 0, false
}
}
func shouldSkipDeprecatedEnglishGrammarEntry(fullKey string) bool {
switch fullKey {
case "gram.noun.passed", "gram.noun.failed", "gram.noun.skipped",
"gram.word.passed", "gram.word.failed", "gram.word.skipped":
return true
default:
return false
}
}