2025-09-20 07:35:50 +00:00
|
|
|
// Package locale provides internationalization (i18n) support for the 3x-ui web panel,
|
|
|
|
// including translation loading, localization, and middleware for web and bot interfaces.
|
2023-05-20 15:11:08 +00:00
|
|
|
package locale
|
|
|
|
|
|
|
|
import (
|
|
|
|
"embed"
|
|
|
|
"io/fs"
|
2025-09-14 18:16:40 +00:00
|
|
|
"os"
|
2023-05-20 15:11:08 +00:00
|
|
|
"strings"
|
2024-03-10 21:31:24 +00:00
|
|
|
|
2025-09-19 08:05:43 +00:00
|
|
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
2023-05-20 15:11:08 +00:00
|
|
|
|
2023-05-20 15:16:34 +00:00
|
|
|
"github.com/gin-gonic/gin"
|
2023-05-20 15:11:08 +00:00
|
|
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
|
|
|
"github.com/pelletier/go-toml/v2"
|
|
|
|
"golang.org/x/text/language"
|
|
|
|
)
|
|
|
|
|
2024-03-10 21:31:24 +00:00
|
|
|
var (
|
|
|
|
i18nBundle *i18n.Bundle
|
|
|
|
LocalizerWeb *i18n.Localizer
|
|
|
|
LocalizerBot *i18n.Localizer
|
|
|
|
)
|
2023-05-20 15:11:08 +00:00
|
|
|
|
2025-09-20 07:35:50 +00:00
|
|
|
// I18nType represents the type of interface for internationalization.
|
2023-05-20 15:11:08 +00:00
|
|
|
type I18nType string
|
|
|
|
|
|
|
|
const (
|
2025-09-20 07:35:50 +00:00
|
|
|
Bot I18nType = "bot" // Bot interface type
|
|
|
|
Web I18nType = "web" // Web interface type
|
2023-05-20 15:11:08 +00:00
|
|
|
)
|
|
|
|
|
2025-09-20 07:35:50 +00:00
|
|
|
// SettingService interface defines methods for accessing locale settings.
|
2023-05-20 15:11:08 +00:00
|
|
|
type SettingService interface {
|
|
|
|
GetTgLang() (string, error)
|
|
|
|
}
|
|
|
|
|
2025-09-20 07:35:50 +00:00
|
|
|
// InitLocalizer initializes the internationalization system with embedded translation files.
|
2023-05-20 15:11:08 +00:00
|
|
|
func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
|
|
|
|
// set default bundle to english
|
2023-06-16 09:31:34 +00:00
|
|
|
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
|
2023-05-20 15:11:08 +00:00
|
|
|
i18nBundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
|
|
|
|
|
|
|
// parse files
|
|
|
|
if err := parseTranslationFiles(i18nFS, i18nBundle); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2023-05-20 15:29:04 +00:00
|
|
|
// setup bot locale
|
|
|
|
if err := initTGBotLocalizer(settingService); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2023-05-20 15:11:08 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2025-09-20 07:35:50 +00:00
|
|
|
// createTemplateData creates a template data map from parameters with optional separator.
|
2025-09-19 08:35:03 +00:00
|
|
|
func createTemplateData(params []string, separator ...string) map[string]any {
|
2023-05-20 15:11:08 +00:00
|
|
|
var sep string = "=="
|
2025-09-19 08:35:03 +00:00
|
|
|
if len(separator) > 0 {
|
|
|
|
sep = separator[0]
|
2023-05-20 15:11:08 +00:00
|
|
|
}
|
|
|
|
|
2025-03-12 19:13:51 +00:00
|
|
|
templateData := make(map[string]any)
|
2023-05-20 15:11:08 +00:00
|
|
|
for _, param := range params {
|
|
|
|
parts := strings.SplitN(param, sep, 2)
|
|
|
|
templateData[parts[0]] = parts[1]
|
|
|
|
}
|
|
|
|
|
|
|
|
return templateData
|
|
|
|
}
|
|
|
|
|
2025-09-20 07:35:50 +00:00
|
|
|
// 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.
|
2023-05-20 15:11:08 +00:00
|
|
|
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)
|
|
|
|
|
2025-09-14 18:16:40 +00:00
|
|
|
if localizer == nil {
|
|
|
|
// Fallback to key if localizer not ready; prevents nil panic on pages like sub
|
|
|
|
return key
|
|
|
|
}
|
|
|
|
|
2023-05-20 15:11:08 +00:00
|
|
|
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
|
|
|
MessageID: key,
|
|
|
|
TemplateData: templateData,
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
logger.Errorf("Failed to localize message: %v", err)
|
|
|
|
return ""
|
|
|
|
}
|
|
|
|
|
|
|
|
return msg
|
|
|
|
}
|
|
|
|
|
2025-09-20 07:35:50 +00:00
|
|
|
// initTGBotLocalizer initializes the bot localizer with the configured language.
|
2023-05-20 15:29:04 +00:00
|
|
|
func initTGBotLocalizer(settingService SettingService) error {
|
|
|
|
botLang, err := settingService.GetTgLang()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
LocalizerBot = i18n.NewLocalizer(i18nBundle, botLang)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2025-09-20 07:35:50 +00:00
|
|
|
// 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.
|
2023-05-20 15:16:34 +00:00
|
|
|
func LocalizerMiddleware() gin.HandlerFunc {
|
|
|
|
return func(c *gin.Context) {
|
2025-09-14 18:16:40 +00:00
|
|
|
// Ensure bundle is initialized so creating a Localizer won't panic
|
|
|
|
if i18nBundle == nil {
|
|
|
|
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
|
|
|
|
i18nBundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
|
|
|
// Try lazy-load from disk when running sub server without InitLocalizer
|
|
|
|
if err := loadTranslationsFromDisk(i18nBundle); err != nil {
|
|
|
|
logger.Warning("i18n lazy load failed:", err)
|
|
|
|
}
|
|
|
|
}
|
2023-05-20 15:16:34 +00:00
|
|
|
var lang string
|
|
|
|
|
|
|
|
if cookie, err := c.Request.Cookie("lang"); err == nil {
|
|
|
|
lang = cookie.Value
|
|
|
|
} else {
|
|
|
|
lang = c.GetHeader("Accept-Language")
|
|
|
|
}
|
|
|
|
|
|
|
|
LocalizerWeb = i18n.NewLocalizer(i18nBundle, lang)
|
|
|
|
|
|
|
|
c.Set("localizer", LocalizerWeb)
|
|
|
|
c.Set("I18n", I18n)
|
|
|
|
c.Next()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-09-14 18:16:40 +00:00
|
|
|
// loadTranslationsFromDisk attempts to load translation files from "web/translation" using the local filesystem.
|
|
|
|
func loadTranslationsFromDisk(bundle *i18n.Bundle) error {
|
|
|
|
root := os.DirFS("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
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2025-09-20 07:35:50 +00:00
|
|
|
// parseTranslationFiles parses embedded translation files and adds them to the i18n bundle.
|
2025-09-14 18:16:40 +00:00
|
|
|
func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error {
|
|
|
|
err := fs.WalkDir(i18nFS, "translation",
|
2023-05-20 15:11:08 +00:00
|
|
|
func(path string, d fs.DirEntry, err error) error {
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if d.IsDir() {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2025-09-14 18:16:40 +00:00
|
|
|
data, err := i18nFS.ReadFile(path)
|
2023-05-20 15:11:08 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
_, err = i18nBundle.ParseMessageFileBytes(data, path)
|
|
|
|
return err
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|