//go:build !android && !ios && !freebsd && !js // Package i18n carries the translation domain: the BCP-47 LanguageCode // type, the per-language Language metadata, and the Bundle that loads and // serves translation strings for both the tray (Go) and the React UI // (via the Wails-bound services.I18n facade). // // No Wails or daemon dependencies — this package can be tested and used // standalone. The locale tree is passed in as an fs.FS so the embed // directive can live in the main binary alongside the rest of the // embedded assets. package i18n import ( "encoding/json" "errors" "fmt" "io/fs" "path" "sort" "strings" "sync" log "github.com/sirupsen/logrus" ) const ( // localeIndexFile sits at the locale tree root and lists every shipped // language with its display name. Adding a new language means dropping // a new /common.json bundle and appending a row to this index. localeIndexFile = "_index.json" // commonBundleFile is the per-language translation bundle. Single // namespace for now ("common") — split later if the key set grows // enough to warrant per-screen bundles. commonBundleFile = "common.json" ) // LanguageCode is a BCP-47-ish locale identifier ("en", "hu", ...). Carried // as a named string so the compiler distinguishes a language code from a // translation key or an arbitrary user-supplied string in function // signatures; JSON serialisation is unchanged (still a plain string). type LanguageCode string // DefaultLanguage is used when no preference is on disk and as the fallback // bundle for missing keys. const DefaultLanguage LanguageCode = "en" // ErrUnsupportedLanguage is returned when a caller asks for a language // that has no bundle loaded. var ErrUnsupportedLanguage = errors.New("unsupported language") // Language describes one shipped UI locale. DisplayName is shown in the // picker in its own script (so a Hungarian user sees "Magyar" even when // the current UI language is English). type Language struct { Code LanguageCode `json:"code"` DisplayName string `json:"displayName"` EnglishName string `json:"englishName"` } // localeIndex is the on-disk shape of _index.json. type localeIndex struct { Languages []Language `json:"languages"` } // Bundle holds the parsed translation bundles. Loaded once at construction // and never mutated, so concurrent readers (tray menu rebuilds + Wails // service calls) don't need to coordinate beyond the RW mutex. type Bundle struct { mu sync.RWMutex languages []Language bundles map[LanguageCode]map[string]string } // NewBundle parses _index.json plus every /common.json file in the // locale tree. Hard-fails only when the default language is missing — // individual locales without a bundle are dropped with a warning so the // rest of the product keeps shipping. func NewBundle(localesFS fs.FS) (*Bundle, error) { idx, err := loadLocaleIndex(localesFS) if err != nil { return nil, fmt.Errorf("load locale index: %w", err) } bundles := make(map[LanguageCode]map[string]string, len(idx.Languages)) available := make([]Language, 0, len(idx.Languages)) for _, l := range idx.Languages { b, err := loadBundle(localesFS, l.Code) if err != nil { log.Warnf("skip language %q: %v", l.Code, err) continue } bundles[l.Code] = b available = append(available, l) } if _, ok := bundles[DefaultLanguage]; !ok { return nil, fmt.Errorf("default language %q bundle missing", DefaultLanguage) } sort.Slice(available, func(i, j int) bool { return available[i].Code < available[j].Code }) return &Bundle{ languages: available, bundles: bundles, }, nil } // Languages returns the list of available locales as a copy. func (b *Bundle) Languages() []Language { b.mu.RLock() defer b.mu.RUnlock() out := make([]Language, len(b.languages)) copy(out, b.languages) return out } // HasLanguage reports whether a bundle is loaded for the given code. // preferences.Store uses this to validate SetLanguage input. func (b *Bundle) HasLanguage(code LanguageCode) bool { b.mu.RLock() defer b.mu.RUnlock() _, ok := b.bundles[code] return ok } // BundleFor returns the full key->text map for one language as a copy. // The Wails facade exposes this to React so the frontend can drive its // own translation library (i18next, etc.) off the same source bundles. func (b *Bundle) BundleFor(code LanguageCode) (map[string]string, error) { b.mu.RLock() defer b.mu.RUnlock() bundle, ok := b.bundles[code] if !ok { return nil, fmt.Errorf("%w: %q", ErrUnsupportedLanguage, code) } out := make(map[string]string, len(bundle)) for k, v := range bundle { out[k] = v } return out, nil } // Translate resolves key for the given language with a placeholder pass. // Args must come in {placeholderName, value} pairs (e.g. "version", "1.2.3" // substitutes "{version}"). Unknown keys fall back to the default language; // if even that fails, the key itself is returned — a missed key is visible // in the UI rather than blank. func (b *Bundle) Translate(lang LanguageCode, key string, args ...string) string { b.mu.RLock() defer b.mu.RUnlock() if v, ok := b.bundles[lang][key]; ok { return applyPlaceholders(v, args) } if lang != DefaultLanguage { if v, ok := b.bundles[DefaultLanguage][key]; ok { return applyPlaceholders(v, args) } } return key } // applyPlaceholders substitutes {name} occurrences in s using args interpreted // as flat name/value pairs. Odd-length args lists drop the trailing item with // a debug log — preferable to a hard error since the caller is internal code. func applyPlaceholders(s string, args []string) string { if len(args) == 0 { return s } if len(args)%2 != 0 { log.Debugf("i18n placeholder args not paired: %d items, last dropped", len(args)) args = args[:len(args)-1] } for j := 0; j < len(args); j += 2 { s = strings.ReplaceAll(s, "{"+args[j]+"}", args[j+1]) } return s } func loadLocaleIndex(localesFS fs.FS) (*localeIndex, error) { data, err := fs.ReadFile(localesFS, localeIndexFile) if err != nil { return nil, err } var idx localeIndex if err := json.Unmarshal(data, &idx); err != nil { return nil, fmt.Errorf("parse %s: %w", localeIndexFile, err) } if len(idx.Languages) == 0 { return nil, errors.New("no languages declared") } return &idx, nil } func loadBundle(localesFS fs.FS, code LanguageCode) (map[string]string, error) { p := path.Join(string(code), commonBundleFile) data, err := fs.ReadFile(localesFS, p) if err != nil { return nil, err } var bundle map[string]string if err := json.Unmarshal(data, &bundle); err != nil { return nil, fmt.Errorf("parse %s: %w", p, err) } return bundle, nil }