// Copyright GoFrame Author(https://goframe.org). All Rights Reserved. // // This Source Code Form is subject to the terms of the MIT License. // If a copy of the MIT was not distributed with this file, // You can obtain one at https://github.com/gogf/gf. package gi18n import ( "context" "fmt" "strings" "sync" "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/errors/gcode" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/internal/intlog" "github.com/gogf/gf/v2/os/gfile" "github.com/gogf/gf/v2/os/gfsnotify" "github.com/gogf/gf/v2/os/gres" "github.com/gogf/gf/v2/text/gregex" "github.com/gogf/gf/v2/util/gconv" ) // pathType is the type for i18n file path. type pathType string const ( pathTypeNone pathType = "none" pathTypeNormal pathType = "normal" pathTypeGres pathType = "gres" ) // Manager for i18n contents, it is concurrent safe, supporting hot reload. type Manager struct { mu sync.RWMutex data map[string]map[string]string // Translating map. pattern string // Pattern for regex parsing. pathType pathType // Path type for i18n files. options Options // configuration options. } // Options is used for i18n object configuration. type Options struct { Path string // I18n files storage path. Language string // Default local language. Delimiters []string // Delimiters for variable parsing. Resource *gres.Resource // Resource for i18n files. } var ( // defaultDelimiters defines the default language if user does not specify in options. defaultLanguage = "en" // defaultDelimiters defines the default key variable delimiters. defaultDelimiters = []string{"{#", "}"} // i18n files searching folders. searchFolders = []string{"manifest/i18n", "manifest/config/i18n", "i18n"} ) // New creates and returns a new i18n manager. // The optional parameter `option` specifies the custom options for i18n manager. // It uses a default one if it's not passed. func New(options ...Options) *Manager { var opts Options var pathType = pathTypeNone if len(options) > 0 { opts = options[0] pathType = opts.checkPathType(opts.Path) } else { opts = Options{} for _, folder := range searchFolders { pathType = opts.checkPathType(folder) if pathType != pathTypeNone { break } } if opts.Path != "" { // To avoid of the source path of GoFrame: github.com/gogf/i18n/gi18n if gfile.Exists(opts.Path + gfile.Separator + "gi18n") { opts.Path = "" pathType = pathTypeNone } } } if len(opts.Language) == 0 { opts.Language = defaultLanguage } if len(opts.Delimiters) == 0 { opts.Delimiters = defaultDelimiters } m := &Manager{ options: opts, pattern: fmt.Sprintf( `%s(.+?)%s`, gregex.Quote(opts.Delimiters[0]), gregex.Quote(opts.Delimiters[1]), ), pathType: pathType, } intlog.Printf(context.TODO(), `New: %#v`, m) return m } // checkPathType checks and returns the path type for given directory path. func (o *Options) checkPathType(dirPath string) pathType { if dirPath == "" { return pathTypeNone } if o.Resource == nil { o.Resource = gres.Instance() } if o.Resource.Contains(dirPath) { o.Path = dirPath return pathTypeGres } realPath, _ := gfile.Search(dirPath) if realPath != "" { o.Path = realPath return pathTypeNormal } return pathTypeNone } // SetPath sets the directory path storing i18n files. func (m *Manager) SetPath(path string) error { pathType := m.options.checkPathType(path) if pathType == pathTypeNone { return gerror.NewCodef(gcode.CodeInvalidParameter, `%s does not exist`, path) } m.pathType = pathType intlog.Printf(context.TODO(), `SetPath[%s]: %s`, m.pathType, m.options.Path) // Reset the manager after path changed. m.reset() return nil } // SetLanguage sets the language for translator. func (m *Manager) SetLanguage(language string) { m.options.Language = language intlog.Printf(context.TODO(), `SetLanguage: %s`, m.options.Language) } // SetDelimiters sets the delimiters for translator. func (m *Manager) SetDelimiters(left, right string) { m.pattern = fmt.Sprintf(`%s(.+?)%s`, gregex.Quote(left), gregex.Quote(right)) intlog.Printf(context.TODO(), `SetDelimiters: %v`, m.pattern) } // T is alias of Translate for convenience. func (m *Manager) T(ctx context.Context, content string) string { return m.Translate(ctx, content) } // Tf is alias of TranslateFormat for convenience. func (m *Manager) Tf(ctx context.Context, format string, values ...interface{}) string { return m.TranslateFormat(ctx, format, values...) } // TranslateFormat translates, formats and returns the `format` with configured language // and given `values`. func (m *Manager) TranslateFormat(ctx context.Context, format string, values ...interface{}) string { return fmt.Sprintf(m.Translate(ctx, format), values...) } // Translate translates `content` with configured language. func (m *Manager) Translate(ctx context.Context, content string) string { m.init(ctx) m.mu.RLock() defer m.mu.RUnlock() transLang := m.options.Language if lang := LanguageFromCtx(ctx); lang != "" { transLang = lang } data := m.data[transLang] if data == nil { return content } // Parse content as name. if v, ok := data[content]; ok { return v } // Parse content as variables container. result, _ := gregex.ReplaceStringFuncMatch( m.pattern, content, func(match []string) string { if v, ok := data[match[1]]; ok { return v } // return match[1] will return the content between delimiters // return match[0] will return the original content return match[0] }) intlog.Printf(ctx, `Translate for language: %s`, transLang) return result } // GetContent retrieves and returns the configured content for given key and specified language. // It returns an empty string if not found. func (m *Manager) GetContent(ctx context.Context, key string) string { m.init(ctx) m.mu.RLock() defer m.mu.RUnlock() transLang := m.options.Language if lang := LanguageFromCtx(ctx); lang != "" { transLang = lang } if data, ok := m.data[transLang]; ok { return data[key] } return "" } // reset reset data of the manager. func (m *Manager) reset() { m.mu.Lock() defer m.mu.Unlock() m.data = nil } // init initializes the manager for lazy initialization design. // The i18n manager is only initialized once. func (m *Manager) init(ctx context.Context) { m.mu.RLock() // If the data is not nil, means it's already initialized. if m.data != nil { m.mu.RUnlock() return } m.mu.RUnlock() defer func() { intlog.Printf(ctx, `Manager init finish: %#v`, m) }() intlog.Printf(ctx, `init path: %s`, m.options.Path) m.mu.Lock() defer m.mu.Unlock() switch m.pathType { case pathTypeGres: files := m.options.Resource.ScanDirFile(m.options.Path, "*.*", true) if len(files) > 0 { var ( path string name string lang string array []string ) m.data = make(map[string]map[string]string) for _, file := range files { name = file.Name() path = name[len(m.options.Path)+1:] array = strings.Split(path, "/") if len(array) > 1 { lang = array[0] } else if len(array) == 1 { lang = gfile.Name(array[0]) } if m.data[lang] == nil { m.data[lang] = make(map[string]string) } if j, err := gjson.LoadContent(file.Content()); err == nil { for k, v := range j.Var().Map() { m.data[lang][k] = gconv.String(v) } } else { intlog.Errorf(ctx, "load i18n file '%s' failed: %+v", name, err) } } } case pathTypeNormal: files, _ := gfile.ScanDirFile(m.options.Path, "*.*", true) if len(files) == 0 { return } var ( path string lang string array []string ) m.data = make(map[string]map[string]string) for _, file := range files { path = file[len(m.options.Path)+1:] array = strings.Split(path, gfile.Separator) if len(array) > 1 { lang = array[0] } else if len(array) == 1 { lang = gfile.Name(array[0]) } if m.data[lang] == nil { m.data[lang] = make(map[string]string) } if j, err := gjson.LoadContent(gfile.GetBytes(file)); err == nil { for k, v := range j.Var().Map() { m.data[lang][k] = gconv.String(v) } } else { intlog.Errorf(ctx, "load i18n file '%s' failed: %+v", file, err) } } intlog.Printf(ctx, "i18n files loaded in path: %s", m.options.Path) // Monitor changes of i18n files for hot reload feature. _, _ = gfsnotify.Add(m.options.Path, func(event *gfsnotify.Event) { intlog.Printf(ctx, `i18n file changed: %s`, event.Path) // Any changes of i18n files, clear the data. m.reset() gfsnotify.Exit() }) } }