mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
f1e1b0fb0c
InitLocalizer filled the shared bundle in place, and the Telegram bot runs it again while the panel is serving, so a request could read the bundle's maps while they were being written and the process died on a concurrent map access. Build the bundle privately and publish it, and the bot and web localizers, through atomic pointers; the lazy load in the middleware publishes only if nothing else did first.
233 lines
6.1 KiB
Go
233 lines
6.1 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 (
|
|
"encoding/json"
|
|
"io/fs"
|
|
"os"
|
|
"strings"
|
|
"sync/atomic"
|
|
|
|
"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"
|
|
)
|
|
|
|
// Each is published whole: InitLocalizer runs again (Telegram bot start) while requests read them.
|
|
var (
|
|
i18nBundle atomic.Pointer[i18n.Bundle]
|
|
localizerWeb atomic.Pointer[i18n.Localizer]
|
|
localizerBot atomic.Pointer[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
|
|
)
|
|
|
|
// SettingService interface defines methods for accessing locale settings.
|
|
type SettingService interface {
|
|
GetTgLang() (string, error)
|
|
}
|
|
|
|
// InitLocalizer initializes the internationalization system with embedded translation files.
|
|
func InitLocalizer(i18nFS fs.FS, settingService SettingService) error {
|
|
// set default bundle to English
|
|
bundle := i18n.NewBundle(language.MustParse("en-US"))
|
|
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
|
|
|
// parse files
|
|
if err := parseTranslationFiles(i18nFS, bundle); err != nil {
|
|
return err
|
|
}
|
|
i18nBundle.Store(bundle)
|
|
|
|
// 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.Load()
|
|
case "web":
|
|
localizer = localizerWeb.Load()
|
|
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
|
|
}
|
|
|
|
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
|
MessageID: key,
|
|
TemplateData: templateData,
|
|
})
|
|
if err != nil {
|
|
logger.Errorf("Failed to localize message: %v", err)
|
|
return ""
|
|
}
|
|
|
|
return msg
|
|
}
|
|
|
|
// LocalizerFor returns a new Localizer for the given language tag using the global bundle.
|
|
func LocalizerFor(lang string) *i18n.Localizer {
|
|
bundle := i18nBundle.Load()
|
|
if bundle == nil {
|
|
return nil
|
|
}
|
|
return i18n.NewLocalizer(bundle, lang)
|
|
}
|
|
|
|
// I18nForLang retrieves a localized message for a specific language tag with optional params.
|
|
func I18nForLang(lang string, key string, params ...string) string {
|
|
loc := LocalizerFor(lang)
|
|
if loc == nil {
|
|
return key
|
|
}
|
|
templateData := createTemplateData(params)
|
|
msg, err := loc.Localize(&i18n.LocalizeConfig{
|
|
MessageID: key,
|
|
TemplateData: templateData,
|
|
})
|
|
if err != nil {
|
|
return key
|
|
}
|
|
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
|
|
}
|
|
|
|
localizerBot.Store(i18n.NewLocalizer(i18nBundle.Load(), 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
|
|
bundle := i18nBundle.Load()
|
|
if bundle == nil {
|
|
bundle = i18n.NewBundle(language.MustParse("en-US"))
|
|
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
|
// Try lazy-load from disk when running sub server without InitLocalizer
|
|
if err := loadTranslationsFromDisk(bundle); err != nil {
|
|
logger.Warning("i18n lazy load failed:", err)
|
|
}
|
|
if !i18nBundle.CompareAndSwap(nil, bundle) {
|
|
bundle = i18nBundle.Load()
|
|
}
|
|
}
|
|
var lang string
|
|
|
|
if cookie, err := c.Request.Cookie("lang"); err == nil {
|
|
lang = cookie.Value
|
|
} else {
|
|
lang = c.GetHeader("Accept-Language")
|
|
}
|
|
|
|
localizer := i18n.NewLocalizer(bundle, lang)
|
|
localizerWeb.Store(localizer)
|
|
|
|
c.Set("localizer", localizer)
|
|
c.Set("I18n", I18n)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// loadTranslationsFromDisk attempts to load translation files from "internal/web/translation" using the local filesystem.
|
|
func loadTranslationsFromDisk(bundle *i18n.Bundle) 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
|
|
}
|
|
_, err = bundle.ParseMessageFileBytes(data, path)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// parseTranslationFiles parses embedded translation files and adds them to the i18n bundle.
|
|
func parseTranslationFiles(i18nFS fs.FS, i18nBundle *i18n.Bundle) error {
|
|
err := 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 := fs.ReadFile(i18nFS, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = i18nBundle.ParseMessageFileBytes(data, path)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SwapBotLocalizer installs l as the bot localizer and returns the previous one.
|
|
func SwapBotLocalizer(l *i18n.Localizer) *i18n.Localizer {
|
|
return localizerBot.Swap(l)
|
|
}
|