Files
l-ui/internal/web/locale/locale.go
T

373 lines
10 KiB
Go

// Package locale provides internationalization (i18n) support for the 3x-ui web panel,
// including translation loading, localization, and middleware for web and bot interfaces.
package locale
import (
"embed"
"encoding/json"
"io/fs"
"os"
"path"
"strings"
"sync"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/gin-gonic/gin"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
)
var (
i18nBundle *i18n.Bundle
LocalizerWeb *i18n.Localizer
LocalizerBot *i18n.Localizer
)
// I18nType represents the type of interface for internationalization.
type I18nType string
const (
Bot I18nType = "bot" // Bot interface type
Web I18nType = "web" // Web interface type
)
// defaultLanguageTag is the bundle's fallback language — the one
// language parsed eagerly at startup.
const defaultLanguageTag = "en-US"
// i18nSource is one translation file held as raw bytes. Parsing is
// what costs memory (ITEM-P3 baseline: all 13 language files parsed
// ≈ 10.2 MB of live heap, ~2/3 of the master's total), while the raw
// JSON is an order of magnitude smaller — so sources stay resident
// unparsed and each language is parsed on first use and cached
// (ITEM-P3 M1).
type i18nSource struct {
tag string // language tag from the file name, e.g. "zh-CN"
path string // path handed to ParseMessageFileBytes, as before
data []byte
}
var (
// i18nMu serializes lazy parses against localizations: parsing
// takes the write lock, Localizer creation and Localize calls
// take the read lock, so a first-use parse can never race a
// lookup in another goroutine.
i18nMu sync.RWMutex
i18nSources []i18nSource
i18nParsed map[string]bool
)
// SettingService interface defines methods for accessing locale settings.
type SettingService interface {
GetTgLang() (string, error)
}
// InitLocalizer initializes the internationalization system with embedded translation files.
// Only the fallback language (and, via initTGBotLocalizer, the bot's
// configured language) is parsed here; every other language is
// indexed as raw bytes and parsed lazily on first use.
func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
i18nMu.Lock()
// set default bundle to English
i18nBundle = i18n.NewBundle(language.MustParse(defaultLanguageTag))
i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal)
i18nSources = nil
i18nParsed = map[string]bool{}
// index the translation files, then parse the fallback language
if err := indexTranslationFiles(i18nFS); err != nil {
i18nMu.Unlock()
return err
}
err := ensureLanguageLocked(defaultLanguageTag)
i18nMu.Unlock()
if err != nil {
return err
}
// setup bot locale
if err := initTGBotLocalizer(settingService); err != nil {
return err
}
return nil
}
// createTemplateData creates a template data map from parameters with optional separator.
func createTemplateData(params []string, separator ...string) map[string]any {
sep := "=="
if len(separator) > 0 {
sep = separator[0]
}
templateData := make(map[string]any)
for _, param := range params {
parts := strings.SplitN(param, sep, 2)
templateData[parts[0]] = parts[1]
}
return templateData
}
// I18n retrieves a localized message for the given key and type.
// It supports both bot and web contexts, with optional template parameters.
// Returns the localized message or an empty string if localization fails.
func I18n(i18nType I18nType, key string, params ...string) string {
var localizer *i18n.Localizer
switch i18nType {
case "bot":
localizer = LocalizerBot
case "web":
localizer = LocalizerWeb
default:
logger.Errorf("Invalid type for I18n: %s", i18nType)
return ""
}
templateData := createTemplateData(params)
if localizer == nil {
// Fallback to key if localizer not ready; prevents nil panic on pages like sub
return key
}
i18nMu.RLock()
msg, err := localizer.Localize(&i18n.LocalizeConfig{
MessageID: key,
TemplateData: templateData,
})
i18nMu.RUnlock()
if err != nil {
logger.Errorf("Failed to localize message: %v", err)
return ""
}
return msg
}
// initTGBotLocalizer initializes the bot localizer with the configured language.
func initTGBotLocalizer(settingService SettingService) error {
botLang, err := settingService.GetTgLang()
if err != nil {
return err
}
// The bot localizer is created once, here, so its language is a
// startup language: parse it now (its first use). A parse failure
// fails startup, exactly as the old parse-everything init did for
// every language.
if err := ensureLanguage(botLang); err != nil {
return err
}
i18nMu.RLock()
defer i18nMu.RUnlock()
LocalizerBot = i18n.NewLocalizer(i18nBundle, botLang)
return nil
}
// LocalizerMiddleware returns a Gin middleware that sets up localization for web requests.
// It determines the user's language from cookies or Accept-Language header,
// creates a localizer instance, and stores it in the Gin context for use in handlers.
// Also provides the I18n function in the context for template rendering.
func LocalizerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Ensure bundle is initialized so creating a Localizer won't panic
ensureBundle()
var lang string
if cookie, err := c.Request.Cookie("lang"); err == nil {
lang = cookie.Value
} else {
lang = c.GetHeader("Accept-Language")
}
// First use of a language parses it (cached afterwards). A
// parse failure only degrades this request to the fallback
// language; it must not fail the request.
if err := ensureLanguage(lang); err != nil {
logger.Warning("i18n lazy load failed:", err)
}
i18nMu.RLock()
LocalizerWeb = i18n.NewLocalizer(i18nBundle, lang)
i18nMu.RUnlock()
c.Set("localizer", LocalizerWeb)
c.Set("I18n", I18n)
c.Next()
}
}
// ensureBundle covers processes that serve web requests without
// InitLocalizer having run (the sub server's historical lazy path):
// it creates the bundle and indexes the translation files from disk.
func ensureBundle() {
i18nMu.RLock()
ready := i18nBundle != nil
i18nMu.RUnlock()
if ready {
return
}
i18nMu.Lock()
defer i18nMu.Unlock()
if i18nBundle != nil {
return
}
i18nBundle = i18n.NewBundle(language.MustParse(defaultLanguageTag))
i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal)
i18nParsed = map[string]bool{}
if err := loadTranslationsFromDisk(); err != nil {
logger.Warning("i18n lazy load failed:", err)
return
}
if err := ensureLanguageLocked(defaultLanguageTag); err != nil {
logger.Warning("i18n lazy load failed:", err)
}
}
// ensureLanguage parses the translation file best matching the
// preference (a bare tag like "zh-CN", or an Accept-Language header
// value) unless it is already parsed, and always makes sure the
// fallback language is present. Concurrent first uses are safe.
func ensureLanguage(pref string) error {
i18nMu.Lock()
defer i18nMu.Unlock()
return ensureLanguageLocked(pref)
}
// ensureLanguageLocked is ensureLanguage with i18nMu write-held.
func ensureLanguageLocked(pref string) error {
if err := parseLanguageLocked(defaultLanguageTag); err != nil {
return err
}
tag := matchLanguageTag(pref)
if tag == "" || tag == defaultLanguageTag {
return nil
}
return parseLanguageLocked(tag)
}
// matchLanguageTag resolves a preference to one of the indexed
// source tags, or "" when nothing plausible matches — an unsupported
// language must not trigger a pointless parse, it just falls back.
func matchLanguageTag(pref string) string {
// An Accept-Language header is a preference list with optional
// quality values; the first entry is the most preferred.
if i := strings.IndexAny(pref, ",;"); i >= 0 {
pref = pref[:i]
}
pref = strings.TrimSpace(pref)
if pref == "" {
return ""
}
for _, src := range i18nSources {
if strings.EqualFold(src.tag, pref) {
return src.tag
}
}
want, err := language.Parse(pref)
if err != nil {
return ""
}
tags := make([]language.Tag, 0, len(i18nSources))
raws := make([]string, 0, len(i18nSources))
for _, src := range i18nSources {
if t, err := language.Parse(src.tag); err == nil {
tags = append(tags, t)
raws = append(raws, src.tag)
}
}
if len(tags) == 0 {
return ""
}
_, idx, confidence := language.NewMatcher(tags).Match(want)
if confidence == language.No {
return ""
}
return raws[idx]
}
// parseLanguageLocked parses one indexed language into the bundle,
// once. Unknown tags are a no-op (lookups fall back), as before.
// Caller holds i18nMu (write).
func parseLanguageLocked(tag string) error {
if i18nParsed[tag] {
return nil
}
for _, src := range i18nSources {
if src.tag == tag {
if _, err := i18nBundle.ParseMessageFileBytes(src.data, src.path); err != nil {
return err
}
i18nParsed[tag] = true
return nil
}
}
return nil
}
// languageTagFromPath extracts the language tag from a translation
// file path ("translation/zh-CN.json" -> "zh-CN"), the same name
// ParseMessageFileBytes derives the language from.
func languageTagFromPath(p string) string {
base := path.Base(p)
return strings.TrimSuffix(base, path.Ext(base))
}
// indexTranslationFiles indexes the embedded translation files as
// raw bytes without parsing them (ITEM-P3 M1). Caller holds i18nMu.
func indexTranslationFiles(i18nFS embed.FS) error {
return fs.WalkDir(i18nFS, "translation",
func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, err := i18nFS.ReadFile(path)
if err != nil {
return err
}
i18nSources = append(i18nSources, i18nSource{
tag: languageTagFromPath(path),
path: path,
data: data,
})
return nil
})
}
// loadTranslationsFromDisk indexes translation files from
// "internal/web/translation" using the local filesystem, as raw
// bytes (parsing stays lazy). Caller holds i18nMu.
func loadTranslationsFromDisk() error {
root := os.DirFS("internal/web")
return fs.WalkDir(root, "translation", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, err := fs.ReadFile(root, path)
if err != nil {
return err
}
i18nSources = append(i18nSources, i18nSource{
tag: languageTagFromPath(path),
path: path,
data: data,
})
return nil
})
}