mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-16 21:59:56 +00:00
Adds a tray + React translation pipeline driven by a single JSON locale tree (frontend/src/i18n/locales) embedded into the Go binary. The tray re-renders on language switch via a Localizer that subscribes to the preferences store. Layout: - client/ui/i18n: Bundle, LanguageCode, Language, errors, embedded-FS loader. Pure domain, no Wails/daemon deps. - client/ui/preferences: Store + UIPreferences for user-scope UI state, persisted under os.UserConfigDir()/netbird/ui-preferences.json with atomic writes and a subscribe/broadcast channel. - client/ui/services: thin Wails-binding facades (services.I18n, services.Preferences) so React sees ctx-first signatures. - client/ui/localizer.go: tray bridge that owns the active language, exposes T()/StatusLabel() and re-paints the menu on prefs change. - tray.go: every user-facing const replaced by translation keys via t.loc.T(...); menu rebuild + state replay on language switch. - main.go: //go:embed all:frontend/src/i18n/locales, wires Bundle -> Store -> Localizer -> Wails facades in order. Frontend API exposed via Wails bindings: I18n.Languages, I18n.Bundle, Preferences.Get, Preferences.SetLanguage, plus the netbird:preferences:changed event. Includes regenerated Wails TS bindings (peers/profileswitcher/etc. re-emitted as part of the build) and en/hu seed bundles.
210 lines
6.6 KiB
Go
210 lines
6.6 KiB
Go
//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 <code>/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 <code>/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
|
|
}
|