373 lines
10 KiB
Go
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
|
|
})
|
|
}
|