package phrasebook import ( "context" "fmt" "log/slog" "strconv" "strings" "sync" "unicode" "unicode/utf8" "git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/towel" "github.com/nicksnyder/go-i18n/v2/i18n" "github.com/nicksnyder/go-i18n/v2/i18n/template" "golang.org/x/text/language" ) const ( defaultLocale = "en" defaultFallback = "en" ) var ( bundleCache sync.Map // locale -> *i18n.Bundle categoryCache sync.Map // locale -> []string identityParse = template.IdentityParser{} pluralSamples = []any{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, 100, 101, 102, 111, 1000, 1000000, "0.0", "0.1", "0.5", "1.0", "1.1", "1.5", "2.0", "2.5", "10.0", "100.0", } categoryMessage = &i18n.Message{ ID: "phrasebook.plural.category", Zero: "zero", One: "one", Two: "two", Few: "few", Many: "many", Other: "other", } ) // Options configures an app-scoped translator. type Options struct { Locale string Fallback string Production bool } // Translator resolves namespaced translation keys from a loaded catalog. type Translator struct { cat *Catalog locale string fallback string production bool missing sync.Map } // NewTranslator returns a translator that reads from cat. Empty locale or // fallback values become the framework defaults en/en. func NewTranslator(cat *Catalog, opts Options) *Translator { if cat == nil { cat = NewCatalog() } locale := strings.TrimSpace(opts.Locale) if locale == "" { locale = defaultLocale } fallback := strings.TrimSpace(opts.Fallback) if fallback == "" { fallback = defaultFallback } return &Translator{ cat: cat, locale: locale, fallback: fallback, production: opts.Production, } } // Activate loads HasLang catalogs in plugin order, then publishes one // Translator on app before Boot. app.locale and app.fallback_locale default to en. func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error { if app == nil { return fmt.Errorf("phrasebook: app is nil") } cat := NewCatalog() if err := cat.Load("lagoon", systemLangFS); err != nil { return err } for _, p := range plugins { hl, ok := any(p).(pact.HasLang) if !ok { continue } if err := cat.Load(p.ID(), hl.LangFS()); err != nil { return err } } opts := Options{Locale: defaultLocale, Fallback: defaultFallback} if app.Config != nil { if v := strings.TrimSpace(app.Config.String("app.locale")); v != "" { opts.Locale = v } if v := strings.TrimSpace(app.Config.String("app.fallback_locale")); v != "" { opts.Fallback = v } opts.Production = app.Config.Environment() == "production" } if err := app.Publish(NewTranslator(cat, opts)); err != nil { return fmt.Errorf("phrasebook: %w", err) } return nil } // Get returns the translation for key using the request locale when present, // otherwise the configured app locale. func (t *Translator) Get(ctx context.Context, key string, params map[string]string) string { return t.GetIn(t.localeFrom(ctx), key, params) } // GetIn looks up key in an explicit locale. func (t *Translator) GetIn(locale, key string, params map[string]string) string { if t == nil { return key } e, _, ok := t.find(locale, key) if !ok { return key } text := e.text if text == "" && e.plurals != nil { text = e.plurals["other"] } return interpolate(text, params) } // Choice selects a plural form for key using CLDR rules or Laravel pipe conditions. func (t *Translator) Choice(ctx context.Context, key string, count any, params map[string]string) string { return t.ChoiceIn(t.localeFrom(ctx), key, count, params) } // ChoiceIn is the explicit-locale form of Choice. func (t *Translator) ChoiceIn(locale, key string, count any, params map[string]string) string { if t == nil { return key } e, found, ok := t.find(locale, key) if !ok { return key } text := e.text switch { case len(e.pipes) > 0: text = selectPipe(e.pipes, found, count) case e.plurals != nil: text = selectMap(e.plurals, found, count) } return interpolate(text, withCount(params, count)) } func (t *Translator) find(locale, key string) (entry, string, bool) { if t == nil { return entry{}, locale, false } fallback := defaultFallback if t.fallback != "" { fallback = t.fallback } for _, step := range fallbackChain(locale, fallback) { if e, ok := t.cat.lookup(step, key); ok { return e, step, true } } t.logMissing(key) return entry{}, locale, false } func (t *Translator) logMissing(key string) { if t.production { return } if _, loaded := t.missing.LoadOrStore(key, struct{}{}); loaded { return } slog.Warn("phrasebook: missing translation key", "key", key) } func fallbackChain(requested, fallback string) []string { out := make([]string, 0, 4) seen := make(map[string]struct{}, 4) add := func(s string) { s = strings.TrimSpace(s) if s == "" { return } if _, ok := seen[s]; ok { return } seen[s] = struct{}{} out = append(out, s) } add(requested) if tag, err := language.Parse(requested); err == nil { add(tag.String()) for p := tag.Parent(); !p.IsRoot(); p = p.Parent() { add(p.String()) } } else if i := strings.IndexAny(requested, "-_"); i > 0 { add(requested[:i]) } add(fallback) return out } func selectMap(plurals map[string]string, locale string, count any) string { cat, err := cldrCategory(locale, count) if err != nil { return plurals["other"] } if s, ok := plurals[cat]; ok { return s } return plurals["other"] } func selectPipe(parts []pipePart, locale string, count any) string { if n, ok := asFloat(count); ok { for _, p := range parts { if p.exact != nil && n == *p.exact { return p.text } if p.lo != nil && n >= *p.lo && (p.hi == nil || n <= *p.hi) { return p.text } } } cat, err := cldrCategory(locale, count) if err != nil { cat = "other" } var other, last string for _, p := range parts { last = p.text if p.cat == cat { return p.text } if p.cat == "other" { other = p.text } } if other != "" { return other } return last } func cldrCategory(locale string, count any) (string, error) { if strings.TrimSpace(locale) == "" { locale = defaultLocale } loc := i18n.NewLocalizer(bundleFor(locale), locale) return loc.Localize(&i18n.LocalizeConfig{ DefaultMessage: categoryMessage, PluralCount: coercePluralCount(count), TemplateParser: identityParse, }) } func bundleFor(locale string) *i18n.Bundle { if v, ok := bundleCache.Load(locale); ok { return v.(*i18n.Bundle) } tag, err := language.Parse(locale) if err != nil { tag = language.English } b := i18n.NewBundle(tag) actual, _ := bundleCache.LoadOrStore(locale, b) return actual.(*i18n.Bundle) } func coercePluralCount(count any) any { switch count.(type) { case float32, float64: return formatCount(count) default: return count } } func cldrCategories(locale string) ([]string, map[string]struct{}, error) { if v, ok := categoryCache.Load(locale); ok { ordered := v.([]string) return ordered, setFrom(ordered), nil } seen := make(map[string]struct{}) for _, n := range pluralSamples { cat, err := cldrCategory(locale, n) if err != nil { return nil, nil, err } seen[cat] = struct{}{} } seen["other"] = struct{}{} ordered := make([]string, 0, len(cldrOrder)) for _, cat := range cldrOrder { if _, ok := seen[cat]; ok { ordered = append(ordered, cat) } } categoryCache.Store(locale, ordered) return ordered, seen, nil } func cldrCategorySet(locale string) (map[string]struct{}, error) { _, set, err := cldrCategories(locale) return set, err } func setFrom(vals []string) map[string]struct{} { out := make(map[string]struct{}, len(vals)) for _, v := range vals { out[v] = struct{}{} } return out } func withCount(params map[string]string, count any) map[string]string { out := make(map[string]string, len(params)+1) for k, v := range params { out[k] = v } if _, ok := out["count"]; !ok { out["count"] = formatCount(count) } return out } func formatCount(count any) string { switch v := count.(type) { case int: return strconv.Itoa(v) case int8: return strconv.FormatInt(int64(v), 10) case int16: return strconv.FormatInt(int64(v), 10) case int32: return strconv.FormatInt(int64(v), 10) case int64: return strconv.FormatInt(v, 10) case uint: return strconv.FormatUint(uint64(v), 10) case uint64: return strconv.FormatUint(v, 10) case float32: return formatFloat(float64(v)) case float64: return formatFloat(v) default: return fmt.Sprint(v) } } func formatFloat(v float64) string { if v == float64(int64(v)) { return strconv.FormatInt(int64(v), 10) } return strconv.FormatFloat(v, 'f', -1, 64) } func asFloat(count any) (float64, bool) { switch v := count.(type) { case int: return float64(v), true case int8: return float64(v), true case int16: return float64(v), true case int32: return float64(v), true case int64: return float64(v), true case uint: return float64(v), true case uint64: return float64(v), true case float32: return float64(v), true case float64: return v, true default: return 0, false } } func (t *Translator) localeFrom(ctx context.Context) string { if loc, ok := towel.Locale(ctx); ok { if loc = strings.TrimSpace(loc); loc != "" { return loc } } if t == nil || t.locale == "" { return defaultLocale } return t.locale } func interpolate(text string, params map[string]string) string { if text == "" || len(params) == 0 { return text } type pair struct { placeholder string value string } pairs := make([]pair, 0, len(params)*3) for name, value := range params { name = strings.TrimPrefix(name, ":") if name == "" { continue } pairs = append(pairs, pair{placeholder: ":" + ucfirst(name), value: ucfirst(value)}, pair{placeholder: ":" + strings.ToUpper(name), value: strings.ToUpper(value)}, pair{placeholder: ":" + name, value: value}, ) } for i := 1; i < len(pairs); i++ { j := i for j > 0 && len(pairs[j].placeholder) > len(pairs[j-1].placeholder) { pairs[j], pairs[j-1] = pairs[j-1], pairs[j] j-- } } for _, p := range pairs { text = strings.ReplaceAll(text, p.placeholder, p.value) } return text } func ucfirst(s string) string { if s == "" { return s } r, size := utf8.DecodeRuneInString(s) if r == utf8.RuneError && size == 1 { return s } return string(unicode.ToUpper(r)) + s[size:] }