Initial commit
This commit is contained in:
93
internal/external.go
Normal file
93
internal/external.go
Normal file
@@ -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
|
||||
}
|
||||
239
internal/loader.go
Normal file
239
internal/loader.go
Normal file
@@ -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
|
||||
}
|
||||
51
internal/loader_test.go
Normal file
51
internal/loader_test.go
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
57
internal/testsfs.go
Normal file
57
internal/testsfs.go
Normal file
@@ -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,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user