From 3cfda09cedc33b93ec57f56d89013f35dff32e60 Mon Sep 17 00:00:00 2001 From: derfenix Date: Tue, 18 Oct 2022 15:44:29 +0300 Subject: [PATCH] Initial commit --- .gitignore | 13 ++ README.md | 1 + context.go | 42 ++++++ error.go | 63 +++++++++ error_test.go | 31 +++++ example/example.go | 42 ++++++ example/example_test.go | 17 +++ example/locales/en/active.json | 38 ++++++ example/locales/ru/active.json | 38 ++++++ go.mod | 15 +++ go.sum | 19 +++ internal/external.go | 93 +++++++++++++ internal/loader.go | 239 +++++++++++++++++++++++++++++++++ internal/loader_test.go | 51 +++++++ internal/testsfs.go | 57 ++++++++ translator.go | 111 +++++++++++++++ translator_extra_test.go | 89 ++++++++++++ translator_test.go | 106 +++++++++++++++ 18 files changed, 1065 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 context.go create mode 100644 error.go create mode 100644 error_test.go create mode 100644 example/example.go create mode 100644 example/example_test.go create mode 100644 example/locales/en/active.json create mode 100644 example/locales/ru/active.json create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/external.go create mode 100644 internal/loader.go create mode 100644 internal/loader_test.go create mode 100644 internal/testsfs.go create mode 100644 translator.go create mode 100644 translator_extra_test.go create mode 100644 translator_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..426b527 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +*~ +.fuse_hidden* +.directory +.Trash-* +.nfs* +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +.idea diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a2d809 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Easy Golang Internationalization diff --git a/context.go b/context.go new file mode 100644 index 0000000..0b7731c --- /dev/null +++ b/context.go @@ -0,0 +1,42 @@ +package i18n + +import ( + "context" + + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +type i18nCtxType uint8 + +const ( + langCtxKey i18nCtxType = iota + printerCtxKey +) + +func ContextWithLang(ctx context.Context, lang language.Tag) context.Context { + ctx = context.WithValue(ctx, langCtxKey, lang) + ctx = context.WithValue(ctx, printerCtxKey, GetPrinter(lang)) + + return ctx +} + +func PrinterFromContext(ctx context.Context) *message.Printer { + if p, ok := ctx.Value(printerCtxKey).(*message.Printer); ok { + return p + } + + return GetPrinter(LanguageFromContext(ctx)) +} + +func Sprintf(ctx context.Context, val string, args ...interface{}) string { + return PrinterFromContext(ctx).Sprintf(val, args...) +} + +func LanguageFromContext(ctx context.Context) language.Tag { + if lang, ok := ctx.Value(langCtxKey).(language.Tag); ok { + return lang + } + + return defaultLanguage +} diff --git a/error.go b/error.go new file mode 100644 index 0000000..56825b9 --- /dev/null +++ b/error.go @@ -0,0 +1,63 @@ +package i18n + +import ( + "context" + "fmt" + + "github.com/pkg/errors" +) + +type TranslatableError interface { + error + Translatable +} + +func TryTranslateError(ctx context.Context, err error) (string, bool) { + var translatable TranslatableError + + if errors.As(err, &translatable) { + return translatable.Translate(ctx), true + } + + return "", false +} + +func NewError(key string) *Error { + return &Error{key: key} +} + +type Error struct { + key string + params []interface{} +} + +func (e *Error) Error() string { + return e.key +} + +func (e *Error) WithParams(params ...interface{}) *Error { + newErr := *e + + newErr.params = params + + return &newErr +} + +func (e *Error) Translate(ctx context.Context) string { + printer := PrinterFromContext(ctx) + + translatedParams := make([]interface{}, len(e.params)) + + for idx, param := range e.params { + switch typed := param.(type) { + case string: + translatedParams[idx] = printer.Sprintf(typed) + case fmt.Stringer: + translatedParams[idx] = printer.Sprintf(typed.String()) + default: + translatedParams[idx] = param + } + } + + return printer.Sprintf(e.key, translatedParams...) +} diff --git a/error_test.go b/error_test.go new file mode 100644 index 0000000..b666757 --- /dev/null +++ b/error_test.go @@ -0,0 +1,31 @@ +package i18n_test + +import ( + "context" + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + + . "github.com/derfenix/goi18n" + "github.com/derfenix/goi18n/internal" +) + +func TestTryTranslateError(t *testing.T) { + t.Parallel() + + require.NoError(t, Init(internal.TestFS)) + + err := NewError("test").WithParams("book") + wrapped := errors.Wrap(err, "foo bar") + + errorString, translated := TryTranslateError(context.Background(), wrapped) + require.True(t, translated) + assert.Equal(t, "Тест book", errorString) + + errorString, translated = TryTranslateError(ContextWithLang(context.Background(), language.English), wrapped) + require.True(t, translated) + assert.Equal(t, "Test of the book", errorString) +} diff --git a/example/example.go b/example/example.go new file mode 100644 index 0000000..71bbf07 --- /dev/null +++ b/example/example.go @@ -0,0 +1,42 @@ +package example + +import ( + "context" + "embed" + "fmt" + + "golang.org/x/text/language" + "golang.org/x/text/language/display" + + i18n "github.com/derfenix/goi18n" +) + +//go:embed locales/**/*.json +var locales embed.FS + +func Basic() { + if err := i18n.Init(locales); err != nil { + panic(err) + } + + printer := i18n.GetPrinter(language.Russian) + fmt.Println(printer.Sprintf("test", "теста")) + + printer = i18n.GetPrinter(language.English) + fmt.Println(printer.Sprintf("test", "beer")) + + ctx := i18n.ContextWithLang(context.Background(), language.Russian) + handler(ctx) + + ctx = i18n.ContextWithLang(context.Background(), language.English) + handler(ctx) +} + +func handler(ctx context.Context) { + translated := i18n.Sprintf(ctx, "test plural", 2) + fmt.Println(translated) + + lang := i18n.LanguageFromContext(ctx) + script, _ := lang.Script() + fmt.Println("Использован", display.Languages(language.Russian).Name(lang), "язык,", display.Scripts(language.Russian).Name(script)) +} diff --git a/example/example_test.go b/example/example_test.go new file mode 100644 index 0000000..04f4275 --- /dev/null +++ b/example/example_test.go @@ -0,0 +1,17 @@ +package example_test + +import ( + "github.com/derfenix/goi18n/example" +) + +func ExampleBasic() { + example.Basic() + + // Output: + // Тест теста + // Test of the beer + // всего пара пауков + // Использован русский язык, кириллица + // just pair of spiders + // Использован английский язык, латиница +} diff --git a/example/locales/en/active.json b/example/locales/en/active.json new file mode 100644 index 0000000..4cfa289 --- /dev/null +++ b/example/locales/en/active.json @@ -0,0 +1,38 @@ +[ + { + "key": "test", + "description": "Для тестов, не трогать", + "translation": "Test of the %s" + }, + { + "key": "test plural", + "description": "Для тестов, не трогать", + "plural": { + "other": "exactly %d spiders", + "one": "spider", + "=0": "no spiders", + "=2": "just pair of spiders" + } + }, + { + "key": "transition_not_allowed", + "description": "Ошибка при попытке произвести запрещённое изменение состояния", + "translation": "Transition from '%s' to '%s' not allowed" + }, + { + "key": "Should be shorter than %d symbols", + "translation": "Should be shorter than %d symbols" + }, + { + "key": "Should be longer than %d symbols", + "translation": "Should be longer than %d symbols" + }, + { + "key": "Deleted", + "translation": "Deleted" + }, + { + "key": "Published", + "translation": "Published" + } +] diff --git a/example/locales/ru/active.json b/example/locales/ru/active.json new file mode 100644 index 0000000..24d21eb --- /dev/null +++ b/example/locales/ru/active.json @@ -0,0 +1,38 @@ +[ + { + "key": "test", + "description": "Для тестов, не трогать", + "translation": "Тест %s" + }, + { + "key": "test plural", + "description": "Для тестов, не трогать", + "plural": { + "other": "всего %d пауков", + "one": "паучок", + "=0": "нет пауков", + "=2": "всего пара пауков" + } + }, + { + "key": "transition_not_allowed", + "description": "Ошибка при попытке произвести запрещённое изменение состояния", + "translation": "Переход от '%s' к '%s' запрещён" + }, + { + "key": "Should be shorter than %d symbols", + "translation": "Должно быть короче %d символов" + }, + { + "key": "Should be longer than %d symbols", + "translation": "Должно быть длиннее %d символов" + }, + { + "key": "Deleted", + "translation": "Удалён" + }, + { + "key": "Published", + "translation": "Опубликован" + } +] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8ad9849 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module github.com/derfenix/goi18n + +go 1.18 + +require ( + github.com/pkg/errors v0.9.1 + github.com/stretchr/testify v1.8.0 + golang.org/x/text v0.4.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4337509 --- /dev/null +++ b/go.sum @@ -0,0 +1,19 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/external.go b/internal/external.go new file mode 100644 index 0000000..0b1e75d --- /dev/null +++ b/internal/external.go @@ -0,0 +1,93 @@ +package internal + +import ( + "context" + "net" + "net/http" + "net/url" + "time" + + "github.com/pkg/errors" + "golang.org/x/text/language" + "golang.org/x/text/message/catalog" +) + +var ErrInvalidResponseCode = errors.New("invalid response status code") + +func NewExternalLoader(baseURL string, header http.Header) *ExternalLoader { + client := http.Client{ + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 2 * time.Second, + }).DialContext, + DisableKeepAlives: true, + MaxIdleConns: 1, + MaxIdleConnsPerHost: 1, + MaxConnsPerHost: 2, + IdleConnTimeout: 30 * time.Second, + ResponseHeaderTimeout: 5 * time.Second, + MaxResponseHeaderBytes: 1024 * 3, + WriteBufferSize: 100, + ReadBufferSize: 1024 * 8, + }, + Timeout: time.Second * 10, + } + + return NewExternalLoaderWithClient(baseURL, header, &client) +} + +func NewExternalLoaderWithClient(baseURL string, header http.Header, client *http.Client) *ExternalLoader { + return &ExternalLoader{baseURL: baseURL, client: client, header: header} +} + +type ExternalLoader struct { + baseURL string + header http.Header + client *http.Client +} + +func (e *ExternalLoader) Load(builder *catalog.Builder) error { + languages := builder.Languages() + + for _, lang := range languages { + if err := e.load(lang, builder); err != nil { + return errors.WithMessagef(err, "load translation for %s", lang.String()) + } + } + + return nil +} + +func (e *ExternalLoader) load(lang language.Tag, builder *catalog.Builder) error { + langURL, err := url.JoinPath(e.baseURL, lang.String()) + if err != nil { + return errors.Wrap(err, "join url path") + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, langURL, nil) + if err != nil { + return errors.Wrap(err, "new request") + } + + if e.header != nil { + req.Header = e.header + } + + response, err := e.client.Do(req) + if err != nil { + return errors.Wrap(err, "do request") + } + + if response.StatusCode != http.StatusOK { + return errors.Wrapf(ErrInvalidResponseCode, "got status %d", response.StatusCode) + } + + if err := load(response.Body, lang, builder); err != nil { + return errors.WithMessage(err, "load translation") + } + + return nil +} diff --git a/internal/loader.go b/internal/loader.go new file mode 100644 index 0000000..8cdea06 --- /dev/null +++ b/internal/loader.go @@ -0,0 +1,239 @@ +package internal + +import ( + "encoding/json" + "io" + "io/fs" + "path" + + "github.com/pkg/errors" + "golang.org/x/text/feature/plural" + "golang.org/x/text/language" + "golang.org/x/text/message/catalog" +) + +type Loader interface { + Load(cat *catalog.Builder) error +} + +var ( + extendBuilder func(builder *catalog.Builder) error + extLoader Loader +) + +func SetExtendBuilder(b func(builder *catalog.Builder) error) { + extendBuilder = b +} + +func SetExternalLoader(loader Loader) { + extLoader = loader +} + +func RefreshTranslations(builder *catalog.Builder) error { + if extLoader == nil { + return nil + } + + if err := extLoader.Load(builder); err != nil { + return errors.WithMessage(err, "load translations from external") + } + + return nil +} + +type Translation struct { + Key string `json:"key"` + Description string `json:"description"` + Translation string `json:"translation"` + Plural *plurals `json:"plural"` +} + +type pluralsBase struct { + Zero string `json:"zero"` + One string `json:"one"` + Two string `json:"two"` + Few string `json:"few"` + Many string `json:"many"` + Other string `json:"other"` +} + +type plurals struct { + pluralsBase + Custom map[string]string `json:"-"` +} + +func (p *plurals) UnmarshalJSON(data []byte) error { + var ( + base pluralsBase + val map[string]string + ) + + if err := json.Unmarshal(data, &base); err != nil { + return errors.Wrap(err, "unmarshal base") + } + + if err := json.Unmarshal(data, &val); err != nil { + return errors.Wrap(err, "unmarshal custom") + } + + // FIXME Looks hacky, there should be a better way + delete(val, "one") + delete(val, "zero") + delete(val, "two") + delete(val, "few") + delete(val, "may") + delete(val, "other") + + p.pluralsBase = base + p.Custom = val + + return nil +} + +func (p *plurals) cases() (cases []interface{}) { + capacity := 12 + if len(p.Custom) > 0 { + capacity += len(p.Custom) * 2 + } + + cases = make([]interface{}, 0, capacity) + + if p.Custom != nil { + for cond, val := range p.Custom { + cases = append(cases, cond, val) + } + } + + if p.Zero != "" { + cases = append(cases, plural.Zero, p.Zero) + } + + if p.One != "" { + cases = append(cases, plural.One, p.One) + } + + if p.Two != "" { + cases = append(cases, plural.Two, p.Two) + } + + if p.Few != "" { + cases = append(cases, plural.Few, p.Few) + } + + if p.Many != "" { + cases = append(cases, plural.Many, p.Many) + } + + if p.Other != "" { + cases = append(cases, plural.Other, p.Other) + } + + return cases +} + +func InitBuilder(fs fs.ReadDirFS) (*catalog.Builder, error) { + cat := catalog.NewBuilder() + + if err := loadTranslations(fs, cat); err != nil { + return nil, errors.Wrap(err, "load translations") + } + + if extendBuilder != nil { + if err := extendBuilder(cat); err != nil { + return nil, errors.Wrap(err, "extend builder") + } + } + + return cat, nil +} + +func loadTranslations(files fs.ReadDirFS, cat *catalog.Builder) error { + dir, err := files.ReadDir("locales") + if err != nil { + return errors.Wrap(err, "read locales dir") + } + + for _, entry := range dir { + if !entry.IsDir() { + continue + } + + lang, err := language.Parse(entry.Name()) + if err != nil { + return errors.Wrapf(err, "parse language %s", entry.Name()) + } + + subDirPath := path.Join("locales", entry.Name()) + + filePath := path.Join(subDirPath, "active.json") + + reader, err := files.Open(filePath) + if err != nil { + return errors.Wrapf(err, "open file %s", filePath) + } + + if err := load(reader, lang, cat); err != nil { + if cErr := reader.Close(); cErr != nil { + _ = cErr + } + + return errors.Wrapf(err, "load translations from %s", filePath) + } + + if err := reader.Close(); err != nil { + return errors.Wrapf(err, "close file %s", filePath) + } + } + + if extLoader != nil { + if err := extLoader.Load(cat); err != nil { + return errors.WithMessage(err, "load translations from external") + } + } + + return nil +} + +func load(r io.Reader, lang language.Tag, cat *catalog.Builder) error { + var translations []Translation + if err := json.NewDecoder(r).Decode(&translations); err != nil { + return errors.Wrap(err, "decode translation") + } + + for idx := range translations { + trans := &translations[idx] + + switch { + case trans.Plural != nil: + count, format := getPlaceholders(trans.Plural.Other) + + msg := plural.Selectf(count, format, trans.Plural.cases()...) + + if err := cat.Set(lang, trans.Key, msg); err != nil { + return errors.Wrapf(err, "set message for %s", trans.Key) + } + + case trans.Translation != "": + if err := cat.Set(lang, trans.Key, catalog.String(trans.Translation)); err != nil { + return errors.Wrapf(err, "set string for %s", trans.Key) + } + } + } + + return nil +} + +func getPlaceholders(s string) (count int, format string) { + for idx := 0; idx < len(s); idx++ { + if s[idx] == '%' { + count++ + + if format == "" { + format = s[idx : idx+1] + } + idx++ + } + } + + return count, format +} diff --git a/internal/loader_test.go b/internal/loader_test.go new file mode 100644 index 0000000..0ea6dfe --- /dev/null +++ b/internal/loader_test.go @@ -0,0 +1,51 @@ +package internal_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + . "github.com/derfenix/goi18n/internal" +) + +var vvv = `[ + { + "key": "test", + "description": "Для тестов, не трогать", + "translation": "Тест %s" + }, + { + "key": "test plural", + "description": "Для тестов, не трогать", + "plural": { + "other": "всего %d пауков", + "one": "паучок", + "=0": "нет пауков", + "=2": "всего пара пауков" + } + }, + { + "key": "transition_not_allowed", + "description": "Ошибка при попытке произвести запрещённое изменение состояния", + "translation": "Переход запрещён" + }, + { + "key": "Should be shorter than %d symbols", + "translation": "Должно быть короче %d символов" + }, + { + "key": "Should be longer than %d symbols", + "translation": "Должно быть длиннее %d символов" + } +]` + +func Benchmark_plurals_UnmarshalJSON(b *testing.B) { + var trans []Translation + + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + require.NoError(b, json.Unmarshal([]byte(vvv), &trans)) + } +} diff --git a/internal/testsfs.go b/internal/testsfs.go new file mode 100644 index 0000000..bb2bda2 --- /dev/null +++ b/internal/testsfs.go @@ -0,0 +1,57 @@ +package internal + +import ( + "io/fs" + "testing/fstest" + "time" +) + +var TestFS = fstest.MapFS{ + "locales": &fstest.MapFile{Mode: 0777 | fs.ModeDir}, + "locales/ru": &fstest.MapFile{Mode: 0777 | fs.ModeDir}, + "locales/en": &fstest.MapFile{Mode: 0777 | fs.ModeDir}, + "locales/ru/active.json": &fstest.MapFile{ + Data: []byte(`[ + { + "key": "test", + "description": "Для тестов, не трогать", + "translation": "Тест %s" + }, + { + "key": "test plural", + "description": "Для тестов, не трогать", + "plural": { + "other": "всего %d пауков", + "one": "паучок", + "=0": "нет пауков", + "=2": "всего пара пауков" + } + } +]`), + Mode: 0555, + ModTime: time.Now(), + Sys: 1, + }, + "locales/en/active.json": &fstest.MapFile{ + Data: []byte(`[ + { + "key": "test", + "description": "Для тестов, не трогать", + "translation": "Test of the %s" + }, + { + "key": "test plural", + "description": "Для тестов, не трогать", + "plural": { + "other": "exactly %d spiders", + "one": "spider", + "=0": "no spiders", + "=2": "just pair of spiders" + } + } +]`), + Mode: 0555, + ModTime: time.Now(), + Sys: 1, + }, +} diff --git a/translator.go b/translator.go new file mode 100644 index 0000000..474c6d1 --- /dev/null +++ b/translator.go @@ -0,0 +1,111 @@ +package i18n + +import ( + "context" + "io/fs" + "net/http" + + "github.com/pkg/errors" + "golang.org/x/text/language" + "golang.org/x/text/message" + "golang.org/x/text/message/catalog" + + "github.com/derfenix/goi18n/internal" +) + +var defaultLanguage = language.Russian + +var ( + builder *catalog.Builder + + supportedLanguages []language.Tag + supportedLanguagesMap = map[string]struct{}{} +) + +func NewExternalLoader(baseURL string, header http.Header) *internal.ExternalLoader { + return internal.NewExternalLoader(baseURL, header) +} + +func NewExternalLoaderWithClient(baseURL string, header http.Header, client *http.Client) *internal.ExternalLoader { + return internal.NewExternalLoaderWithClient(baseURL, header, client) +} + +type Translatable interface { + Translate(ctx context.Context) string +} + +func TryTranslate(ctx context.Context, obj interface{}) (string, bool) { + if translatable, ok := obj.(Translatable); ok { + return translatable.Translate(ctx), true + } + + return "", false +} + +func Init(fs fs.ReadDirFS) error { + if builder != nil { + return nil + } + + initCatalog, err := internal.InitBuilder(fs) + if err != nil { + return errors.Wrap(err, "init catalog") + } + + builder = initCatalog + + // Fill the local cache + GetLanguages() + + return nil +} + +func GetPrinter(lang language.Tag) *message.Printer { + _ = GetLanguages() + + base, _ := lang.Base() + if _, ok := supportedLanguagesMap[lang.String()]; !ok { + if _, ok = supportedLanguagesMap[base.String()]; !ok { + lang = defaultLanguage + } + } + + p := message.NewPrinter(lang, message.Catalog(builder)) + + return p +} + +func GetLanguages() []language.Tag { + if supportedLanguages == nil { + supportedLanguages = builder.Languages() + + for idx := range supportedLanguages { + supportedLanguagesMap[supportedLanguages[idx].String()] = struct{}{} + + base, _ := supportedLanguages[idx].Base() + supportedLanguagesMap[base.String()] = struct{}{} + } + } + + return supportedLanguages +} + +func RefreshTranslations() error { + if builder == nil { + return nil + } + + if err := internal.RefreshTranslations(builder); err != nil { + return errors.WithMessage(err, "refresh translations") + } + + return nil +} + +func SetExternalBuilder(b func(builder *catalog.Builder) error) { + internal.SetExtendBuilder(b) +} + +func SetExternalLoader(loader internal.Loader) { + internal.SetExternalLoader(loader) +} diff --git a/translator_extra_test.go b/translator_extra_test.go new file mode 100644 index 0000000..4ddfa0c --- /dev/null +++ b/translator_extra_test.go @@ -0,0 +1,89 @@ +//go:build i18n_extra + +package i18n + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + "golang.org/x/text/message/catalog" + + "github.com/derfenix/goi18n/internal" +) + +func TestOverrideTranslation(t *testing.T) { + t.Parallel() + + SetExternalBuilder(func(builder *catalog.Builder) error { + if err := builder.Set(language.Russian, "test", catalog.String("Тост %s")); err != nil { + return err + } + + return nil + }) + + require.NoError(t, Init(internal.TestFS)) + + translated := GetPrinter(language.Russian).Sprintf("test", "был") + assert.Equal(t, "Тост был", translated) +} + +func TestExternalLoader(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + case "/en": + _, _ = w.Write([]byte(`[{"key": "Published","translation": "Foo"}]`)) + case "/ru": + _, _ = w.Write([]byte(`[{"key": "Published","translation": "Буу"}]`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + + SetExternalLoader(NewExternalLoader(srv.URL, http.Header{})) + require.NoError(t, Init(internal.TestFS)) + + { + translated := GetPrinter(language.Russian).Sprintf("Published") + assert.Equal(t, "Буу", translated) + } + + { + translated := GetPrinter(language.English).Sprintf("Published") + assert.Equal(t, "Foo", translated) + } + + t.Run("refresh", func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + case "/en": + _, _ = w.Write([]byte(`[{"key": "Published","translation": "Bar"}]`)) + case "/ru": + _, _ = w.Write([]byte(`[{"key": "Published","translation": "Бяя"}]`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + + SetExternalLoader(NewExternalLoader(srv.URL, http.Header{})) + require.NoError(t, RefreshTranslations()) + + { + translated := GetPrinter(language.Russian).Sprintf("Published") + assert.Equal(t, "Бяя", translated) + } + + { + translated := GetPrinter(language.English).Sprintf("Published") + assert.Equal(t, "Bar", translated) + } + }) +} diff --git a/translator_test.go b/translator_test.go new file mode 100644 index 0000000..d72779b --- /dev/null +++ b/translator_test.go @@ -0,0 +1,106 @@ +//go:build !i18n_extra + +package i18n_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + + . "github.com/derfenix/goi18n" + "github.com/derfenix/goi18n/internal" +) + +var supportedLanguages = []language.Tag{language.Russian, language.English} + +func TestPrinter(t *testing.T) { + t.Parallel() + + require.NoError(t, Init(internal.TestFS)) + + t.Run("translation engaged", func(t *testing.T) { + t.Parallel() + + t.Run("russian", func(t *testing.T) { + t.Parallel() + + printer := GetPrinter(language.Russian) + require.Equal(t, "Тест пива", printer.Sprintf("test", "пива")) + assert.Equal(t, "111,223", printer.Sprint(111.223)) + }) + + t.Run("russian (extended)", func(t *testing.T) { + t.Parallel() + + printer := GetPrinter(language.MustParse("ru_RU")) + require.Equal(t, "Тест пива", printer.Sprintf("test", "пива")) + assert.Equal(t, "111,223", printer.Sprint(111.223)) + }) + + t.Run("russian (extended kz)", func(t *testing.T) { + t.Parallel() + + printer := GetPrinter(language.MustParse("ru_KZ")) + require.Equal(t, "Тест пива", printer.Sprintf("test", "пива")) + assert.Equal(t, "111,223", printer.Sprint(111.223)) + }) + + t.Run("english (exactly)", func(t *testing.T) { + t.Parallel() + + printer := GetPrinter(language.English) + require.Equal(t, "Test of the beer", printer.Sprintf("test", "beer")) + assert.Equal(t, "111.223", printer.Sprint(111.223)) + }) + + t.Run("english (dialect)", func(t *testing.T) { + t.Parallel() + + printer := GetPrinter(language.AmericanEnglish) + require.Equal(t, "Test of the gun", printer.Sprintf("test", "gun")) + assert.Equal(t, "111.223", printer.Sprint(111.223)) + }) + + t.Run("unsupported language", func(t *testing.T) { + t.Parallel() + + printer := GetPrinter(language.Albanian) + require.Equal(t, "Тест пива", printer.Sprintf("test", "пива")) + assert.Equal(t, "111,223", printer.Sprint(111.223)) + }) + + t.Run("plural", func(t *testing.T) { + t.Parallel() + + t.Run("russian", func(t *testing.T) { + t.Parallel() + + printer := GetPrinter(language.Russian) + assert.Equal(t, "всего 100 пауков", printer.Sprintf("test plural", 100)) + assert.Equal(t, "нет пауков", printer.Sprintf("test plural", 0)) + assert.Equal(t, "паучок", printer.Sprintf("test plural", 1)) + assert.Equal(t, "всего пара пауков", printer.Sprintf("test plural", 2)) + }) + + t.Run("english", func(t *testing.T) { + t.Parallel() + + printer := GetPrinter(language.English) + assert.Equal(t, "exactly 100 spiders", printer.Sprintf("test plural", 100)) + assert.Equal(t, "no spiders", printer.Sprintf("test plural", 0)) + assert.Equal(t, "spider", printer.Sprintf("test plural", 1)) + assert.Equal(t, "just pair of spiders", printer.Sprintf("test plural", 2)) + }) + }) + }) + + t.Run("languages ok", func(t *testing.T) { + t.Parallel() + + languages := GetLanguages() + require.Len(t, languages, len(supportedLanguages)) + require.ElementsMatch(t, supportedLanguages, languages) + }) +}