package i18n import ( "context" "encoding/json" "errors" "fmt" "git.noahlan.cn/noahlan/ntool/nlog" "github.com/BurntSushi/toml" "github.com/nicksnyder/go-i18n/v2/i18n" "github.com/radovskyb/watcher" "golang.org/x/text/language" "gopkg.in/yaml.v3" "os" "strings" "sync" "time" ) // Translator is a struct storing translating data. type Translator struct { BundleConfig bundle *i18n.Bundle localizerMap map[string]*i18n.Localizer supportedFormat []string mu sync.RWMutex } type Option func(*Translator) func NewTranslator(opts ...Option) *Translator { ret := &Translator{ localizerMap: make(map[string]*i18n.Localizer), BundleConfig: defaultBundleConfig, } for _, opt := range opts { opt(ret) } ret.bundle = i18n.NewBundle(ret.DefaultLanguage) ret.RegisterUnmarshalFunc("json", json.Unmarshal) ret.RegisterUnmarshalFunc("toml", toml.Unmarshal) ret.RegisterUnmarshalFunc("yaml", yaml.Unmarshal) // init and watcher files := ret.watcher() lngs := make([]string, len(files)) for i, file := range files { lngs[i] = filenameWithoutExt(file.Name) if err := ret.loadFileByPath(file.Path); err != nil { nlog.Debugf("load locale file err: %v\n", err) } } ret.configLocalizer(lngs...) return ret } func (l *Translator) RegisterUnmarshalFunc(format string, fn i18n.UnmarshalFunc) { if l.supportedFormat == nil { l.supportedFormat = make([]string, 0) } l.supportedFormat = append(l.supportedFormat, format) l.bundle.RegisterUnmarshalFunc(format, fn) } // Trans translates the message of message's ID func (l *Translator) Trans(ctx context.Context, messageID string) string { s, err := l.TransAny(ctx, messageID) if err != nil || s == "" { return messageID } return s } // TransAny translates the message of any param func (l *Translator) TransAny(ctx context.Context, param any) (string, error) { lngTag := l.TagGetter(ctx) if lngTag == "" { lngTag = l.DefaultLanguage.String() } localizer := l.getLocalizerByTag(lngTag) config, err := l.getLocalizeConfig(param) if err != nil { return "", err } message, err := localizer.Localize(config) if err != nil { return "", err } return message, nil } func (l *Translator) loadFileByPath(path string) error { buf, err := l.FileLoader.LoadMessage(path) if err != nil { return err } if _, err = l.bundle.ParseMessageFileBytes(buf, path); err != nil { return err } return nil } // watcher 监控给定Path的文件数据变化,实时更改localizer,以实现语言文件热更新 func (l *Translator) watcher() []struct { Path string Name string } { w := watcher.New() w.FilterOps(watcher.Create, watcher.Remove, watcher.Write, watcher.Rename) w.AddFilterHook(func(info os.FileInfo, fullPath string) error { if info.IsDir() { return watcher.ErrSkip } if info.Size() <= 0 { return watcher.ErrSkip } name := info.Name() for _, format := range l.supportedFormat { if strings.HasSuffix(name, format) { return nil } } // 文件后缀不支持,直接跳过 return watcher.ErrSkip }) go func() { for { select { case event := <-w.Event: if event.IsDir() { continue } filePrefix := filenameWithoutExt(event.Name()) lang, err := language.Parse(filePrefix) if err != nil { nlog.Debugf("cannot parse filename:%s to language.Tag, process will continue.", filePrefix) continue } path := event.Path switch event.Op { case watcher.Remove: l.removeLocalizer(lang.String()) case watcher.Create, watcher.Write: if err := l.loadFileByPath(path); err != nil { nlog.Debugf("load locale file err [Create] or [Write], %+v", err) continue } l.configLocalizer(lang.String()) case watcher.Rename: l.removeLocalizer(lang.String()) newLangStr := filenameWithoutExt(path) newLang, err := language.Parse(newLangStr) if err != nil { continue } if err := l.loadFileByPath(path); err != nil { nlog.Debugf("load locale file err [Rename], %+v", err) continue } l.configLocalizer(newLang.String()) } case err := <-w.Error: nlog.Errorf("watch locale file err: %+v", err) case <-w.Closed: nlog.Debugf("i18n watcher closed...") return } } }() if err := w.Add(l.RootPath); err != nil { nlog.Errorf("[i18n] watching %s err:%v", l.RootPath, err) w.Closed <- struct{}{} return nil } go func() { _ = w.Start(time.Second * 5) }() watchedFiles := w.WatchedFiles() ret := make([]struct { Path string Name string }, 0, len(watchedFiles)) for path, info := range watchedFiles { if info.IsDir() { continue } if info.Size() <= 0 { continue } name := info.Name() for _, format := range l.supportedFormat { if strings.HasSuffix(name, format) { ret = append(ret, struct { Path string Name string }{Path: path, Name: info.Name()}) } } } return ret } // getLocalizerByTag get localizer by language func (l *Translator) getLocalizerByTag(tag string) *i18n.Localizer { l.mu.RLock() defer l.mu.RUnlock() if localizer, ok := l.localizerMap[tag]; ok { return localizer } return l.localizerMap[l.DefaultLanguage.String()] } // getLocalizerByTag get localizer by language func (l *Translator) getLocalizeConfig(param any) (*i18n.LocalizeConfig, error) { switch t := param.(type) { case string: return &i18n.LocalizeConfig{ DefaultMessage: &i18n.Message{ID: t}, }, nil case *MessageWithParameter: // 转化 template data return &i18n.LocalizeConfig{ TemplateData: l.buildTemplateData(t.Params, t.ParamsWithName), DefaultMessage: &i18n.Message{ID: t.MessageID}, }, nil case *i18n.LocalizeConfig: return t, nil } // extends localizerConfig getter if ret := l.LocalizeConfigGetter(l.BundleConfig, param); ret != nil { return ret, nil } return nil, errors.New(fmt.Sprintf("un supported localize param: %v", param)) } // buildTemplateData 构建TemplateData func (l *Translator) buildTemplateData(params []any, paramsWithName map[string]any) map[string]any { if len(params) == 0 { return paramsWithName } data := make(map[string]any) for i, param := range params { data[fmt.Sprintf("%s%d", l.SortedParameterPrefix, i)] = param } return data } // configLocalizer set localizer by language func (l *Translator) configLocalizer(acceptLanguage ...string) { l.mu.Lock() defer l.mu.Unlock() for _, lng := range acceptLanguage { if lng == "" { continue } if _, ok := l.localizerMap[lng]; !ok { l.localizerMap[lng] = l.newLocalizer(lng) } } // configure default defaultLng := l.DefaultLanguage.String() if _, ok := l.localizerMap[defaultLng]; !ok { l.localizerMap[defaultLng] = l.newLocalizer(defaultLng) } } // removeLocalizer remove localizer by tag string func (l *Translator) removeLocalizer(lang ...string) { l.mu.Lock() defer l.mu.Unlock() for _, lng := range lang { delete(l.localizerMap, lng) } } // newLocalizer create a localizer by language func (l *Translator) newLocalizer(lng string) *i18n.Localizer { lngDefault := l.DefaultLanguage.String() lngs := []string{lng} if lng != lngDefault { lngs = append(lngs, lngDefault) } localizer := i18n.NewLocalizer( l.bundle, lngs..., ) return localizer }