locale.go 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. // Package locale provides internationalization (i18n) support for the 3x-ui web panel,
  2. // including translation loading, localization, and middleware for web and bot interfaces.
  3. package locale
  4. import (
  5. "embed"
  6. "io/fs"
  7. "os"
  8. "strings"
  9. "github.com/mhsanaei/3x-ui/v2/logger"
  10. "github.com/gin-gonic/gin"
  11. "github.com/nicksnyder/go-i18n/v2/i18n"
  12. "github.com/pelletier/go-toml/v2"
  13. "golang.org/x/text/language"
  14. )
  15. var (
  16. i18nBundle *i18n.Bundle
  17. LocalizerWeb *i18n.Localizer
  18. LocalizerBot *i18n.Localizer
  19. )
  20. // I18nType represents the type of interface for internationalization.
  21. type I18nType string
  22. const (
  23. Bot I18nType = "bot" // Bot interface type
  24. Web I18nType = "web" // Web interface type
  25. )
  26. // SettingService interface defines methods for accessing locale settings.
  27. type SettingService interface {
  28. GetTgLang() (string, error)
  29. }
  30. // InitLocalizer initializes the internationalization system with embedded translation files.
  31. func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
  32. // set default bundle to english
  33. i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
  34. i18nBundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
  35. // parse files
  36. if err := parseTranslationFiles(i18nFS, i18nBundle); err != nil {
  37. return err
  38. }
  39. // setup bot locale
  40. if err := initTGBotLocalizer(settingService); err != nil {
  41. return err
  42. }
  43. return nil
  44. }
  45. // createTemplateData creates a template data map from parameters with optional separator.
  46. func createTemplateData(params []string, separator ...string) map[string]any {
  47. var sep string = "=="
  48. if len(separator) > 0 {
  49. sep = separator[0]
  50. }
  51. templateData := make(map[string]any)
  52. for _, param := range params {
  53. parts := strings.SplitN(param, sep, 2)
  54. templateData[parts[0]] = parts[1]
  55. }
  56. return templateData
  57. }
  58. // I18n retrieves a localized message for the given key and type.
  59. // It supports both bot and web contexts, with optional template parameters.
  60. // Returns the localized message or an empty string if localization fails.
  61. func I18n(i18nType I18nType, key string, params ...string) string {
  62. var localizer *i18n.Localizer
  63. switch i18nType {
  64. case "bot":
  65. localizer = LocalizerBot
  66. case "web":
  67. localizer = LocalizerWeb
  68. default:
  69. logger.Errorf("Invalid type for I18n: %s", i18nType)
  70. return ""
  71. }
  72. templateData := createTemplateData(params)
  73. if localizer == nil {
  74. // Fallback to key if localizer not ready; prevents nil panic on pages like sub
  75. return key
  76. }
  77. msg, err := localizer.Localize(&i18n.LocalizeConfig{
  78. MessageID: key,
  79. TemplateData: templateData,
  80. })
  81. if err != nil {
  82. logger.Errorf("Failed to localize message: %v", err)
  83. return ""
  84. }
  85. return msg
  86. }
  87. // initTGBotLocalizer initializes the bot localizer with the configured language.
  88. func initTGBotLocalizer(settingService SettingService) error {
  89. botLang, err := settingService.GetTgLang()
  90. if err != nil {
  91. return err
  92. }
  93. LocalizerBot = i18n.NewLocalizer(i18nBundle, botLang)
  94. return nil
  95. }
  96. // LocalizerMiddleware returns a Gin middleware that sets up localization for web requests.
  97. // It determines the user's language from cookies or Accept-Language header,
  98. // creates a localizer instance, and stores it in the Gin context for use in handlers.
  99. // Also provides the I18n function in the context for template rendering.
  100. func LocalizerMiddleware() gin.HandlerFunc {
  101. return func(c *gin.Context) {
  102. // Ensure bundle is initialized so creating a Localizer won't panic
  103. if i18nBundle == nil {
  104. i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
  105. i18nBundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
  106. // Try lazy-load from disk when running sub server without InitLocalizer
  107. if err := loadTranslationsFromDisk(i18nBundle); err != nil {
  108. logger.Warning("i18n lazy load failed:", err)
  109. }
  110. }
  111. var lang string
  112. if cookie, err := c.Request.Cookie("lang"); err == nil {
  113. lang = cookie.Value
  114. } else {
  115. lang = c.GetHeader("Accept-Language")
  116. }
  117. LocalizerWeb = i18n.NewLocalizer(i18nBundle, lang)
  118. c.Set("localizer", LocalizerWeb)
  119. c.Set("I18n", I18n)
  120. c.Next()
  121. }
  122. }
  123. // loadTranslationsFromDisk attempts to load translation files from "web/translation" using the local filesystem.
  124. func loadTranslationsFromDisk(bundle *i18n.Bundle) error {
  125. root := os.DirFS("web")
  126. return fs.WalkDir(root, "translation", func(path string, d fs.DirEntry, err error) error {
  127. if err != nil {
  128. return err
  129. }
  130. if d.IsDir() {
  131. return nil
  132. }
  133. data, err := fs.ReadFile(root, path)
  134. if err != nil {
  135. return err
  136. }
  137. _, err = bundle.ParseMessageFileBytes(data, path)
  138. return err
  139. })
  140. }
  141. // parseTranslationFiles parses embedded translation files and adds them to the i18n bundle.
  142. func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error {
  143. err := fs.WalkDir(i18nFS, "translation",
  144. func(path string, d fs.DirEntry, err error) error {
  145. if err != nil {
  146. return err
  147. }
  148. if d.IsDir() {
  149. return nil
  150. }
  151. data, err := i18nFS.ReadFile(path)
  152. if err != nil {
  153. return err
  154. }
  155. _, err = i18nBundle.ParseMessageFileBytes(data, path)
  156. return err
  157. })
  158. if err != nil {
  159. return err
  160. }
  161. return nil
  162. }