diff --git a/go.mod b/go.mod index a538dcd..f77cf23 100644 --- a/go.mod +++ b/go.mod @@ -12,12 +12,14 @@ require ( github.com/json-iterator/go v1.1.12 github.com/lib/pq v1.10.9 github.com/nats-io/nats.go v1.37.0 + github.com/nicksnyder/go-i18n/v2 v2.5.0 github.com/robfig/cron/v3 v3.0.1 github.com/rs/zerolog v1.33.0 github.com/samber/lo v1.47.0 github.com/spf13/viper v1.19.0 github.com/valyala/fasthttp v1.57.0 go.etcd.io/etcd/client/v3 v3.5.16 + golang.org/x/text v0.21.0 google.golang.org/grpc v1.67.1 google.golang.org/protobuf v1.35.1 gorm.io/datatypes v1.2.4 @@ -78,9 +80,8 @@ require ( golang.org/x/crypto v0.28.0 // indirect golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect golang.org/x/net v0.30.0 // indirect - golang.org/x/sync v0.8.0 // indirect + golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.26.0 // indirect - golang.org/x/text v0.19.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20241021214115-324edc3d5d38 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index a2d341d..5d828ed 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= @@ -108,6 +110,8 @@ github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI= github.com/nats-io/nkeys v0.4.7/go.mod h1:kqXRgRDPlGy7nGaEDMuYzmiJCIAAWDK0IMBtDmGD0nc= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/nicksnyder/go-i18n/v2 v2.5.0 h1:3wH1gpaekcgGuwzWdSu7JwJhH9Tk87k1ezt0i1p2/Is= +github.com/nicksnyder/go-i18n/v2 v2.5.0/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= @@ -193,8 +197,8 @@ golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -205,8 +209,8 @@ golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= -golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= diff --git a/pkg/nex/localize/bundle.go b/pkg/nex/localize/bundle.go new file mode 100644 index 0000000..9b98720 --- /dev/null +++ b/pkg/nex/localize/bundle.go @@ -0,0 +1,139 @@ +package localize + +import ( + "errors" + "fmt" + "github.com/goccy/go-json" + "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/rs/zerolog/log" + "golang.org/x/text/language" + htmpl "html/template" + "os" + "path/filepath" + "strings" + "text/template" +) + +type Bundle struct { + Bundle *i18n.Bundle + + LocalesPath string + TemplatesPath string +} + +const FallbackLanguage = "en-US" + +var L *Bundle + +func LoadLocalization(localesPath string, templatesPath ...string) error { + L = &Bundle{ + LocalesPath: localesPath, + } + if len(templatesPath) > 0 { + L.TemplatesPath = templatesPath[0] + } + + L.Bundle = i18n.NewBundle(language.AmericanEnglish) + L.Bundle.RegisterUnmarshalFunc("json", json.Unmarshal) + + var count int + + basePath := localesPath + if entries, err := os.ReadDir(basePath); err != nil { + return fmt.Errorf("unable to read locales directory: %v", err) + } else { + for _, entry := range entries { + if entry.IsDir() { + continue + } + if _, err := L.Bundle.LoadMessageFile(filepath.Join(basePath, entry.Name())); err != nil { + return fmt.Errorf("unable to load localization file %s: %v", entry.Name(), err) + } else { + count++ + } + } + } + + log.Info().Int("locales", count).Msg("Loaded localization files...") + + return nil +} + +func (v *Bundle) GetLocalizer(lang string) *i18n.Localizer { + return i18n.NewLocalizer(v.Bundle, lang) +} + +func (v *Bundle) GetLocalizedString(name string, lang string) string { + localizer := v.GetLocalizer(lang) + msg, err := localizer.LocalizeMessage(&i18n.Message{ + ID: name, + }) + if err != nil { + log.Warn().Err(err).Str("lang", lang).Str("name", name).Msg("Failed to localize string...") + return name + } + return msg +} + +func (v *Bundle) GetLocalizedTemplatePath(name string, lang string) string { + basePath := v.TemplatesPath + filePath := filepath.Join(basePath, lang, name) + + if _, err := os.Stat(filePath); errors.Is(err, os.ErrNotExist) { + // Fallback to English + filePath = filepath.Join(basePath, FallbackLanguage, name) + return filePath + } + + return filePath +} + +func (v *Bundle) GetLocalizedTemplate(name string, lang string) *template.Template { + path := v.GetLocalizedTemplatePath(name, lang) + tmpl, err := template.ParseFiles(path) + if err != nil { + log.Warn().Err(err).Str("lang", lang).Str("name", name).Msg("Failed to load localized template...") + return nil + } + + return tmpl +} + +func (v *Bundle) GetLocalizedTemplateHTML(name string, lang string) *htmpl.Template { + path := v.GetLocalizedTemplatePath(name, lang) + tmpl, err := htmpl.ParseFiles(path) + if err != nil { + log.Warn().Err(err).Str("lang", lang).Str("name", name).Msg("Failed to load localized template...") + return nil + } + + return tmpl +} + +func (v *Bundle) RenderLocalizedTemplateHTML(name string, lang string, data any) string { + tmpl := v.GetLocalizedTemplate(name, lang) + if tmpl == nil { + return "" + } + buf := new(strings.Builder) + err := tmpl.Execute(buf, data) + if err != nil { + log.Warn().Err(err).Str("lang", lang).Str("name", name).Msg("Failed to render localized template...") + return "" + } + return buf.String() +} + +func (v *Bundle) RenderLocalizedTemplate(name string, lang string, data any) string { + tmpl := v.GetLocalizedTemplate(name, lang) + if tmpl == nil { + return "" + } + buf := new(strings.Builder) + err := tmpl.Execute(buf, data) + if err != nil { + log.Warn().Err(err).Str("lang", lang).Str("name", name).Msg("Failed to render localized template...") + return "" + } + return buf.String() +}