2026-02-16 19:51:27 +00:00
|
|
|
package i18n
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"embed"
|
|
|
|
|
"io/fs"
|
2026-02-22 21:00:16 +00:00
|
|
|
"maps"
|
2026-04-02 00:32:16 +00:00
|
|
|
"reflect"
|
2026-02-22 21:00:16 +00:00
|
|
|
"slices"
|
2026-04-02 02:02:46 +00:00
|
|
|
"strings"
|
2026-02-16 19:51:27 +00:00
|
|
|
"sync"
|
|
|
|
|
"sync/atomic"
|
2026-04-02 02:02:46 +00:00
|
|
|
"unicode"
|
2026-02-16 19:51:27 +00:00
|
|
|
|
2026-03-26 14:11:15 +00:00
|
|
|
"dappco.re/go/core"
|
2026-03-21 23:49:15 +00:00
|
|
|
log "dappco.re/go/core/log"
|
2026-02-16 19:51:27 +00:00
|
|
|
"golang.org/x/text/language"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Service provides grammar-aware internationalisation.
|
2026-04-03 07:35:27 +00:00
|
|
|
//
|
|
|
|
|
// svc, err := i18n.New()
|
|
|
|
|
// i18n.SetDefault(svc)
|
2026-02-16 19:51:27 +00:00
|
|
|
type Service struct {
|
2026-04-01 23:06:02 +00:00
|
|
|
loader Loader
|
|
|
|
|
messages map[string]map[string]Message // lang -> key -> message
|
|
|
|
|
currentLang string
|
|
|
|
|
fallbackLang string
|
2026-04-02 09:36:34 +00:00
|
|
|
requestedLang string
|
2026-04-01 23:06:02 +00:00
|
|
|
languageExplicit bool
|
|
|
|
|
availableLangs []language.Tag
|
|
|
|
|
mode Mode
|
|
|
|
|
debug bool
|
|
|
|
|
formality Formality
|
2026-04-02 00:03:23 +00:00
|
|
|
location string
|
2026-04-01 23:06:02 +00:00
|
|
|
handlers []KeyHandler
|
2026-04-02 06:03:18 +00:00
|
|
|
loadedLocales map[int]struct{}
|
|
|
|
|
loadedProviders map[int]struct{}
|
2026-04-01 23:06:02 +00:00
|
|
|
mu sync.RWMutex
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Option configures a Service during construction.
|
2026-04-03 07:35:27 +00:00
|
|
|
//
|
|
|
|
|
// svc, err := i18n.New(i18n.WithLanguage("en"))
|
2026-02-16 19:51:27 +00:00
|
|
|
type Option func(*Service)
|
|
|
|
|
|
|
|
|
|
// WithFallback sets the fallback language for missing translations.
|
|
|
|
|
func WithFallback(lang string) Option {
|
2026-04-02 06:17:36 +00:00
|
|
|
return func(s *Service) { s.fallbackLang = normalizeLanguageTag(lang) }
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 09:36:34 +00:00
|
|
|
// WithLanguage sets an explicit initial language for the service.
|
|
|
|
|
//
|
|
|
|
|
// The language is applied after the loader has populated the available
|
|
|
|
|
// languages, so it can resolve to the best supported tag instead of failing
|
|
|
|
|
// during option construction.
|
|
|
|
|
func WithLanguage(lang string) Option {
|
|
|
|
|
return func(s *Service) { s.requestedLang = normalizeLanguageTag(lang) }
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 19:51:27 +00:00
|
|
|
// WithFormality sets the default formality level.
|
|
|
|
|
func WithFormality(f Formality) Option {
|
|
|
|
|
return func(s *Service) { s.formality = f }
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 00:03:23 +00:00
|
|
|
// WithLocation sets the default location context.
|
|
|
|
|
func WithLocation(location string) Option {
|
|
|
|
|
return func(s *Service) { s.location = location }
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 19:51:27 +00:00
|
|
|
// WithHandlers sets custom handlers (replaces default handlers).
|
|
|
|
|
func WithHandlers(handlers ...KeyHandler) Option {
|
2026-04-02 07:10:49 +00:00
|
|
|
return func(s *Service) {
|
2026-04-02 07:33:52 +00:00
|
|
|
s.handlers = filterNilHandlers(append([]KeyHandler(nil), handlers...))
|
2026-04-02 07:10:49 +00:00
|
|
|
}
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WithDefaultHandlers adds the default i18n.* namespace handlers.
|
|
|
|
|
func WithDefaultHandlers() Option {
|
2026-04-02 00:32:16 +00:00
|
|
|
return func(s *Service) {
|
|
|
|
|
for _, handler := range DefaultHandlers() {
|
|
|
|
|
if hasHandlerType(s.handlers, handler) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
s.handlers = append(s.handlers, handler)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WithMode sets the translation mode.
|
|
|
|
|
func WithMode(m Mode) Option {
|
|
|
|
|
return func(s *Service) { s.mode = m }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WithDebug enables or disables debug mode.
|
|
|
|
|
func WithDebug(enabled bool) Option {
|
|
|
|
|
return func(s *Service) { s.debug = enabled }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
defaultService atomic.Pointer[Service]
|
2026-04-02 05:44:02 +00:00
|
|
|
defaultInitMu sync.Mutex
|
2026-02-16 19:51:27 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
//go:embed locales/*.json
|
|
|
|
|
var localeFS embed.FS
|
|
|
|
|
|
|
|
|
|
var _ Translator = (*Service)(nil)
|
2026-04-02 04:49:33 +00:00
|
|
|
var _ core.Translator = (*Service)(nil)
|
2026-02-16 19:51:27 +00:00
|
|
|
|
|
|
|
|
// New creates a new i18n service with embedded locales.
|
2026-04-02 09:43:45 +00:00
|
|
|
//
|
|
|
|
|
// Example:
|
2026-04-02 09:57:05 +00:00
|
|
|
//
|
|
|
|
|
// svc, err := i18n.New(i18n.WithLanguage("en"))
|
2026-02-16 19:51:27 +00:00
|
|
|
func New(opts ...Option) (*Service, error) {
|
|
|
|
|
return NewWithLoader(NewFSLoader(localeFS, "locales"), opts...)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 09:16:06 +00:00
|
|
|
// NewService creates a new i18n service with embedded locales.
|
|
|
|
|
//
|
2026-04-02 09:43:45 +00:00
|
|
|
// Example:
|
2026-04-02 09:57:05 +00:00
|
|
|
//
|
|
|
|
|
// svc, err := i18n.NewService(i18n.WithFallback("en"))
|
2026-04-02 09:43:45 +00:00
|
|
|
//
|
2026-04-02 09:16:06 +00:00
|
|
|
// This is a named alias for New that keeps the constructor intent explicit
|
|
|
|
|
// for callers that prefer service-oriented naming.
|
|
|
|
|
func NewService(opts ...Option) (*Service, error) {
|
|
|
|
|
return New(opts...)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 19:51:27 +00:00
|
|
|
// NewWithFS creates a new i18n service loading locales from the given filesystem.
|
2026-04-02 09:43:45 +00:00
|
|
|
//
|
|
|
|
|
// Example:
|
2026-04-02 09:57:05 +00:00
|
|
|
//
|
|
|
|
|
// svc, err := i18n.NewWithFS(os.DirFS("."), "locales")
|
2026-02-16 19:51:27 +00:00
|
|
|
func NewWithFS(fsys fs.FS, dir string, opts ...Option) (*Service, error) {
|
|
|
|
|
return NewWithLoader(NewFSLoader(fsys, dir), opts...)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 09:16:06 +00:00
|
|
|
// NewServiceWithFS creates a new i18n service loading locales from the given filesystem.
|
2026-04-02 09:43:45 +00:00
|
|
|
//
|
|
|
|
|
// Example:
|
2026-04-02 09:57:05 +00:00
|
|
|
//
|
|
|
|
|
// svc, err := i18n.NewServiceWithFS(os.DirFS("."), "locales")
|
2026-04-02 09:16:06 +00:00
|
|
|
func NewServiceWithFS(fsys fs.FS, dir string, opts ...Option) (*Service, error) {
|
|
|
|
|
return NewWithFS(fsys, dir, opts...)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 19:51:27 +00:00
|
|
|
// NewWithLoader creates a new i18n service with a custom loader.
|
2026-04-02 09:43:45 +00:00
|
|
|
//
|
|
|
|
|
// Example:
|
2026-04-02 09:57:05 +00:00
|
|
|
//
|
|
|
|
|
// svc, err := i18n.NewWithLoader(loader)
|
2026-02-16 19:51:27 +00:00
|
|
|
func NewWithLoader(loader Loader, opts ...Option) (*Service, error) {
|
2026-04-02 07:07:42 +00:00
|
|
|
if loader == nil {
|
|
|
|
|
return nil, log.E("NewWithLoader", "nil loader", nil)
|
|
|
|
|
}
|
2026-02-16 19:51:27 +00:00
|
|
|
s := &Service{
|
2026-04-02 06:03:18 +00:00
|
|
|
loader: loader,
|
|
|
|
|
messages: make(map[string]map[string]Message),
|
|
|
|
|
fallbackLang: "en",
|
|
|
|
|
handlers: DefaultHandlers(),
|
|
|
|
|
loadedLocales: make(map[int]struct{}),
|
|
|
|
|
loadedProviders: make(map[int]struct{}),
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
|
|
|
|
for _, opt := range opts {
|
|
|
|
|
opt(s)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
langs := loader.Languages()
|
|
|
|
|
if len(langs) == 0 {
|
2026-03-09 08:22:42 +00:00
|
|
|
// Check if the loader exposes a scan error (e.g. FSLoader).
|
|
|
|
|
if el, ok := loader.(interface{ LanguagesErr() error }); ok {
|
|
|
|
|
if langErr := el.LanguagesErr(); langErr != nil {
|
2026-03-17 07:51:29 +00:00
|
|
|
return nil, log.E("NewWithLoader", "no languages available", langErr)
|
2026-03-09 08:22:42 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-17 07:51:29 +00:00
|
|
|
return nil, log.E("NewWithLoader", "no languages available from loader", nil)
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, lang := range langs {
|
|
|
|
|
messages, grammar, err := loader.Load(lang)
|
|
|
|
|
if err != nil {
|
2026-03-17 07:51:29 +00:00
|
|
|
return nil, log.E("NewWithLoader", "load locale: "+lang, err)
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
2026-04-02 06:31:35 +00:00
|
|
|
lang = normalizeLanguageTag(lang)
|
2026-04-02 09:57:05 +00:00
|
|
|
s.ingestLocaleData(lang, messages, grammar)
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if detected := detectLanguage(s.availableLangs); detected != "" {
|
|
|
|
|
s.currentLang = detected
|
|
|
|
|
} else {
|
|
|
|
|
s.currentLang = s.fallbackLang
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 09:36:34 +00:00
|
|
|
if s.requestedLang != "" {
|
|
|
|
|
if err := s.SetLanguage(s.requestedLang); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 19:51:27 +00:00
|
|
|
return s, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 09:16:06 +00:00
|
|
|
// NewServiceWithLoader creates a new i18n service with a custom loader.
|
2026-04-02 09:43:45 +00:00
|
|
|
//
|
|
|
|
|
// Example:
|
2026-04-02 09:57:05 +00:00
|
|
|
//
|
|
|
|
|
// svc, err := i18n.NewServiceWithLoader(loader)
|
2026-04-02 09:16:06 +00:00
|
|
|
func NewServiceWithLoader(loader Loader, opts ...Option) (*Service, error) {
|
|
|
|
|
return NewWithLoader(loader, opts...)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 02:00:12 +00:00
|
|
|
// Init initialises the default global service if none has been set via SetDefault.
|
2026-04-02 09:43:45 +00:00
|
|
|
//
|
|
|
|
|
// Example:
|
2026-04-02 09:57:05 +00:00
|
|
|
//
|
|
|
|
|
// if err := i18n.Init(); err != nil { return err }
|
2026-02-16 19:51:27 +00:00
|
|
|
func Init() error {
|
2026-04-02 05:44:02 +00:00
|
|
|
if defaultService.Load() != nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
defaultInitMu.Lock()
|
|
|
|
|
defer defaultInitMu.Unlock()
|
|
|
|
|
// Re-check after taking the lock so concurrent callers do not create
|
|
|
|
|
// duplicate services.
|
|
|
|
|
if defaultService.Load() != nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
svc, err := New()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
// Register and load any locales queued before initialisation.
|
|
|
|
|
loadRegisteredLocales(svc)
|
|
|
|
|
// CAS prevents overwriting a concurrent SetDefault call that raced between
|
|
|
|
|
// the Load check above and this store.
|
|
|
|
|
if !defaultService.CompareAndSwap(nil, svc) {
|
|
|
|
|
// If a concurrent caller already installed a service, load registered
|
|
|
|
|
// locales into that active default service instead.
|
|
|
|
|
loadRegisteredLocales(defaultService.Load())
|
|
|
|
|
}
|
|
|
|
|
return nil
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Default returns the global i18n service, initialising if needed.
|
2026-04-02 09:43:45 +00:00
|
|
|
//
|
|
|
|
|
// Example:
|
2026-04-02 09:57:05 +00:00
|
|
|
//
|
|
|
|
|
// svc := i18n.Default()
|
|
|
|
|
//
|
2026-03-09 08:22:42 +00:00
|
|
|
// Returns nil if initialisation fails (error is logged).
|
2026-02-16 19:51:27 +00:00
|
|
|
func Default() *Service {
|
2026-03-17 02:00:12 +00:00
|
|
|
if svc := defaultService.Load(); svc != nil {
|
|
|
|
|
return svc
|
|
|
|
|
}
|
2026-03-09 08:22:42 +00:00
|
|
|
if err := Init(); err != nil {
|
2026-03-17 07:51:29 +00:00
|
|
|
log.Error("failed to initialise default service", "err", err)
|
2026-03-09 08:22:42 +00:00
|
|
|
}
|
2026-02-16 19:51:27 +00:00
|
|
|
return defaultService.Load()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SetDefault sets the global i18n service.
|
2026-04-02 09:43:45 +00:00
|
|
|
//
|
|
|
|
|
// Example:
|
2026-04-02 09:57:05 +00:00
|
|
|
//
|
|
|
|
|
// i18n.SetDefault(svc)
|
|
|
|
|
//
|
2026-03-09 08:22:42 +00:00
|
|
|
// Passing nil clears the default service.
|
2026-02-16 19:51:27 +00:00
|
|
|
func SetDefault(s *Service) {
|
|
|
|
|
defaultService.Store(s)
|
2026-04-02 03:15:00 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
registeredLocalesMu.Lock()
|
2026-04-02 06:03:18 +00:00
|
|
|
hasRegistrations := len(registeredLocales) > 0 || len(registeredLocaleProviders) > 0
|
2026-04-02 03:15:00 +00:00
|
|
|
registeredLocalesMu.Unlock()
|
2026-04-02 06:03:18 +00:00
|
|
|
if hasRegistrations {
|
2026-04-02 03:15:00 +00:00
|
|
|
loadRegisteredLocales(s)
|
|
|
|
|
}
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-17 01:31:32 +00:00
|
|
|
// AddLoader loads translations from a Loader into the default service.
|
2026-04-02 09:43:45 +00:00
|
|
|
//
|
|
|
|
|
// Example:
|
2026-04-02 09:57:05 +00:00
|
|
|
//
|
|
|
|
|
// i18n.AddLoader(loader)
|
|
|
|
|
//
|
2026-03-17 01:31:32 +00:00
|
|
|
// Call this from init() in packages that ship their own locale files:
|
2026-03-17 00:45:14 +00:00
|
|
|
//
|
2026-03-17 01:31:32 +00:00
|
|
|
// //go:embed *.json
|
2026-03-17 00:45:14 +00:00
|
|
|
// var localeFS embed.FS
|
2026-03-17 01:31:32 +00:00
|
|
|
// func init() { i18n.AddLoader(i18n.NewFSLoader(localeFS, ".")) }
|
2026-03-17 14:20:29 +00:00
|
|
|
//
|
|
|
|
|
// Note: When using the Core framework, NewCoreService creates a fresh Service
|
|
|
|
|
// and calls SetDefault, so init-time AddLoader calls are superseded. In that
|
|
|
|
|
// context, packages should implement LocaleProvider instead.
|
2026-03-17 01:31:32 +00:00
|
|
|
func AddLoader(loader Loader) {
|
2026-03-17 00:45:14 +00:00
|
|
|
svc := Default()
|
|
|
|
|
if svc == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-02 08:49:51 +00:00
|
|
|
if err := svc.AddLoader(loader); err != nil {
|
|
|
|
|
log.Error("i18n: AddLoader failed", "err", err)
|
|
|
|
|
}
|
2026-03-17 00:45:14 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-16 19:51:27 +00:00
|
|
|
func (s *Service) loadJSON(lang string, data []byte) error {
|
|
|
|
|
var raw map[string]any
|
2026-03-26 14:11:15 +00:00
|
|
|
if r := core.JSONUnmarshal(data, &raw); !r.OK {
|
|
|
|
|
return r.Value.(error)
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
|
|
|
|
messages := make(map[string]Message)
|
|
|
|
|
grammarData := &GrammarData{
|
|
|
|
|
Verbs: make(map[string]VerbForms),
|
|
|
|
|
Nouns: make(map[string]NounForms),
|
|
|
|
|
Words: make(map[string]string),
|
|
|
|
|
}
|
|
|
|
|
flattenWithGrammar("", raw, messages, grammarData)
|
2026-04-02 09:57:05 +00:00
|
|
|
s.ingestLocaleData(lang, messages, grammarData)
|
2026-02-16 19:51:27 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SetLanguage sets the language for translations.
|
|
|
|
|
func (s *Service) SetLanguage(lang string) error {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return ErrServiceNotInitialised
|
|
|
|
|
}
|
2026-02-16 19:51:27 +00:00
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
2026-04-02 03:11:15 +00:00
|
|
|
lang = normalizeLanguageTag(lang)
|
2026-02-16 19:51:27 +00:00
|
|
|
requestedLang, err := language.Parse(lang)
|
|
|
|
|
if err != nil {
|
2026-03-17 07:51:29 +00:00
|
|
|
return log.E("Service.SetLanguage", "invalid language tag: "+lang, err)
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
|
|
|
|
if len(s.availableLangs) == 0 {
|
2026-03-17 07:51:29 +00:00
|
|
|
return log.E("Service.SetLanguage", "no languages available", nil)
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
|
|
|
|
matcher := language.NewMatcher(s.availableLangs)
|
2026-04-01 23:06:02 +00:00
|
|
|
bestMatch, bestIndex, confidence := matcher.Match(requestedLang)
|
2026-02-16 19:51:27 +00:00
|
|
|
if confidence == language.No {
|
2026-04-02 08:16:00 +00:00
|
|
|
return log.E("Service.SetLanguage", "unsupported language: "+lang+" (available: "+joinAvailableLanguagesLocked(s.availableLangs)+")", nil)
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
2026-04-01 23:06:02 +00:00
|
|
|
if bestIndex >= 0 && bestIndex < len(s.availableLangs) {
|
|
|
|
|
s.currentLang = s.availableLangs[bestIndex].String()
|
|
|
|
|
} else {
|
|
|
|
|
s.currentLang = bestMatch.String()
|
|
|
|
|
}
|
|
|
|
|
s.languageExplicit = true
|
2026-02-16 19:51:27 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) Language() string {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return "en"
|
|
|
|
|
}
|
2026-02-16 19:51:27 +00:00
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
return s.currentLang
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 10:51:20 +00:00
|
|
|
// CurrentLanguage returns the current language tag.
|
|
|
|
|
func (s *Service) CurrentLanguage() string {
|
|
|
|
|
return s.Language()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:48:29 +00:00
|
|
|
// CurrentLang is a short alias for CurrentLanguage.
|
|
|
|
|
func (s *Service) CurrentLang() string {
|
|
|
|
|
return s.CurrentLanguage()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 12:27:21 +00:00
|
|
|
// Prompt translates a prompt key from the prompt namespace using this service.
|
|
|
|
|
func (s *Service) Prompt(key string) string {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
key = normalizeLookupKey(key)
|
|
|
|
|
if key == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return namespaceLookupKey("prompt", key)
|
|
|
|
|
}
|
2026-04-02 12:27:21 +00:00
|
|
|
key = normalizeLookupKey(key)
|
|
|
|
|
if key == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
2026-04-02 13:53:26 +00:00
|
|
|
lookupKey := namespaceLookupKey("prompt", key)
|
|
|
|
|
if text, ok := s.translateWithStatus(lookupKey); ok {
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
return lookupKey
|
2026-04-02 12:27:21 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 12:31:00 +00:00
|
|
|
// CurrentPrompt is a short alias for Prompt.
|
|
|
|
|
func (s *Service) CurrentPrompt(key string) string {
|
|
|
|
|
return s.Prompt(key)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 12:27:21 +00:00
|
|
|
// Lang translates a language label from the lang namespace using this service.
|
|
|
|
|
func (s *Service) Lang(key string) string {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
key = normalizeLookupKey(key)
|
|
|
|
|
if key == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return namespaceLookupKey("lang", key)
|
|
|
|
|
}
|
2026-04-02 12:27:21 +00:00
|
|
|
key = normalizeLookupKey(key)
|
|
|
|
|
if key == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
2026-04-02 13:53:26 +00:00
|
|
|
lookupKey := namespaceLookupKey("lang", key)
|
2026-04-03 07:12:34 +00:00
|
|
|
s.mu.RLock()
|
|
|
|
|
text := s.resolveDirectLocked(lookupKey, nil)
|
|
|
|
|
s.mu.RUnlock()
|
|
|
|
|
if text != "" {
|
2026-04-02 13:53:26 +00:00
|
|
|
return text
|
2026-04-02 12:27:21 +00:00
|
|
|
}
|
|
|
|
|
if idx := indexAny(key, "-_"); idx > 0 {
|
|
|
|
|
if base := key[:idx]; base != "" {
|
2026-04-02 13:53:26 +00:00
|
|
|
baseLookupKey := namespaceLookupKey("lang", base)
|
2026-04-03 07:12:34 +00:00
|
|
|
s.mu.RLock()
|
|
|
|
|
text = s.resolveDirectLocked(baseLookupKey, nil)
|
|
|
|
|
s.mu.RUnlock()
|
|
|
|
|
if text != "" {
|
2026-04-02 13:53:26 +00:00
|
|
|
return text
|
2026-04-02 12:27:21 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-03 07:12:34 +00:00
|
|
|
return s.handleMissingKey(lookupKey, nil)
|
2026-04-02 12:27:21 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-16 19:51:27 +00:00
|
|
|
func (s *Service) AvailableLanguages() []string {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return []string{}
|
|
|
|
|
}
|
2026-02-16 19:51:27 +00:00
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
langs := make([]string, len(s.availableLangs))
|
|
|
|
|
for i, tag := range s.availableLangs {
|
|
|
|
|
langs[i] = tag.String()
|
|
|
|
|
}
|
|
|
|
|
return langs
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 10:51:20 +00:00
|
|
|
// CurrentAvailableLanguages returns the current language tags.
|
|
|
|
|
func (s *Service) CurrentAvailableLanguages() []string {
|
|
|
|
|
return s.AvailableLanguages()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 14:02:13 +00:00
|
|
|
func (s *Service) SetMode(m Mode) {
|
|
|
|
|
if s == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
s.mode = m
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) Mode() Mode {
|
|
|
|
|
if s == nil {
|
|
|
|
|
return ModeNormal
|
|
|
|
|
}
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
return s.mode
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) CurrentMode() Mode { return s.Mode() }
|
|
|
|
|
func (s *Service) SetFormality(f Formality) {
|
|
|
|
|
if s == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
s.formality = f
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) Formality() Formality {
|
|
|
|
|
if s == nil {
|
|
|
|
|
return FormalityNeutral
|
|
|
|
|
}
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
return s.formality
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 10:51:20 +00:00
|
|
|
func (s *Service) CurrentFormality() Formality {
|
|
|
|
|
return s.Formality()
|
|
|
|
|
}
|
2026-04-02 06:31:35 +00:00
|
|
|
func (s *Service) SetFallback(lang string) {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-02 06:31:35 +00:00
|
|
|
s.mu.Lock()
|
|
|
|
|
s.fallbackLang = normalizeLanguageTag(lang)
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
}
|
2026-04-02 04:20:04 +00:00
|
|
|
func (s *Service) Fallback() string {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return "en"
|
|
|
|
|
}
|
2026-04-02 04:20:04 +00:00
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
return s.fallbackLang
|
|
|
|
|
}
|
2026-04-02 10:51:20 +00:00
|
|
|
func (s *Service) CurrentFallback() string { return s.Fallback() }
|
2026-02-16 19:51:27 +00:00
|
|
|
|
2026-04-02 00:03:23 +00:00
|
|
|
func (s *Service) SetLocation(location string) {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-02 00:03:23 +00:00
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
s.location = location
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) Location() string {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
2026-04-02 00:03:23 +00:00
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
return s.location
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 10:51:20 +00:00
|
|
|
// CurrentLocation returns the current default location context.
|
|
|
|
|
func (s *Service) CurrentLocation() string {
|
|
|
|
|
return s.Location()
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 19:51:27 +00:00
|
|
|
func (s *Service) Direction() TextDirection {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return DirLTR
|
|
|
|
|
}
|
2026-02-16 19:51:27 +00:00
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
if IsRTLLanguage(s.currentLang) {
|
|
|
|
|
return DirRTL
|
|
|
|
|
}
|
|
|
|
|
return DirLTR
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 10:51:20 +00:00
|
|
|
// CurrentDirection returns the current text direction.
|
|
|
|
|
func (s *Service) CurrentDirection() TextDirection {
|
|
|
|
|
return s.Direction()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 12:10:30 +00:00
|
|
|
// CurrentTextDirection is a more explicit alias for CurrentDirection.
|
|
|
|
|
func (s *Service) CurrentTextDirection() TextDirection {
|
|
|
|
|
return s.CurrentDirection()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 14:02:13 +00:00
|
|
|
func (s *Service) IsRTL() bool { return s.Direction() == DirRTL }
|
|
|
|
|
func (s *Service) CurrentIsRTL() bool { return s.IsRTL() }
|
2026-04-02 11:37:33 +00:00
|
|
|
|
|
|
|
|
// RTL is a short alias for IsRTL.
|
|
|
|
|
func (s *Service) RTL() bool { return s.IsRTL() }
|
|
|
|
|
|
|
|
|
|
// CurrentRTL is a short alias for CurrentIsRTL.
|
|
|
|
|
func (s *Service) CurrentRTL() bool { return s.CurrentIsRTL() }
|
|
|
|
|
|
2026-04-02 10:51:20 +00:00
|
|
|
func (s *Service) CurrentDebug() bool {
|
|
|
|
|
return s.Debug()
|
|
|
|
|
}
|
2026-02-16 19:51:27 +00:00
|
|
|
|
|
|
|
|
func (s *Service) PluralCategory(n int) PluralCategory {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return PluralOther
|
|
|
|
|
}
|
2026-02-16 19:51:27 +00:00
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
return GetPluralCategory(s.currentLang, n)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 10:51:20 +00:00
|
|
|
// CurrentPluralCategory returns the plural category for the current language.
|
|
|
|
|
func (s *Service) CurrentPluralCategory(n int) PluralCategory {
|
|
|
|
|
return s.PluralCategory(n)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:52:32 +00:00
|
|
|
// PluralCategoryOf is a short alias for CurrentPluralCategory.
|
|
|
|
|
func (s *Service) PluralCategoryOf(n int) PluralCategory {
|
|
|
|
|
return s.CurrentPluralCategory(n)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 08:16:00 +00:00
|
|
|
func joinAvailableLanguagesLocked(tags []language.Tag) string {
|
|
|
|
|
if len(tags) == 0 {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
langs := make([]string, len(tags))
|
|
|
|
|
for i, tag := range tags {
|
|
|
|
|
langs[i] = tag.String()
|
|
|
|
|
}
|
|
|
|
|
slices.Sort(langs)
|
2026-04-02 12:20:39 +00:00
|
|
|
return core.Join(", ", langs...)
|
2026-04-02 08:16:00 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 07:07:14 +00:00
|
|
|
func (s *Service) AddHandler(handlers ...KeyHandler) {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-16 19:51:27 +00:00
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
2026-04-02 07:33:52 +00:00
|
|
|
s.handlers = append(s.handlers, filterNilHandlers(handlers)...)
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 09:40:20 +00:00
|
|
|
// SetHandlers replaces the current handler chain.
|
|
|
|
|
func (s *Service) SetHandlers(handlers ...KeyHandler) {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-02 09:40:20 +00:00
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
s.handlers = filterNilHandlers(handlers)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 07:07:14 +00:00
|
|
|
func (s *Service) PrependHandler(handlers ...KeyHandler) {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-16 19:51:27 +00:00
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
2026-04-02 07:33:52 +00:00
|
|
|
handlers = filterNilHandlers(handlers)
|
2026-04-01 07:07:14 +00:00
|
|
|
if len(handlers) == 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.handlers = append(append([]KeyHandler(nil), handlers...), s.handlers...)
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) ClearHandlers() {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-16 19:51:27 +00:00
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
s.handlers = nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 10:18:13 +00:00
|
|
|
// ResetHandlers restores the built-in default handler chain.
|
|
|
|
|
func (s *Service) ResetHandlers() {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-02 10:18:13 +00:00
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
s.handlers = DefaultHandlers()
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 19:51:27 +00:00
|
|
|
func (s *Service) Handlers() []KeyHandler {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return []KeyHandler{}
|
|
|
|
|
}
|
2026-02-16 19:51:27 +00:00
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
result := make([]KeyHandler, len(s.handlers))
|
|
|
|
|
copy(result, s.handlers)
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 10:51:20 +00:00
|
|
|
// CurrentHandlers returns a copy of the current handler chain.
|
|
|
|
|
func (s *Service) CurrentHandlers() []KeyHandler {
|
|
|
|
|
return s.Handlers()
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 19:51:27 +00:00
|
|
|
// T translates a message by its ID with handler chain support.
|
|
|
|
|
//
|
|
|
|
|
// T("i18n.label.status") // "Status:"
|
|
|
|
|
// T("i18n.progress.build") // "Building..."
|
|
|
|
|
// T("i18n.count.file", 5) // "5 files"
|
|
|
|
|
// T("i18n.done.delete", "file") // "File deleted"
|
|
|
|
|
// T("i18n.fail.delete", "file") // "Failed to delete file"
|
|
|
|
|
func (s *Service) T(messageID string, args ...any) string {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return messageID
|
|
|
|
|
}
|
2026-04-02 13:46:25 +00:00
|
|
|
result, _ := s.translateWithStatus(messageID, args...)
|
2026-02-16 19:51:27 +00:00
|
|
|
s.mu.RLock()
|
2026-04-02 06:10:14 +00:00
|
|
|
debug := s.debug
|
|
|
|
|
s.mu.RUnlock()
|
2026-04-02 13:46:25 +00:00
|
|
|
if debug {
|
|
|
|
|
return debugFormat(messageID, result)
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Translate translates a message by its ID and returns a Core result.
|
|
|
|
|
func (s *Service) Translate(messageID string, args ...any) core.Result {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return core.Result{Value: messageID, OK: false}
|
|
|
|
|
}
|
2026-04-02 13:46:25 +00:00
|
|
|
value, ok := s.translateWithStatus(messageID, args...)
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
debug := s.debug
|
|
|
|
|
s.mu.RUnlock()
|
|
|
|
|
if debug {
|
|
|
|
|
value = debugFormat(messageID, value)
|
|
|
|
|
}
|
|
|
|
|
return core.Result{Value: value, OK: ok}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) translateWithStatus(messageID string, args ...any) (string, bool) {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return messageID, false
|
|
|
|
|
}
|
2026-04-02 13:46:25 +00:00
|
|
|
s.mu.RLock()
|
|
|
|
|
handlers := append([]KeyHandler(nil), s.handlers...)
|
|
|
|
|
s.mu.RUnlock()
|
2026-04-02 06:10:14 +00:00
|
|
|
|
|
|
|
|
result := RunHandlerChain(handlers, messageID, args, func() string {
|
2026-02-16 19:51:27 +00:00
|
|
|
var data any
|
|
|
|
|
if len(args) > 0 {
|
|
|
|
|
data = args[0]
|
|
|
|
|
}
|
2026-04-02 06:10:14 +00:00
|
|
|
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
text := s.resolveWithFallbackLocked(messageID, data)
|
|
|
|
|
s.mu.RUnlock()
|
2026-02-16 19:51:27 +00:00
|
|
|
return text
|
|
|
|
|
})
|
2026-04-02 13:46:25 +00:00
|
|
|
if result != "" {
|
|
|
|
|
return result, true
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
2026-04-02 13:46:25 +00:00
|
|
|
return s.handleMissingKey(messageID, args), false
|
2026-04-02 04:49:33 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 04:41:24 +00:00
|
|
|
// resolveDirect performs exact-key lookup in the current language, its base
|
|
|
|
|
// language tag, and then the configured fallback language.
|
2026-04-02 06:10:14 +00:00
|
|
|
func (s *Service) resolveDirectLocked(messageID string, data any) string {
|
|
|
|
|
if text := s.tryResolveLocked(s.currentLang, messageID, data); text != "" {
|
2026-02-16 19:51:27 +00:00
|
|
|
return text
|
|
|
|
|
}
|
2026-04-02 04:41:24 +00:00
|
|
|
if base := baseLanguageTag(s.currentLang); base != "" && base != s.currentLang {
|
2026-04-02 06:10:14 +00:00
|
|
|
if text := s.tryResolveLocked(base, messageID, data); text != "" {
|
2026-04-02 04:41:24 +00:00
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-02 06:10:14 +00:00
|
|
|
if text := s.tryResolveLocked(s.fallbackLang, messageID, data); text != "" {
|
2026-04-02 05:31:48 +00:00
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
if base := baseLanguageTag(s.fallbackLang); base != "" && base != s.fallbackLang {
|
2026-04-02 06:10:14 +00:00
|
|
|
return s.tryResolveLocked(base, messageID, data)
|
2026-04-02 05:31:48 +00:00
|
|
|
}
|
|
|
|
|
return ""
|
2026-04-01 23:38:35 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 06:10:14 +00:00
|
|
|
func (s *Service) resolveWithFallbackLocked(messageID string, data any) string {
|
|
|
|
|
if text := s.resolveDirectLocked(messageID, data); text != "" {
|
2026-02-16 19:51:27 +00:00
|
|
|
return text
|
|
|
|
|
}
|
2026-03-26 14:11:15 +00:00
|
|
|
if core.Contains(messageID, ".") {
|
|
|
|
|
parts := core.Split(messageID, ".")
|
2026-02-16 19:51:27 +00:00
|
|
|
verb := parts[len(parts)-1]
|
|
|
|
|
commonKey := "common.action." + verb
|
2026-04-02 06:10:14 +00:00
|
|
|
if text := s.resolveDirectLocked(commonKey, data); text != "" {
|
2026-02-16 19:51:27 +00:00
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
commonKey = "common." + verb
|
2026-04-02 06:10:14 +00:00
|
|
|
if text := s.resolveDirectLocked(commonKey, data); text != "" {
|
2026-02-16 19:51:27 +00:00
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 06:10:14 +00:00
|
|
|
func (s *Service) tryResolveLocked(lang, key string, data any) string {
|
2026-04-01 23:19:10 +00:00
|
|
|
context, gender, location, formality := s.getEffectiveContextGenderLocationAndFormality(data)
|
2026-04-02 02:02:46 +00:00
|
|
|
extra := s.getEffectiveContextExtra(data)
|
|
|
|
|
for _, lookupKey := range lookupVariants(key, context, gender, location, formality, extra) {
|
2026-04-02 06:10:14 +00:00
|
|
|
if text := s.resolveMessageLocked(lang, lookupKey, data); text != "" {
|
2026-02-16 19:51:27 +00:00
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-01 06:12:35 +00:00
|
|
|
return ""
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 06:10:14 +00:00
|
|
|
func (s *Service) resolveMessageLocked(lang, key string, data any) string {
|
2026-02-16 19:51:27 +00:00
|
|
|
msg, ok := s.getMessage(lang, key)
|
|
|
|
|
if !ok {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
text := msg.Text
|
|
|
|
|
if msg.IsPlural() {
|
|
|
|
|
count := getCount(data)
|
|
|
|
|
category := GetPluralCategory(lang, count)
|
|
|
|
|
text = msg.ForCategory(category)
|
|
|
|
|
}
|
|
|
|
|
if text == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
if data != nil {
|
|
|
|
|
text = applyTemplate(text, data)
|
|
|
|
|
}
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 23:19:10 +00:00
|
|
|
func (s *Service) getEffectiveContextGenderLocationAndFormality(data any) (string, string, string, Formality) {
|
2026-04-01 06:12:35 +00:00
|
|
|
if ctx, ok := data.(*TranslationContext); ok && ctx != nil {
|
|
|
|
|
formality := ctx.FormalityValue()
|
|
|
|
|
if formality == FormalityNeutral {
|
|
|
|
|
formality = s.formality
|
|
|
|
|
}
|
2026-04-02 06:49:28 +00:00
|
|
|
location := ctx.LocationString()
|
|
|
|
|
if location == "" {
|
|
|
|
|
location = s.location
|
|
|
|
|
}
|
|
|
|
|
return ctx.ContextString(), ctx.GenderString(), location, formality
|
2026-04-01 06:46:21 +00:00
|
|
|
}
|
|
|
|
|
if subj, ok := data.(*Subject); ok && subj != nil {
|
|
|
|
|
formality := subj.formality
|
|
|
|
|
if formality == FormalityNeutral {
|
|
|
|
|
formality = s.formality
|
|
|
|
|
}
|
2026-04-02 06:49:28 +00:00
|
|
|
location := subj.location
|
|
|
|
|
if location == "" {
|
|
|
|
|
location = s.location
|
|
|
|
|
}
|
|
|
|
|
return "", subj.gender, location, formality
|
2026-04-01 06:12:35 +00:00
|
|
|
}
|
|
|
|
|
if m, ok := data.(map[string]any); ok {
|
|
|
|
|
var context string
|
2026-04-01 06:46:21 +00:00
|
|
|
var gender string
|
2026-04-02 02:09:50 +00:00
|
|
|
location := s.location
|
2026-04-01 06:46:21 +00:00
|
|
|
formality := s.formality
|
2026-04-02 03:49:25 +00:00
|
|
|
if v, ok := mapValueString(m, "Context"); ok {
|
|
|
|
|
context = v
|
2026-04-01 06:12:35 +00:00
|
|
|
}
|
2026-04-02 03:49:25 +00:00
|
|
|
if v, ok := mapValueString(m, "Gender"); ok {
|
|
|
|
|
gender = v
|
2026-04-01 06:46:21 +00:00
|
|
|
}
|
2026-04-02 03:49:25 +00:00
|
|
|
if v, ok := mapValueString(m, "Location"); ok {
|
|
|
|
|
location = v
|
2026-04-01 23:19:10 +00:00
|
|
|
}
|
2026-04-02 07:30:15 +00:00
|
|
|
if f, ok := parseFormalityValue(m["Formality"]); ok {
|
|
|
|
|
formality = f
|
2026-04-01 06:12:35 +00:00
|
|
|
}
|
2026-04-01 23:19:10 +00:00
|
|
|
return context, gender, location, formality
|
2026-04-01 06:12:35 +00:00
|
|
|
}
|
2026-04-02 03:49:25 +00:00
|
|
|
if m, ok := data.(map[string]string); ok {
|
|
|
|
|
var context string
|
|
|
|
|
var gender string
|
|
|
|
|
location := s.location
|
|
|
|
|
formality := s.formality
|
|
|
|
|
if v, ok := mapValueString(m, "Context"); ok {
|
|
|
|
|
context = v
|
|
|
|
|
}
|
|
|
|
|
if v, ok := mapValueString(m, "Gender"); ok {
|
|
|
|
|
gender = v
|
|
|
|
|
}
|
|
|
|
|
if v, ok := mapValueString(m, "Location"); ok {
|
|
|
|
|
location = v
|
|
|
|
|
}
|
2026-04-02 07:30:15 +00:00
|
|
|
if f, ok := parseFormalityValue(m["Formality"]); ok {
|
|
|
|
|
formality = f
|
2026-04-02 03:49:25 +00:00
|
|
|
}
|
|
|
|
|
return context, gender, location, formality
|
|
|
|
|
}
|
2026-04-02 00:03:23 +00:00
|
|
|
return "", "", s.location, s.getEffectiveFormality(data)
|
2026-04-01 06:12:35 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 02:02:46 +00:00
|
|
|
func (s *Service) getEffectiveContextExtra(data any) map[string]any {
|
2026-04-02 02:25:45 +00:00
|
|
|
switch v := data.(type) {
|
|
|
|
|
case *TranslationContext:
|
|
|
|
|
if v == nil || len(v.Extra) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return v.Extra
|
|
|
|
|
case map[string]any:
|
2026-04-02 03:49:25 +00:00
|
|
|
return contextMapValues(v)
|
|
|
|
|
case map[string]string:
|
|
|
|
|
return contextMapValues(v)
|
2026-04-02 02:25:45 +00:00
|
|
|
default:
|
2026-04-02 02:02:46 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 03:07:51 +00:00
|
|
|
func mergeContextExtra(dst map[string]any, value any) {
|
|
|
|
|
if dst == nil || value == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
switch extra := value.(type) {
|
|
|
|
|
case map[string]any:
|
|
|
|
|
for key, item := range extra {
|
|
|
|
|
dst[key] = item
|
|
|
|
|
}
|
|
|
|
|
case map[string]string:
|
|
|
|
|
for key, item := range extra {
|
|
|
|
|
dst[key] = item
|
|
|
|
|
}
|
|
|
|
|
case *TranslationContext:
|
|
|
|
|
if extra == nil || len(extra.Extra) == 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
for key, item := range extra.Extra {
|
|
|
|
|
dst[key] = item
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 19:51:27 +00:00
|
|
|
func (s *Service) getEffectiveFormality(data any) Formality {
|
|
|
|
|
if ctx, ok := data.(*TranslationContext); ok && ctx != nil {
|
|
|
|
|
if ctx.Formality != FormalityNeutral {
|
|
|
|
|
return ctx.Formality
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if subj, ok := data.(*Subject); ok && subj != nil {
|
|
|
|
|
if subj.formality != FormalityNeutral {
|
|
|
|
|
return subj.formality
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if m, ok := data.(map[string]any); ok {
|
2026-04-02 07:30:15 +00:00
|
|
|
if f, ok := parseFormalityValue(m["Formality"]); ok {
|
|
|
|
|
return f
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-02 03:49:25 +00:00
|
|
|
if m, ok := data.(map[string]string); ok {
|
2026-04-02 07:30:15 +00:00
|
|
|
if f, ok := parseFormalityValue(m["Formality"]); ok {
|
|
|
|
|
return f
|
2026-04-02 03:49:25 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-16 19:51:27 +00:00
|
|
|
return s.formality
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 07:30:15 +00:00
|
|
|
func parseFormalityValue(value any) (Formality, bool) {
|
|
|
|
|
switch f := value.(type) {
|
|
|
|
|
case Formality:
|
|
|
|
|
if f != FormalityNeutral {
|
|
|
|
|
return f, true
|
|
|
|
|
}
|
|
|
|
|
case string:
|
|
|
|
|
switch core.Lower(f) {
|
|
|
|
|
case "formal":
|
|
|
|
|
return FormalityFormal, true
|
|
|
|
|
case "informal":
|
|
|
|
|
return FormalityInformal, true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return FormalityNeutral, false
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 02:02:46 +00:00
|
|
|
func lookupVariants(key, context, gender, location string, formality Formality, extra map[string]any) []string {
|
2026-04-01 06:12:35 +00:00
|
|
|
var variants []string
|
|
|
|
|
if context != "" {
|
2026-04-01 23:19:10 +00:00
|
|
|
if gender != "" && location != "" && formality != FormalityNeutral {
|
|
|
|
|
variants = append(variants, key+"._"+context+"._"+gender+"._"+location+"._"+formality.String())
|
|
|
|
|
}
|
|
|
|
|
if gender != "" && location != "" {
|
|
|
|
|
variants = append(variants, key+"._"+context+"._"+gender+"._"+location)
|
|
|
|
|
}
|
2026-04-01 06:46:21 +00:00
|
|
|
if gender != "" && formality != FormalityNeutral {
|
|
|
|
|
variants = append(variants, key+"._"+context+"._"+gender+"._"+formality.String())
|
|
|
|
|
}
|
|
|
|
|
if gender != "" {
|
|
|
|
|
variants = append(variants, key+"._"+context+"._"+gender)
|
|
|
|
|
}
|
2026-04-01 23:19:10 +00:00
|
|
|
if location != "" && formality != FormalityNeutral {
|
|
|
|
|
variants = append(variants, key+"._"+context+"._"+location+"._"+formality.String())
|
|
|
|
|
}
|
|
|
|
|
if location != "" {
|
|
|
|
|
variants = append(variants, key+"._"+context+"._"+location)
|
|
|
|
|
}
|
2026-04-01 06:12:35 +00:00
|
|
|
if formality != FormalityNeutral {
|
|
|
|
|
variants = append(variants, key+"._"+context+"._"+formality.String())
|
|
|
|
|
}
|
|
|
|
|
variants = append(variants, key+"._"+context)
|
|
|
|
|
}
|
2026-04-01 23:19:10 +00:00
|
|
|
if gender != "" && location != "" && formality != FormalityNeutral {
|
|
|
|
|
variants = append(variants, key+"._"+gender+"._"+location+"._"+formality.String())
|
|
|
|
|
}
|
|
|
|
|
if gender != "" && location != "" {
|
|
|
|
|
variants = append(variants, key+"._"+gender+"._"+location)
|
|
|
|
|
}
|
|
|
|
|
if gender != "" && formality != FormalityNeutral {
|
|
|
|
|
variants = append(variants, key+"._"+gender+"._"+formality.String())
|
|
|
|
|
}
|
2026-04-01 06:46:21 +00:00
|
|
|
if gender != "" {
|
|
|
|
|
variants = append(variants, key+"._"+gender)
|
|
|
|
|
}
|
2026-04-01 23:19:10 +00:00
|
|
|
if location != "" && formality != FormalityNeutral {
|
|
|
|
|
variants = append(variants, key+"._"+location+"._"+formality.String())
|
|
|
|
|
}
|
|
|
|
|
if location != "" {
|
|
|
|
|
variants = append(variants, key+"._"+location)
|
|
|
|
|
}
|
2026-04-01 06:12:35 +00:00
|
|
|
if formality != FormalityNeutral {
|
|
|
|
|
variants = append(variants, key+"._"+formality.String())
|
|
|
|
|
}
|
2026-04-02 02:02:46 +00:00
|
|
|
if extraSuffix := lookupExtraSuffix(extra); extraSuffix != "" {
|
|
|
|
|
base := slices.Clone(variants)
|
|
|
|
|
var extraVariants []string
|
|
|
|
|
for _, variant := range base {
|
|
|
|
|
extraVariants = append(extraVariants, variant+extraSuffix)
|
|
|
|
|
}
|
|
|
|
|
variants = append(extraVariants, variants...)
|
|
|
|
|
}
|
2026-04-01 06:12:35 +00:00
|
|
|
variants = append(variants, key)
|
|
|
|
|
return variants
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 02:02:46 +00:00
|
|
|
func lookupExtraSuffix(extra map[string]any) string {
|
|
|
|
|
if len(extra) == 0 {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
keys := slices.Sorted(maps.Keys(extra))
|
|
|
|
|
var b strings.Builder
|
|
|
|
|
for _, key := range keys {
|
|
|
|
|
name := lookupSegment(key)
|
|
|
|
|
if name == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
value := lookupSegment(core.Sprintf("%v", extra[key]))
|
|
|
|
|
if value == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
b.WriteString("._")
|
|
|
|
|
b.WriteString(name)
|
|
|
|
|
b.WriteString("._")
|
|
|
|
|
b.WriteString(value)
|
|
|
|
|
}
|
|
|
|
|
return b.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func lookupSegment(s string) string {
|
|
|
|
|
s = core.Trim(s)
|
|
|
|
|
if s == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
var b strings.Builder
|
|
|
|
|
lastUnderscore := false
|
|
|
|
|
for _, r := range core.Lower(s) {
|
|
|
|
|
switch {
|
|
|
|
|
case unicode.IsLetter(r), unicode.IsDigit(r):
|
|
|
|
|
b.WriteRune(r)
|
|
|
|
|
lastUnderscore = false
|
|
|
|
|
case r == '_' || r == '-' || r == '.' || unicode.IsSpace(r):
|
|
|
|
|
if !lastUnderscore {
|
|
|
|
|
b.WriteByte('_')
|
|
|
|
|
lastUnderscore = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return strings.Trim(b.String(), "_")
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 19:51:27 +00:00
|
|
|
func (s *Service) handleMissingKey(key string, args []any) string {
|
|
|
|
|
switch s.mode {
|
|
|
|
|
case ModeStrict:
|
2026-03-26 14:11:15 +00:00
|
|
|
panic(core.Sprintf("i18n: missing translation key %q", key))
|
2026-02-16 19:51:27 +00:00
|
|
|
case ModeCollect:
|
2026-04-02 00:28:29 +00:00
|
|
|
argsMap := missingKeyArgs(args)
|
2026-02-16 19:51:27 +00:00
|
|
|
dispatchMissingKey(key, argsMap)
|
|
|
|
|
return "[" + key + "]"
|
|
|
|
|
default:
|
|
|
|
|
return key
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 00:28:29 +00:00
|
|
|
func missingKeyArgs(args []any) map[string]any {
|
|
|
|
|
if len(args) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-04-02 12:06:17 +00:00
|
|
|
result := make(map[string]any)
|
|
|
|
|
for _, arg := range args {
|
|
|
|
|
mergeMissingKeyArgs(result, arg)
|
|
|
|
|
}
|
|
|
|
|
if len(result) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func mergeMissingKeyArgs(dst map[string]any, value any) {
|
|
|
|
|
if dst == nil || value == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
switch v := value.(type) {
|
2026-04-02 00:28:29 +00:00
|
|
|
case map[string]any:
|
2026-04-02 12:06:17 +00:00
|
|
|
for key, item := range contextMapValuesAny(v) {
|
|
|
|
|
dst[key] = item
|
|
|
|
|
}
|
2026-04-02 03:49:25 +00:00
|
|
|
case map[string]string:
|
2026-04-02 12:06:17 +00:00
|
|
|
for key, item := range contextMapValuesString(v) {
|
|
|
|
|
dst[key] = item
|
|
|
|
|
}
|
2026-04-02 00:28:29 +00:00
|
|
|
case *TranslationContext:
|
2026-04-02 12:06:17 +00:00
|
|
|
for key, item := range missingKeyContextArgs(v) {
|
|
|
|
|
dst[key] = item
|
|
|
|
|
}
|
2026-04-02 00:28:29 +00:00
|
|
|
case *Subject:
|
2026-04-02 12:06:17 +00:00
|
|
|
for key, item := range missingKeySubjectArgs(v) {
|
|
|
|
|
dst[key] = item
|
|
|
|
|
}
|
2026-04-02 00:28:29 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func missingKeyContextArgs(ctx *TranslationContext) map[string]any {
|
|
|
|
|
if ctx == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
data := templateDataForRendering(ctx)
|
|
|
|
|
result, _ := data.(map[string]any)
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func missingKeySubjectArgs(subj *Subject) map[string]any {
|
|
|
|
|
if subj == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
data := templateDataForRendering(subj)
|
|
|
|
|
result, _ := data.(map[string]any)
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 19:51:27 +00:00
|
|
|
// Raw translates without i18n.* namespace magic.
|
|
|
|
|
func (s *Service) Raw(messageID string, args ...any) string {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return messageID
|
|
|
|
|
}
|
2026-02-16 19:51:27 +00:00
|
|
|
s.mu.RLock()
|
|
|
|
|
var data any
|
|
|
|
|
if len(args) > 0 {
|
|
|
|
|
data = args[0]
|
|
|
|
|
}
|
2026-04-02 06:10:14 +00:00
|
|
|
text := s.resolveDirectLocked(messageID, data)
|
2026-04-02 06:13:20 +00:00
|
|
|
debug := s.debug
|
|
|
|
|
s.mu.RUnlock()
|
2026-02-16 19:51:27 +00:00
|
|
|
if text == "" {
|
2026-04-02 06:13:20 +00:00
|
|
|
text = s.handleMissingKey(messageID, args)
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
2026-04-02 06:13:20 +00:00
|
|
|
if debug {
|
2026-02-16 19:51:27 +00:00
|
|
|
return debugFormat(messageID, text)
|
|
|
|
|
}
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) getMessage(lang, key string) (Message, bool) {
|
|
|
|
|
msgs, ok := s.messages[lang]
|
|
|
|
|
if !ok {
|
|
|
|
|
return Message{}, false
|
|
|
|
|
}
|
|
|
|
|
msg, ok := msgs[key]
|
|
|
|
|
return msg, ok
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AddMessages adds messages for a language at runtime.
|
|
|
|
|
func (s *Service) AddMessages(lang string, messages map[string]string) {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-02 06:06:45 +00:00
|
|
|
lang = normalizeLanguageTag(lang)
|
|
|
|
|
if lang == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 19:51:27 +00:00
|
|
|
s.mu.Lock()
|
|
|
|
|
if s.messages[lang] == nil {
|
|
|
|
|
s.messages[lang] = make(map[string]Message)
|
|
|
|
|
}
|
|
|
|
|
for key, text := range messages {
|
|
|
|
|
s.messages[lang][key] = Message{Text: text}
|
|
|
|
|
}
|
2026-04-02 12:02:52 +00:00
|
|
|
s.addAvailableLanguageLocked(language.Make(lang))
|
2026-04-02 06:06:45 +00:00
|
|
|
s.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
s.autoDetectLanguage()
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-17 01:31:32 +00:00
|
|
|
// AddLoader loads translations from an additional Loader, merging messages
|
|
|
|
|
// and grammar data into the existing service. This is the correct way to
|
|
|
|
|
// add package-specific translations at runtime.
|
|
|
|
|
func (s *Service) AddLoader(loader Loader) error {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return ErrServiceNotInitialised
|
|
|
|
|
}
|
2026-04-02 07:07:42 +00:00
|
|
|
if loader == nil {
|
|
|
|
|
return log.E("Service.AddLoader", "nil loader", nil)
|
|
|
|
|
}
|
2026-03-17 01:31:32 +00:00
|
|
|
langs := loader.Languages()
|
2026-04-02 08:03:56 +00:00
|
|
|
if len(langs) == 0 {
|
|
|
|
|
if el, ok := loader.(interface{ LanguagesErr() error }); ok {
|
|
|
|
|
if langErr := el.LanguagesErr(); langErr != nil {
|
|
|
|
|
return log.E("Service.AddLoader", "read locales directory", langErr)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return log.E("Service.AddLoader", "no languages available from loader", nil)
|
|
|
|
|
}
|
2026-03-17 01:31:32 +00:00
|
|
|
for _, lang := range langs {
|
|
|
|
|
messages, grammar, err := loader.Load(lang)
|
|
|
|
|
if err != nil {
|
2026-03-17 07:51:29 +00:00
|
|
|
return log.E("Service.AddLoader", "load locale: "+lang, err)
|
2026-03-17 01:31:32 +00:00
|
|
|
}
|
2026-04-02 06:31:35 +00:00
|
|
|
lang = normalizeLanguageTag(lang)
|
2026-04-02 09:57:05 +00:00
|
|
|
s.ingestLocaleData(lang, messages, grammar)
|
|
|
|
|
}
|
|
|
|
|
s.autoDetectLanguage()
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-03-17 01:31:32 +00:00
|
|
|
|
2026-04-02 09:57:05 +00:00
|
|
|
func (s *Service) ingestLocaleData(lang string, messages map[string]Message, grammar *GrammarData) {
|
|
|
|
|
lang = normalizeLanguageTag(lang)
|
|
|
|
|
if lang == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-17 01:31:32 +00:00
|
|
|
|
2026-04-02 09:57:05 +00:00
|
|
|
s.mu.Lock()
|
|
|
|
|
existing := s.messages[lang]
|
|
|
|
|
if existing == nil {
|
|
|
|
|
s.messages[lang] = make(map[string]Message, len(messages))
|
|
|
|
|
}
|
|
|
|
|
maps.Copy(s.messages[lang], messages)
|
2026-04-02 12:02:52 +00:00
|
|
|
s.addAvailableLanguageLocked(language.Make(lang))
|
2026-04-02 09:57:05 +00:00
|
|
|
s.mu.Unlock()
|
2026-03-17 01:31:32 +00:00
|
|
|
|
2026-04-02 09:57:05 +00:00
|
|
|
// Keep grammar merges outside the service mutex. Message-store updates are
|
|
|
|
|
// the only part that need to be serialized here.
|
|
|
|
|
if grammarDataHasContent(grammar) {
|
|
|
|
|
if existing != nil {
|
|
|
|
|
MergeGrammarData(lang, grammar)
|
|
|
|
|
} else {
|
|
|
|
|
SetGrammarData(lang, grammar)
|
2026-03-17 01:31:32 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 06:03:18 +00:00
|
|
|
func (s *Service) hasLocaleRegistrationLoaded(id int) bool {
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
if len(s.loadedLocales) == 0 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
_, ok := s.loadedLocales[id]
|
|
|
|
|
return ok
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) markLocaleRegistrationLoaded(id int) {
|
|
|
|
|
if id == 0 || s == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
if s.loadedLocales == nil {
|
|
|
|
|
s.loadedLocales = make(map[int]struct{})
|
|
|
|
|
}
|
|
|
|
|
s.loadedLocales[id] = struct{}{}
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) hasLocaleProviderLoaded(id int) bool {
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
if len(s.loadedProviders) == 0 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
_, ok := s.loadedProviders[id]
|
|
|
|
|
return ok
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) markLocaleProviderLoaded(id int) {
|
|
|
|
|
if id == 0 || s == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
if s.loadedProviders == nil {
|
|
|
|
|
s.loadedProviders = make(map[int]struct{})
|
|
|
|
|
}
|
|
|
|
|
s.loadedProviders[id] = struct{}{}
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 12:02:52 +00:00
|
|
|
func (s *Service) addAvailableLanguageLocked(tag language.Tag) {
|
|
|
|
|
if s == nil || tag == (language.Tag{}) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !slices.Contains(s.availableLangs, tag) {
|
|
|
|
|
s.availableLangs = append(s.availableLangs, tag)
|
|
|
|
|
slices.SortFunc(s.availableLangs, func(a, b language.Tag) int {
|
|
|
|
|
return strings.Compare(a.String(), b.String())
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 19:51:27 +00:00
|
|
|
// LoadFS loads additional locale files from a filesystem.
|
2026-04-02 09:43:45 +00:00
|
|
|
//
|
|
|
|
|
// Example:
|
2026-04-02 09:57:05 +00:00
|
|
|
//
|
|
|
|
|
// _ = svc.LoadFS(os.DirFS("."), "locales")
|
|
|
|
|
//
|
2026-03-17 01:31:32 +00:00
|
|
|
// Deprecated: Use AddLoader(NewFSLoader(fsys, dir)) instead for proper grammar handling.
|
2026-02-16 19:51:27 +00:00
|
|
|
func (s *Service) LoadFS(fsys fs.FS, dir string) error {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return ErrServiceNotInitialised
|
|
|
|
|
}
|
2026-04-02 07:55:11 +00:00
|
|
|
loader := NewFSLoader(fsys, dir)
|
|
|
|
|
langs := loader.Languages()
|
|
|
|
|
if len(langs) == 0 {
|
|
|
|
|
if langErr := loader.LanguagesErr(); langErr != nil {
|
|
|
|
|
return log.E("Service.LoadFS", "read locales directory", langErr)
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
2026-04-02 07:55:11 +00:00
|
|
|
return log.E("Service.LoadFS", "no languages available", nil)
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
2026-04-02 07:55:11 +00:00
|
|
|
return s.AddLoader(loader)
|
2026-02-16 19:51:27 +00:00
|
|
|
}
|
2026-04-01 23:06:02 +00:00
|
|
|
|
|
|
|
|
func (s *Service) autoDetectLanguage() {
|
2026-04-02 14:02:13 +00:00
|
|
|
if s == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-01 23:06:02 +00:00
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
if s.languageExplicit {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if detected := detectLanguage(s.availableLangs); detected != "" {
|
|
|
|
|
s.currentLang = detected
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-02 00:32:16 +00:00
|
|
|
|
|
|
|
|
func hasHandlerType(handlers []KeyHandler, candidate KeyHandler) bool {
|
|
|
|
|
want := reflect.TypeOf(candidate)
|
|
|
|
|
for _, handler := range handlers {
|
|
|
|
|
if reflect.TypeOf(handler) == want {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|