go-i18n/loader.go
Virgil eeffe92da0
Some checks failed
Security Scan / security (push) Successful in 12s
Test / test (push) Has been cancelled
fix(i18n): ignore deprecated grammar entries
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 03:03:44 +00:00

356 lines
9 KiB
Go

package i18n
import (
"io/fs"
"path"
"strings"
"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 := []string{
lang + ".json",
core.Replace(lang, "-", "_") + ".json",
core.Replace(lang, "_", "-") + ".json",
}
var data []byte
var err error
for _, filename := range variants {
filePath := path.Join(l.dir, filename)
data, err = fs.ReadFile(l.fsys, filePath)
if err == nil {
break
}
}
if err != nil {
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
}
// 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
}
for _, entry := range entries {
if entry.IsDir() || !core.HasSuffix(entry.Name(), ".json") {
continue
}
lang := core.TrimSuffix(entry.Name(), ".json")
lang = core.Replace(lang, "_", "-")
l.languages = append(l.languages, lang)
}
})
return 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 && core.HasPrefix(fullKey, "gram.word.") {
wordKey := core.TrimPrefix(fullKey, "gram.word.")
if shouldSkipDeprecatedEnglishGrammarEntry(fullKey) {
continue
}
grammar.Words[core.Lower(wordKey)] = v
continue
}
out[fullKey] = Message{Text: v}
case map[string]any:
// Verb form object (has base/past/gerund keys)
if grammar != nil && isVerbFormObject(v) {
verbName := key
if base, ok := v["base"].(string); ok && base != "" {
verbName = base
}
if after, ok := strings.CutPrefix(fullKey, "gram.verb."); ok {
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
continue
}
// Noun form object (under gram.noun.* or has gender field)
if grammar != nil && (core.HasPrefix(fullKey, "gram.noun.") || isNounFormObject(v)) {
nounName := key
if after, ok := strings.CutPrefix(fullKey, "gram.noun."); ok {
nounName = after
}
if shouldSkipDeprecatedEnglishGrammarEntry(fullKey) {
continue
}
_, hasOne := v["one"]
_, hasOther := v["other"]
if hasOne && hasOther {
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
continue
}
}
// Signal data for disambiguation
if grammar != nil && fullKey == "gram.signal" {
if nd, ok := v["noun_determiner"]; ok {
if arr, ok := nd.([]any); ok {
for _, item := range arr {
if s, ok := item.(string); ok {
grammar.Signals.NounDeterminers = append(grammar.Signals.NounDeterminers, core.Lower(s))
}
}
}
}
if va, ok := v["verb_auxiliary"]; ok {
if arr, ok := va.([]any); ok {
for _, item := range arr {
if s, ok := item.(string); ok {
grammar.Signals.VerbAuxiliaries = append(grammar.Signals.VerbAuxiliaries, core.Lower(s))
}
}
}
}
if vi, ok := v["verb_infinitive"]; ok {
if arr, ok := vi.([]any); ok {
for _, item := range arr {
if s, ok := item.(string); ok {
grammar.Signals.VerbInfinitive = append(grammar.Signals.VerbInfinitive, core.Lower(s))
}
}
}
}
if vn, ok := v["verb_negation"]; ok {
if arr, ok := vn.([]any); ok {
for _, item := range arr {
if s, ok := item.(string); ok {
grammar.Signals.VerbNegation = append(grammar.Signals.VerbNegation, core.Lower(s))
}
}
}
}
if priors, ok := v["prior"].(map[string]any); ok {
loadSignalPriors(grammar, priors)
}
if priors, ok := v["priors"].(map[string]any); ok {
loadSignalPriors(grammar, priors)
}
continue
}
// Article configuration
if grammar != nil && fullKey == "gram.article" {
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
}
}
}
continue
}
// Punctuation rules
if grammar != nil && fullKey == "gram.punct" {
if label, ok := v["label"].(string); ok {
grammar.Punct.LabelSuffix = label
}
if progress, ok := v["progress"].(string); ok {
grammar.Punct.ProgressSuffix = progress
}
continue
}
// Number formatting rules
if grammar != nil && fullKey == "gram.number" {
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
}
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 isVerbFormObject(m map[string]any) bool {
_, hasBase := m["base"]
_, hasPast := m["past"]
_, hasGerund := m["gerund"]
return (hasBase || 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 {
if score := toFloat64(value); score != 0 {
grammar.Signals.Priors[key][core.Lower(role)] = score
}
}
}
}
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
}
}