commit 9129f25c318eacdac3c416e6a168cbdfbcb04729 Author: andrey1s Date: Mon Apr 26 17:49:02 2021 +0300 first commit diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..6403558 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,13 @@ +kind: pipeline +name: default + +steps: +- name: test + image: golang + commands: + - go test -parallel 10 ./... + +- name: golangci-lint + image: golangci/golangci-lint:v1.39 + commands: + - golangci-lint run diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4d432a --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# ---> Go +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..23e5557 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,46 @@ +run: + timeout: 5m + +linters-settings: + dupl: + threshold: 100 + funlen: + lines: 100 + statements: 50 + goconst: + min-len: 2 + min-occurrences: 2 + gocyclo: + min-complexity: 15 + golint: + min-confidence: 0 + govet: + check-shadowing: true + lll: + line-length: 140 + maligned: + suggest-new: true + misspell: + locale: US + +linters: + enable-all: true + disable: + - exhaustivestruct + - maligned + - interfacer + - scopelint + +issues: + # Excluding configuration per-path, per-linter, per-text and per-source + exclude-rules: + - path: _test\.go + linters: + - gomnd + - exhaustivestruct + - wrapcheck + - path: test/* + linters: + - gomnd + - exhaustivestruct + - wrapcheck diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f03940c --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +MIT License Copyright (c) 2020 4devs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..47895b9 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# translation + +[![Build Status](https://drone.gitoa.ru/api/badges/go-4devs/translation/status.svg)](https://drone.gitoa.ru/go-4devs/translation) +[![Go Report Card](https://goreportcard.com/badge/gitoa.ru/go-4devs/translation)](https://goreportcard.com/report/gitoa.ru/go-4devs/translation) +[![GoDoc](https://godoc.org/gitoa.ru/go-4devs/translation?status.svg)](http://godoc.org/gitoa.ru/go-4devs/translation) diff --git a/arg/arg.go b/arg/arg.go new file mode 100644 index 0000000..76d7fd5 --- /dev/null +++ b/arg/arg.go @@ -0,0 +1,19 @@ +package arg + +import "fmt" + +// Arg defaults argument. +type Arg struct { + Key string + Value interface{} +} + +// String arg to string. +func (a Arg) String() string { + return fmt.Sprintf("key:%s, value:%v", a.Key, a.Value) +} + +// Val gets valuet argument. +func (a Arg) Val() interface{} { + return a.Value +} diff --git a/arg/currency.go b/arg/currency.go new file mode 100644 index 0000000..72d4330 --- /dev/null +++ b/arg/currency.go @@ -0,0 +1,45 @@ +package arg + +import "fmt" + +// FormatCurrency types format. +type FormatCurrency int + +// Currency format. +const ( + CurrencyFormatSymbol FormatCurrency = iota + 1 + CurrencyFormatISO + CurrencyFormatNarrowSymbol +) + +// CurrencyOption configures option. +type CurrencyOption func(*Currency) + +// WithCurrencyFormat sets format currency. +func WithCurrencyFormat(format FormatCurrency) CurrencyOption { + return func(c *Currency) { c.Format = format } +} + +// WithCurrencyISO sets ISO 4217 code currecy. +func WithCurrencyISO(iso string) CurrencyOption { + return func(c *Currency) { c.ISO = iso } +} + +// Currency argument. +type Currency struct { + Key string + Value interface{} + Format FormatCurrency + // ISO 3-letter ISO 4217 + ISO string +} + +// String gets string from currency. +func (a Currency) String() string { + return fmt.Sprintf("currency key:%s, value:%v", a.Key, a.Value) +} + +// Val gets value currency. +func (a Currency) Val() interface{} { + return a.Value +} diff --git a/arg/number.go b/arg/number.go new file mode 100644 index 0000000..7fb2369 --- /dev/null +++ b/arg/number.go @@ -0,0 +1,49 @@ +package arg + +import "fmt" + +// FormatNumber format number. +type FormatNumber int + +// format argument. +const ( + NumberFormatDecimal FormatNumber = iota + 1 + NumberFormatPercent + NumberFormatPerMille + NumberFormatEngineering + NumberFormatScientific +) + +// NumberOption configure number argument. +type NumberOption func(*Number) + +// WithNumberFormat sets format number. +func WithNumberFormat(format FormatNumber) NumberOption { + return func(n *Number) { n.Format = format } +} + +// Number argument. +type Number struct { + Key string + Value interface{} + Format FormatNumber +} + +// Configure number. +func (n Number) Configure(opts ...NumberOption) Number { + for _, o := range opts { + o(&n) + } + + return n +} + +// Val gets number value. +func (n Number) Val() interface{} { + return n.Value +} + +// String number to string. +func (n Number) String() string { + return fmt.Sprintf("number key: %s, value: %v", n.Key, n.Value) +} diff --git a/context.go b/context.go new file mode 100644 index 0000000..48cd35d --- /dev/null +++ b/context.go @@ -0,0 +1,27 @@ +package translation + +import ( + "context" + + "golang.org/x/text/language" +) + +type ctxkey uint8 + +const ( + localeKey ctxkey = iota +) + +// WithLanguage sets language to context. +func WithLanguage(ctx context.Context, lang language.Tag) context.Context { + return context.WithValue(ctx, localeKey, lang) +} + +// FromContext get language from context or use default. +func FromContext(ctx context.Context, def language.Tag) language.Tag { + if t, ok := ctx.Value(localeKey).(language.Tag); ok { + return t + } + + return def +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..23efaed --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module gitoa.ru/go-4devs/translation + +go 1.16 + +require golang.org/x/text v0.3.6 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3c12b4f --- /dev/null +++ b/go.sum @@ -0,0 +1,3 @@ +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/icu/trans.go b/icu/trans.go new file mode 100644 index 0000000..0be6f5f --- /dev/null +++ b/icu/trans.go @@ -0,0 +1,22 @@ +package icu + +import ( + "context" + + "gitoa.ru/go-4devs/translation" + "gitoa.ru/go-4devs/translation/provider/gotext" + "golang.org/x/text/language" +) + +// nolint: gochecknoglobals +var trans = translation.New(language.English, gotext.NewProvider()) + +// Trans translate message. +func Trans(ctx context.Context, key string, opts ...translation.Option) string { + return trans.Trans(ctx, key, opts...) +} + +// SetTranslator sets translator. +func SetTranslator(tr *translation.Translator) { + trans = tr +} diff --git a/icu/trans_example_test.go b/icu/trans_example_test.go new file mode 100644 index 0000000..b0532cc --- /dev/null +++ b/icu/trans_example_test.go @@ -0,0 +1,41 @@ +package icu_test + +import ( + "context" + "fmt" + "log" + + "gitoa.ru/go-4devs/translation" + "gitoa.ru/go-4devs/translation/arg" + "gitoa.ru/go-4devs/translation/icu" + "golang.org/x/text/language" + "golang.org/x/text/message" + "golang.org/x/text/message/catalog" +) + +func ExampleTrans_withLanguage() { + err := message.Set(language.Russian, "Hello {city}", catalog.String("Привет %s")) + if err != nil { + log.Fatal(err) + } + + err = message.Set(language.Russian, "It costs {cost}", catalog.String("Это стоит %.2f.")) + if err != nil { + log.Fatal(err) + } + + lang, err := language.Parse("ru") + if err != nil { + log.Fatal(err) + } + + ctx := translation.WithLanguage(context.Background(), lang) + + tr := icu.Trans(ctx, "Hello {city}", translation.WithArgs("Москва")) + fmt.Println(tr) + tr = icu.Trans(ctx, "It costs {cost}", translation.WithNumber("cost", 1000.00, arg.WithNumberFormat(arg.NumberFormatDecimal))) + fmt.Println(tr) + // Output: + // Привет Москва + // Это стоит 1 000,00. +} diff --git a/provider/gotext/provider.go b/provider/gotext/provider.go new file mode 100644 index 0000000..59f140a --- /dev/null +++ b/provider/gotext/provider.go @@ -0,0 +1,108 @@ +package gotext + +import ( + "context" + + "gitoa.ru/go-4devs/translation" + "gitoa.ru/go-4devs/translation/arg" + "golang.org/x/text/currency" + "golang.org/x/text/language" + "golang.org/x/text/message" + "golang.org/x/text/message/catalog" + "golang.org/x/text/number" +) + +var _ translation.Provider = (*Provider)(nil) + +// Option confires message provider. +type Option func(*Provider) + +// WithCatalog set coatalog bu domain name. +func WithCatalog(domain string, cat catalog.Catalog) Option { + return func(mp *Provider) { + mp.catalog[domain] = cat + } +} + +// NewProvider creates new messgae provider. +func NewProvider(opts ...Option) *Provider { + mp := &Provider{ + catalog: map[string]catalog.Catalog{ + translation.DefaultDomain: message.DefaultCatalog, + }, + } + + for _, o := range opts { + o(mp) + } + + return mp +} + +// Provider provider messages. +type Provider struct { + catalog map[string]catalog.Catalog +} + +// Translate by key and args. +func (mp *Provider) Translate(ctx context.Context, key string, opt translation.Translate) string { + return message.NewPrinter(opt.Locale, message.Catalog(mp.catalog[opt.Domain])). + Sprintf(key, messageArgs(opt.Locale, opt.Args)...) +} + +func currencyMessage(lang language.Tag) func(v arg.Currency) interface{} { + return func(v arg.Currency) interface{} { + var u currency.Unit + + if v.ISO != "" { + u, _ = currency.ParseISO(v.ISO) + } else { + u, _ = currency.FromTag(lang) + } + + switch v.Format { + case arg.CurrencyFormatISO: + return currency.ISO(u.Amount(v.Val())) + case arg.CurrencyFormatSymbol: + return currency.Symbol(u.Amount(v.Val())) + case arg.CurrencyFormatNarrowSymbol: + return currency.NarrowSymbol(u.Amount(v.Val())) + } + + return u.Amount(v.Val()) + } +} + +func numberMessage(v arg.Number) interface{} { + switch v.Format { + case arg.NumberFormatPercent: + return number.Percent(v.Val()) + case arg.NumberFormatEngineering: + return number.Engineering(v.Val()) + case arg.NumberFormatPerMille: + return number.PerMille(v.Val()) + case arg.NumberFormatDecimal: + return number.Decimal(v.Val()) + case arg.NumberFormatScientific: + return number.Scientific(v.Val()) + } + + return v.Val() +} + +func messageArgs(lang language.Tag, in []translation.Arg) []interface{} { + out := make([]interface{}, 0, len(in)) + + for _, a := range in { + switch v := a.(type) { + case arg.Currency: + out = append(out, currencyMessage(lang)(v)) + case arg.Number: + out = append(out, numberMessage(v)) + default: + out = append(out, v.Val()) + } + } + + return out +} diff --git a/translate.go b/translate.go new file mode 100644 index 0000000..b84b04e --- /dev/null +++ b/translate.go @@ -0,0 +1,80 @@ +package translation + +import ( + "fmt" + + "gitoa.ru/go-4devs/translation/arg" + "golang.org/x/text/language" +) + +// Option configures translate. +type Option func(*Translate) + +// Translate configure translate. +type Translate struct { + Domain string + Locale language.Tag + Args []Arg +} + +// ArgValues gets value arguments. +func (t *Translate) ArgValues() []interface{} { + a := make([]interface{}, len(t.Args)) + for i, v := range t.Args { + a[i] = v.Val() + } + + return a +} + +// Arg arg translate. +type Arg interface { + Val() interface{} + fmt.Stringer +} + +// WithNumber sets number with options. +func WithNumber(key string, val interface{}, opts ...arg.NumberOption) Option { + return func(t *Translate) { + t.Args = append(t.Args, arg.Number{Key: key, Value: val}.Configure(opts...)) + } +} + +// WithCurrency sets date argument. +func WithCurrency(key string, val interface{}, opts ...arg.CurrencyOption) Option { + return func(t *Translate) { + c := arg.Currency{Value: val, Key: key} + + for _, o := range opts { + o(&c) + } + + t.Args = append(t.Args, c) + } +} + +// WithDomain sets domain translate. +func WithDomain(domain string) Option { + return func(o *Translate) { + o.Domain = domain + } +} + +// WithLocale sets locale translate. +func WithLocale(locale string) Option { + return func(o *Translate) { + o.Locale = language.Make(locale) + } +} + +// WithArgs sets arguments value. +func WithArgs(vals ...interface{}) Option { + return func(t *Translate) { + args := make([]Arg, len(vals)) + for i, val := range vals { + args[i] = arg.Arg{Value: val} + } + + t.Args = append(t.Args, args...) + } +} diff --git a/translator.go b/translator.go new file mode 100644 index 0000000..1cdd866 --- /dev/null +++ b/translator.go @@ -0,0 +1,56 @@ +package translation + +import ( + "context" + + "golang.org/x/text/language" +) + +// Default values. +const ( + DefaultDomain = "messages" +) + +// Provider for translate key. +type Provider interface { + Translate(ctx context.Context, key string, opt Translate) string +} + +// TranslatorOption options translator. +type TranslatorOption func(*Translator) + +// New creates new translator. +func New(locale language.Tag, provider Provider, opts ...TranslatorOption) *Translator { + tr := Translator{ + locale: locale, + provider: provider, + domain: DefaultDomain, + } + + for _, o := range opts { + o(&tr) + } + + return &tr +} + +// Translator struct. +type Translator struct { + provider Provider + domain string + locale language.Tag +} + +// Trans translates key by options. +func (t *Translator) Trans(ctx context.Context, key string, opts ...Option) string { + opt := Translate{ + Locale: FromContext(ctx, t.locale), + Domain: t.domain, + } + + for _, o := range opts { + o(&opt) + } + + return t.provider.Translate(ctx, key, opt) +} diff --git a/translator_example_test.go b/translator_example_test.go new file mode 100644 index 0000000..e4ee745 --- /dev/null +++ b/translator_example_test.go @@ -0,0 +1,91 @@ +package translation_test + +import ( + "context" + "fmt" + + "gitoa.ru/go-4devs/translation" + "gitoa.ru/go-4devs/translation/arg" + "gitoa.ru/go-4devs/translation/provider/gotext" + "golang.org/x/text/feature/plural" + "golang.org/x/text/language" + "golang.org/x/text/message/catalog" +) + +func ExampleTranslator_Trans() { + cat := catalog.NewBuilder() + + _ = cat.Set(language.Russian, "Hello World!", catalog.String("Привет Мир!")) + _ = cat.Set(language.Slovak, "Hello World!", catalog.String("Ahoj Svet!")) + _ = cat.Set(language.Hebrew, "Hello World!", catalog.String("שלום עולם")) + _ = cat.Set(language.Russian, "Hello {name}!", catalog.String("Привет %[1]s!")) + _ = cat.Set(language.English, "Hello {name}!", catalog.String("Hello %[1]s!")) + _ = cat.Set(language.English, "There are {bikes} bikes per household.", catalog.String("There are %v bikes per household.")) + _ = cat.Set(language.Russian, "There are {bikes} bikes per household.", catalog.String("В каждом доме есть %v велосипеда.")) + _ = cat.Set(language.English, "You are {minute} minute(s) late.", + plural.Selectf(1, "", + "=0", "You're on time.", + plural.One, "You are %v minute late.", + plural.Other, "You are %v minutes late.")) + _ = cat.Set(language.Russian, "You are {minute} minute(s) late.", + plural.Selectf(1, "", + "=1", "Вы опоздали на одну минуту.", + "=0", "Вы вовремя.", + plural.One, "Вы опоздали на %v минуту.", + plural.Few, "Вы опоздали на %v минуты.", + plural.Other, "Вы опоздали на %v минут.", + ), + ) + _ = cat.Set(language.Russian, "It costs {cost}.", + catalog.String("Это стоит %.2f.")) + _ = cat.Set(language.English, "It costs {cost}.", + catalog.String("It costs %.2f.")) + + provider := gotext.NewProvider(gotext.WithCatalog(translation.DefaultDomain, cat)) + + trans := translation.New(language.Russian, provider) + ctx := context.Background() + // context with language + heCtx := translation.WithLanguage(ctx, language.Make("he")) + + fmt.Println(trans.Trans(ctx, "Hello World!")) + fmt.Println(trans.Trans(ctx, "Hello World!", translation.WithLocale("en"))) + fmt.Println(trans.Trans(ctx, "Hello World!", translation.WithLocale("sk"))) + fmt.Println(trans.Trans(heCtx, "Hello World!")) + + fmt.Println(trans.Trans(ctx, "Hello {name}!", translation.WithArgs("Andrey"))) + + fmt.Println(trans.Trans(ctx, "You are {minute} minute(s) late.", translation.WithNumber("minute", 1))) + fmt.Println(trans.Trans(ctx, "You are {minute} minute(s) late.", translation.WithNumber("minute", 0))) + fmt.Println(trans.Trans(ctx, "You are {minute} minute(s) late.", translation.WithNumber("minute", 101))) + fmt.Println(trans.Trans(ctx, "You are {minute} minute(s) late.", translation.WithArgs(123456.78))) + fmt.Println(trans.Trans(ctx, "You are {minute} minute(s) late.", translation.WithArgs(50))) + fmt.Println(trans.Trans(ctx, "You are {minute} minute(s) late.", translation.WithArgs(1), translation.WithLocale("en"))) + fmt.Println(trans.Trans(ctx, "You are {minute} minute(s) late.", translation.WithArgs(33), translation.WithLocale("en"))) + + fmt.Println(trans.Trans(ctx, "There are {bikes} bikes per household.", translation.WithNumber("bikes", 1.2))) + fmt.Println(trans.Trans(ctx, "There are {bikes} bikes per household.", translation.WithNumber("bikes", 1.2), translation.WithLocale("en"))) + + fmt.Println(trans.Trans(ctx, "It costs {cost}.", + translation.WithCurrency("cost", 12.0101, arg.WithCurrencyISO("rub"), arg.WithCurrencyFormat(arg.CurrencyFormatSymbol)))) + fmt.Println(trans.Trans(ctx, "It costs {cost}.", + translation.WithCurrency("cost", 15.0, arg.WithCurrencyISO("HKD"), arg.WithCurrencyFormat(arg.CurrencyFormatSymbol)), + translation.WithLocale("en"))) + // Output: + // Привет Мир! + // Hello World! + // Ahoj Svet! + // שלום עולם + // Привет Andrey! + // Вы опоздали на одну минуту. + // Вы вовремя. + // Вы опоздали на 101 минуту. + // Вы опоздали на 123 456,78 минут. + // Вы опоздали на 50 минут. + // You are 1 minute late. + // You are 33 minutes late. + // В каждом доме есть 1,2 велосипеда. + // There are 1.2 bikes per household. + // Это стоит ₽ 12.0101. + // It costs HK$ 15. +} diff --git a/translator_test.go b/translator_test.go new file mode 100644 index 0000000..4859531 --- /dev/null +++ b/translator_test.go @@ -0,0 +1,32 @@ +package translation_test + +import ( + "context" + "fmt" + "testing" + + "gitoa.ru/go-4devs/translation" + "golang.org/x/text/language" +) + +type TestProvider struct{} + +func (tp *TestProvider) Translate(_ context.Context, key string, opt translation.Translate) string { + args := make([]interface{}, 0, len(opt.Args)+1) + args = append(args, key) + args = append(args, opt.ArgValues()...) + + return fmt.Sprint(args...) +} + +func TestTranslator(t *testing.T) { + t.Parallel() + + ctx := context.Background() + trans := translation.New(language.Russian, &TestProvider{}) + tr := trans.Trans(ctx, "key", translation.WithArgs("arg1", "arg2")) + + if tr != "keyarg1arg2" { + t.Fatalf("expect: keyarg1arg2, got:%s", tr) + } +}