locale.go 5.6 KB

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