diff --git a/contracts/foundation/application.go b/contracts/foundation/application.go index d67c2f06d..604c2815d 100644 --- a/contracts/foundation/application.go +++ b/contracts/foundation/application.go @@ -1,6 +1,8 @@ package foundation import ( + "context" + "github.com/goravel/framework/contracts/console" ) @@ -20,8 +22,18 @@ type Application interface { DatabasePath(path string) string // StoragePath get the path to the storage directory. StoragePath(path string) string + // LangPath get the path to the language files. + LangPath(path string) string // PublicPath get the path to the public directory. PublicPath(path string) string // Publishes register the given paths to be published by the "vendor:publish" command. Publishes(packageName string, paths map[string]string, groups ...string) + // GetLocale get the current application locale. + GetLocale(ctx context.Context) string + // SetLocale set the current application locale. + SetLocale(ctx context.Context, locale string) context.Context + // Version gets the version number of the application. + Version() string + // IsLocale get the current application locale. + IsLocale(ctx context.Context, locale string) bool } diff --git a/contracts/foundation/container.go b/contracts/foundation/container.go index e7be9cc4f..f6da1d2ce 100644 --- a/contracts/foundation/container.go +++ b/contracts/foundation/container.go @@ -1,6 +1,8 @@ package foundation import ( + "context" + "github.com/goravel/framework/contracts/auth" "github.com/goravel/framework/contracts/auth/access" "github.com/goravel/framework/contracts/cache" @@ -20,6 +22,7 @@ import ( "github.com/goravel/framework/contracts/route" "github.com/goravel/framework/contracts/schedule" "github.com/goravel/framework/contracts/testing" + "github.com/goravel/framework/contracts/translation" "github.com/goravel/framework/contracts/validation" ) @@ -50,6 +53,8 @@ type Container interface { MakeGrpc() grpc.Grpc // MakeHash resolves the hash instance. MakeHash() hash.Hash + // MakeLang resolves the lang instance. + MakeLang(ctx context.Context) translation.Translator // MakeLog resolves the log instance. MakeLog() log.Log // MakeMail resolves the mail instance. diff --git a/contracts/translation/loader.go b/contracts/translation/loader.go new file mode 100644 index 000000000..f14a9539f --- /dev/null +++ b/contracts/translation/loader.go @@ -0,0 +1,6 @@ +package translation + +type Loader interface { + // Load the messages for the given locale. + Load(folder string, locale string) (map[string]map[string]string, error) +} diff --git a/contracts/translation/translator.go b/contracts/translation/translator.go new file mode 100644 index 000000000..a36574304 --- /dev/null +++ b/contracts/translation/translator.go @@ -0,0 +1,32 @@ +package translation + +import ( + "context" +) + +type Translator interface { + // Choice gets a translation according to an integer value. + Choice(key string, number int, options ...Option) (string, error) + // Get the translation for the given key. + Get(key string, options ...Option) (string, error) + // GetFallback get the current application/context fallback locale. + GetFallback() string + // GetLocale get the current application/context locale. + GetLocale() string + // Has checks if a translation exists for a given key. + Has(key string, options ...Option) bool + // SetFallback set the current application/context fallback locale. + SetFallback(locale string) context.Context + // SetLocale set the current application/context locale. + SetLocale(locale string) context.Context +} + +type Option struct { + Fallback *bool + Locale string + Replace map[string]string +} + +func Bool(value bool) *bool { + return &value +} diff --git a/facades/lang.go b/facades/lang.go new file mode 100644 index 000000000..9a1eaaf0a --- /dev/null +++ b/facades/lang.go @@ -0,0 +1,11 @@ +package facades + +import ( + "context" + + "github.com/goravel/framework/contracts/translation" +) + +func Lang(ctx context.Context) translation.Translator { + return App().MakeLang(ctx) +} diff --git a/foundation/application.go b/foundation/application.go index 4348c796e..a02ce06d5 100644 --- a/foundation/application.go +++ b/foundation/application.go @@ -1,6 +1,7 @@ package foundation import ( + "context" "flag" "os" "path/filepath" @@ -81,6 +82,10 @@ func (app *Application) StoragePath(path string) string { return filepath.Join("storage", path) } +func (app *Application) LangPath(path string) string { + return filepath.Join("lang", path) +} + func (app *Application) PublicPath(path string) string { return filepath.Join("public", path) } @@ -97,6 +102,22 @@ func (app *Application) Publishes(packageName string, paths map[string]string, g } } +func (app *Application) Version() string { + return support.Version +} + +func (app *Application) GetLocale(ctx context.Context) string { + return app.MakeLang(ctx).GetLocale() +} + +func (app *Application) SetLocale(ctx context.Context, locale string) context.Context { + return app.MakeLang(ctx).SetLocale(locale) +} + +func (app *Application) IsLocale(ctx context.Context, locale string) bool { + return app.GetLocale(ctx) == locale +} + func (app *Application) ensurePublishArrayInitialized(packageName string) { if _, exist := app.publishes[packageName]; !exist { app.publishes[packageName] = make(map[string]string) @@ -219,7 +240,7 @@ func setEnv() { func setRootPath() { rootPath := getCurrentAbsolutePath() - // Hack air path + // Hack the air path airPath := "/storage/temp" if strings.HasSuffix(rootPath, airPath) { rootPath = strings.ReplaceAll(rootPath, airPath, "") diff --git a/foundation/application_test.go b/foundation/application_test.go index b945c4b65..52721c5cf 100644 --- a/foundation/application_test.go +++ b/foundation/application_test.go @@ -36,6 +36,7 @@ import ( supportdocker "github.com/goravel/framework/support/docker" "github.com/goravel/framework/support/env" "github.com/goravel/framework/support/file" + frameworktranslation "github.com/goravel/framework/translation" "github.com/goravel/framework/validation" ) @@ -234,6 +235,23 @@ func (s *ApplicationTestSuite) TestMakeHash() { mockConfig.AssertExpectations(s.T()) } +func (s *ApplicationTestSuite) TestMakeLang() { + mockConfig := &configmocks.Config{} + mockConfig.On("GetString", "app.locale").Return("en").Once() + mockConfig.On("GetString", "app.fallback_locale").Return("en").Once() + + s.app.Singleton(frameworkconfig.Binding, func(app foundation.Application) (any, error) { + return mockConfig, nil + }) + + serviceProvider := &frameworktranslation.ServiceProvider{} + serviceProvider.Register(s.app) + ctx := http.Background() + + s.NotNil(s.app.MakeLang(ctx)) + mockConfig.AssertExpectations(s.T()) +} + func (s *ApplicationTestSuite) TestMakeLog() { serviceProvider := &frameworklog.ServiceProvider{} serviceProvider.Register(s.app) diff --git a/foundation/container.go b/foundation/container.go index e59ebd3da..487912d5c 100644 --- a/foundation/container.go +++ b/foundation/container.go @@ -1,6 +1,7 @@ package foundation import ( + "context" "fmt" "sync" @@ -30,6 +31,7 @@ import ( routecontract "github.com/goravel/framework/contracts/route" schedulecontract "github.com/goravel/framework/contracts/schedule" testingcontract "github.com/goravel/framework/contracts/testing" + translationcontract "github.com/goravel/framework/contracts/translation" validationcontract "github.com/goravel/framework/contracts/validation" "github.com/goravel/framework/crypt" "github.com/goravel/framework/database" @@ -44,6 +46,7 @@ import ( "github.com/goravel/framework/route" "github.com/goravel/framework/schedule" "github.com/goravel/framework/testing" + "github.com/goravel/framework/translation" "github.com/goravel/framework/validation" ) @@ -167,6 +170,18 @@ func (c *Container) MakeHash() hashcontract.Hash { return instance.(hashcontract.Hash) } +func (c *Container) MakeLang(ctx context.Context) translationcontract.Translator { + instance, err := c.MakeWith(translation.Binding, map[string]any{ + "ctx": ctx, + }) + if err != nil { + color.Redln(err) + return nil + } + + return instance.(translationcontract.Translator) +} + func (c *Container) MakeLog() logcontract.Log { instance, err := c.Make(goravellog.Binding) if err != nil { diff --git a/http/context.go b/http/context.go index 07120b8b8..9ba1658b6 100644 --- a/http/context.go +++ b/http/context.go @@ -38,3 +38,4 @@ func (r *Context) Request() http.ContextRequest { func (r *Context) Response() http.ContextResponse { return nil } + diff --git a/mocks/foundation/Application.go b/mocks/foundation/Application.go index d296b8f77..13cd1ed9e 100644 --- a/mocks/foundation/Application.go +++ b/mocks/foundation/Application.go @@ -12,6 +12,8 @@ import ( console "github.com/goravel/framework/contracts/console" + context "context" + crypt "github.com/goravel/framework/contracts/crypt" event "github.com/goravel/framework/contracts/event" @@ -44,6 +46,8 @@ import ( testing "github.com/goravel/framework/contracts/testing" + translation "github.com/goravel/framework/contracts/translation" + validation "github.com/goravel/framework/contracts/validation" ) @@ -114,11 +118,53 @@ func (_m *Application) DatabasePath(path string) string { return r0 } +// GetLocale provides a mock function with given fields: ctx +func (_m *Application) GetLocale(ctx context.Context) string { + ret := _m.Called(ctx) + + var r0 string + if rf, ok := ret.Get(0).(func(context.Context) string); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + // Instance provides a mock function with given fields: key, instance func (_m *Application) Instance(key interface{}, instance interface{}) { _m.Called(key, instance) } +// IsLocale provides a mock function with given fields: ctx, locale +func (_m *Application) IsLocale(ctx context.Context, locale string) bool { + ret := _m.Called(ctx, locale) + + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context, string) bool); ok { + r0 = rf(ctx, locale) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// LangPath provides a mock function with given fields: path +func (_m *Application) LangPath(path string) string { + ret := _m.Called(path) + + var r0 string + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(path) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + // Make provides a mock function with given fields: key func (_m *Application) Make(key interface{}) (interface{}, error) { ret := _m.Called(key) @@ -289,6 +335,22 @@ func (_m *Application) MakeHash() hash.Hash { return r0 } +// MakeLang provides a mock function with given fields: ctx +func (_m *Application) MakeLang(ctx context.Context) translation.Translator { + ret := _m.Called(ctx) + + var r0 translation.Translator + if rf, ok := ret.Get(0).(func(context.Context) translation.Translator); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(translation.Translator) + } + } + + return r0 +} + // MakeLog provides a mock function with given fields: func (_m *Application) MakeLog() log.Log { ret := _m.Called() @@ -547,6 +609,22 @@ func (_m *Application) Publishes(packageName string, paths map[string]string, gr _m.Called(_ca...) } +// SetLocale provides a mock function with given fields: ctx, locale +func (_m *Application) SetLocale(ctx context.Context, locale string) context.Context { + ret := _m.Called(ctx, locale) + + var r0 context.Context + if rf, ok := ret.Get(0).(func(context.Context, string) context.Context); ok { + r0 = rf(ctx, locale) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(context.Context) + } + } + + return r0 +} + // Singleton provides a mock function with given fields: key, callback func (_m *Application) Singleton(key interface{}, callback func(foundation.Application) (interface{}, error)) { _m.Called(key, callback) @@ -566,6 +644,20 @@ func (_m *Application) StoragePath(path string) string { return r0 } +// Version provides a mock function with given fields: +func (_m *Application) Version() string { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + // NewApplication creates a new instance of Application. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewApplication(t interface { diff --git a/mocks/foundation/Container.go b/mocks/foundation/Container.go index ed95a19cc..1f6a8232b 100644 --- a/mocks/foundation/Container.go +++ b/mocks/foundation/Container.go @@ -12,6 +12,8 @@ import ( console "github.com/goravel/framework/contracts/console" + context "context" + crypt "github.com/goravel/framework/contracts/crypt" event "github.com/goravel/framework/contracts/event" @@ -44,6 +46,8 @@ import ( testing "github.com/goravel/framework/contracts/testing" + translation "github.com/goravel/framework/contracts/translation" + validation "github.com/goravel/framework/contracts/validation" ) @@ -237,6 +241,22 @@ func (_m *Container) MakeHash() hash.Hash { return r0 } +// MakeLang provides a mock function with given fields: ctx +func (_m *Container) MakeLang(ctx context.Context) translation.Translator { + ret := _m.Called(ctx) + + var r0 translation.Translator + if rf, ok := ret.Get(0).(func(context.Context) translation.Translator); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(translation.Translator) + } + } + + return r0 +} + // MakeLog provides a mock function with given fields: func (_m *Container) MakeLog() log.Log { ret := _m.Called() diff --git a/mocks/translation/Loader.go b/mocks/translation/Loader.go new file mode 100644 index 000000000..7987809d6 --- /dev/null +++ b/mocks/translation/Loader.go @@ -0,0 +1,50 @@ +// Code generated by mockery v2.34.2. DO NOT EDIT. + +package mocks + +import mock "github.com/stretchr/testify/mock" + +// Loader is an autogenerated mock type for the Loader type +type Loader struct { + mock.Mock +} + +// Load provides a mock function with given fields: folder, locale +func (_m *Loader) Load(folder string, locale string) (map[string]map[string]string, error) { + ret := _m.Called(folder, locale) + + var r0 map[string]map[string]string + var r1 error + if rf, ok := ret.Get(0).(func(string, string) (map[string]map[string]string, error)); ok { + return rf(folder, locale) + } + if rf, ok := ret.Get(0).(func(string, string) map[string]map[string]string); ok { + r0 = rf(folder, locale) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]map[string]string) + } + } + + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(folder, locale) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewLoader creates a new instance of Loader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewLoader(t interface { + mock.TestingT + Cleanup(func()) +}) *Loader { + mock := &Loader{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/translation/Translator.go b/mocks/translation/Translator.go new file mode 100644 index 000000000..78e53c6ed --- /dev/null +++ b/mocks/translation/Translator.go @@ -0,0 +1,172 @@ +// Code generated by mockery v2.34.2. DO NOT EDIT. + +package mocks + +import ( + context "context" + + translation "github.com/goravel/framework/contracts/translation" + mock "github.com/stretchr/testify/mock" +) + +// Translator is an autogenerated mock type for the Translator type +type Translator struct { + mock.Mock +} + +// Choice provides a mock function with given fields: key, number, options +func (_m *Translator) Choice(key string, number int, options ...translation.Option) (string, error) { + _va := make([]interface{}, len(options)) + for _i := range options { + _va[_i] = options[_i] + } + var _ca []interface{} + _ca = append(_ca, key, number) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string, int, ...translation.Option) (string, error)); ok { + return rf(key, number, options...) + } + if rf, ok := ret.Get(0).(func(string, int, ...translation.Option) string); ok { + r0 = rf(key, number, options...) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string, int, ...translation.Option) error); ok { + r1 = rf(key, number, options...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Get provides a mock function with given fields: key, options +func (_m *Translator) Get(key string, options ...translation.Option) (string, error) { + _va := make([]interface{}, len(options)) + for _i := range options { + _va[_i] = options[_i] + } + var _ca []interface{} + _ca = append(_ca, key) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string, ...translation.Option) (string, error)); ok { + return rf(key, options...) + } + if rf, ok := ret.Get(0).(func(string, ...translation.Option) string); ok { + r0 = rf(key, options...) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string, ...translation.Option) error); ok { + r1 = rf(key, options...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetFallback provides a mock function with given fields: +func (_m *Translator) GetFallback() string { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// GetLocale provides a mock function with given fields: +func (_m *Translator) GetLocale() string { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// Has provides a mock function with given fields: key, options +func (_m *Translator) Has(key string, options ...translation.Option) bool { + _va := make([]interface{}, len(options)) + for _i := range options { + _va[_i] = options[_i] + } + var _ca []interface{} + _ca = append(_ca, key) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 bool + if rf, ok := ret.Get(0).(func(string, ...translation.Option) bool); ok { + r0 = rf(key, options...) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// SetFallback provides a mock function with given fields: locale +func (_m *Translator) SetFallback(locale string) context.Context { + ret := _m.Called(locale) + + var r0 context.Context + if rf, ok := ret.Get(0).(func(string) context.Context); ok { + r0 = rf(locale) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(context.Context) + } + } + + return r0 +} + +// SetLocale provides a mock function with given fields: locale +func (_m *Translator) SetLocale(locale string) context.Context { + ret := _m.Called(locale) + + var r0 context.Context + if rf, ok := ret.Get(0).(func(string) context.Context); ok { + r0 = rf(locale) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(context.Context) + } + } + + return r0 +} + +// NewTranslator creates a new instance of Translator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewTranslator(t interface { + mock.TestingT + Cleanup(func()) +}) *Translator { + mock := &Translator{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/translation/errors.go b/translation/errors.go new file mode 100644 index 000000000..1ff9327a9 --- /dev/null +++ b/translation/errors.go @@ -0,0 +1,7 @@ +package translation + +import "errors" + +var ( + ErrFileNotExist = errors.New("translation file does not exist") +) diff --git a/translation/file_loader.go b/translation/file_loader.go new file mode 100644 index 000000000..c1be19cfc --- /dev/null +++ b/translation/file_loader.go @@ -0,0 +1,68 @@ +package translation + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/bytedance/sonic" + "github.com/bytedance/sonic/decoder" + + "github.com/goravel/framework/support/file" +) + +type FileLoader struct { + paths []string +} + +func NewFileLoader(paths []string) *FileLoader { + return &FileLoader{ + paths: paths, + } +} + +func (f *FileLoader) Load(folder string, locale string) (map[string]map[string]string, error) { + translations := make(map[string]map[string]string) + for _, path := range f.paths { + var val map[string]string + fullPath := path + // Check if the folder is not "*", and if so, split it into subFolders + if folder != "*" { + subFolders := strings.Split(folder, ".") + for _, subFolder := range subFolders { + fullPath = filepath.Join(fullPath, subFolder) + } + } + fullPath = filepath.Join(fullPath, locale+".json") + + if file.Exists(fullPath) { + data, err := os.ReadFile(fullPath) + if err != nil { + return nil, err + } + if err := sonic.Unmarshal(data, &val); err != nil { + if _, ok := err.(decoder.SyntaxError); ok { + return nil, fmt.Errorf("translation file [%s] contains an invalid JSON structure", fullPath) + } else if _, ok := err.(*decoder.MismatchTypeError); ok { + return nil, fmt.Errorf("translation file [%s] contains mismatched types", fullPath) + } + return nil, err + } + // Initialize the map if it's a nil + if translations[locale] == nil { + translations[locale] = make(map[string]string) + } + mergeMaps(translations[locale], val) + } else { + return nil, ErrFileNotExist + } + } + return translations, nil +} + +func mergeMaps[M1 ~map[K]V, M2 ~map[K]V, K comparable, V any](dst M1, src M2) { + for k, v := range src { + dst[k] = v + } +} diff --git a/translation/file_loader_test.go b/translation/file_loader_test.go new file mode 100644 index 000000000..ed4c4c624 --- /dev/null +++ b/translation/file_loader_test.go @@ -0,0 +1,98 @@ +package translation + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/goravel/framework/support/env" + "github.com/goravel/framework/support/file" +) + +type FileLoaderTestSuite struct { + suite.Suite +} + +func TestFileLoaderTestSuite(t *testing.T) { + assert.Nil(t, file.Create("lang/en.json", `{"foo": "bar"}`)) + assert.Nil(t, file.Create("lang/another/en.json", `{"foo": "backagebar", "baz": "backagesplash"}`)) + assert.Nil(t, file.Create("lang/invalid/en.json", `{"foo": "bar",}`)) + restrictedFilePath := "lang/restricted/en.json" + assert.Nil(t, file.Create(restrictedFilePath, `{"foo": "restricted"}`)) + assert.Nil(t, os.Chmod(restrictedFilePath, 0000)) + suite.Run(t, &FileLoaderTestSuite{}) + assert.Nil(t, file.Remove("lang")) +} + +func (f *FileLoaderTestSuite) SetupTest() { +} + +func (f *FileLoaderTestSuite) TestLoad() { + paths := []string{"./lang"} + loader := NewFileLoader(paths) + translations, err := loader.Load("*", "en") + f.NoError(err) + f.NotNil(translations) + f.Equal("bar", translations["en"]["foo"]) + + paths = []string{"./lang/another", "./lang"} + loader = NewFileLoader(paths) + translations, err = loader.Load("*", "en") + f.NoError(err) + f.NotNil(translations) + f.Equal("bar", translations["en"]["foo"]) + + paths = []string{"./lang"} + loader = NewFileLoader(paths) + translations, err = loader.Load("another", "en") + f.NoError(err) + f.NotNil(translations) + f.Equal("backagebar", translations["en"]["foo"]) + + paths = []string{"./lang/restricted"} + loader = NewFileLoader(paths) + translations, err = loader.Load("*", "en") + if env.IsWindows() { + f.NoError(err) + f.NotNil(translations) + f.Equal("restricted", translations["en"]["foo"]) + } else { + f.Error(err) + f.Nil(translations) + } +} + +func (f *FileLoaderTestSuite) TestLoadNonExistentFile() { + paths := []string{"./lang"} + loader := NewFileLoader(paths) + translations, err := loader.Load("*", "hi") + + f.Error(err) + f.Nil(translations) + f.Equal(ErrFileNotExist, err) +} + +func (f *FileLoaderTestSuite) TestLoadInvalidJSON() { + paths := []string{"./lang/invalid"} + loader := NewFileLoader(paths) + translations, err := loader.Load("*", "en") + + f.Error(err) + f.Nil(translations) +} + +func TestMergeMaps(t *testing.T) { + dst := map[string]string{ + "foo": "bar", + } + src := map[string]string{ + "baz": "backage", + } + mergeMaps(dst, src) + assert.Equal(t, map[string]string{ + "foo": "bar", + "baz": "backage", + }, dst) +} diff --git a/translation/message_selector.go b/translation/message_selector.go new file mode 100644 index 000000000..da6b3f074 --- /dev/null +++ b/translation/message_selector.go @@ -0,0 +1,239 @@ +package translation + +import ( + "regexp" + "strconv" + "strings" +) + +type MessageSelector struct{} + +func NewMessageSelector() *MessageSelector { + return &MessageSelector{} +} + +// Choose a translation string from an array according to a number. +func (m *MessageSelector) Choose(message string, number int, locale string) string { + segments := strings.Split(strings.Trim(message, "\" "), "|") + if value := m.extract(segments, number); value != nil { + return strings.TrimSpace(*value) + } + + segments = stripConditions(segments) + pluralIndex := getPluralIndex(number, locale) + + if len(segments) == 1 || pluralIndex >= len(segments) { + return strings.Trim(segments[0], "\" ") + } + + return segments[pluralIndex] +} + +func (m *MessageSelector) extract(segments []string, number int) *string { + for _, segment := range segments { + if line := m.extractFromString(segment, number); line != nil { + return line + } + } + + return nil +} + +func (m *MessageSelector) extractFromString(segment string, number int) *string { + // Define a regular expression pattern for matching the condition and value in the part + pattern := `^[\{\[]([^\[\]\{\}]*)[\}\]]([\s\S]*)` + regex := regexp.MustCompile(pattern) + matches := regex.FindStringSubmatch(segment) + // Check if we have exactly three sub matches (full match and two capturing groups) + if len(matches) != 3 { + return nil + } + + condition, value := matches[1], matches[2] + + // Check if the condition contains a comma + if strings.Contains(condition, ",") { + fromTo := strings.SplitN(condition, ",", 2) + from, to := fromTo[0], fromTo[1] + + if from == "*" { + if to == "*" { + return &value + } + + toInt, err := strconv.Atoi(to) + if err == nil && number <= toInt { + return &value + } + } else if to == "*" { + fromInt, err := strconv.Atoi(from) + if err == nil && number >= fromInt { + return &value + } + } else { + fromInt, errFrom := strconv.Atoi(from) + toInt, errTo := strconv.Atoi(to) + + if errFrom == nil && errTo == nil && number >= fromInt && number <= toInt { + return &value + } + } + } + + // Check if the condition is equal to the number + conditionInt, err := strconv.Atoi(condition) + if err == nil && conditionInt == number { + return &value + } + + return nil +} + +func stripConditions(segments []string) []string { + strippedSegments := make([]string, len(segments)) + + for i, part := range segments { + // Define a regular expression pattern for stripping conditions + pattern := `^[\{\[]([^\[\]\{\}]*)[\}\]]` + + // Replace the matched pattern with an empty string + re := regexp.MustCompile(pattern) + strippedPart := re.ReplaceAllString(part, "") + + // Store the stripped part in the result slice + strippedSegments[i] = strippedPart + } + + return strippedSegments +} + +// getPluralIndex returns the plural index for the given number and locale. +func getPluralIndex(number int, locale string) int { + switch locale { + case "az", "az_AZ", "bo", "bo_CN", "bo_IN", "dz", "dz_BT", "id", "id_ID", "ja", "ja_JP", "jv", "ka", "ka_GE", "km", "km_KH", "kn", "kn_KR", "ko", "ko_KR", "ms", "ms_MY", "th", "th_TH", "tr", "tr_TR", "vi", "vi_VN", "zh", "zh_CN", "zh_HK", "zh_SG", "zh_TW": + return 0 + case "af", "af_ZA", "bn", "bn_BD", "bn_IN", "bg", "bg_BG", "ca", "ca_AD", "ca_ES", "ca_FR", "ca_IT", "da", "da_DK", "de", "de_AT", "de_BE", "de_CH", "de_DE", "de_LI", "de_LU", "el", "el_CY", "el_GR", "en", "en_AS", "en_AU", "en_BE", "en_BW", "en_BZ", "en_CA", "en_GB", "en_GU", "en_HK", "en_IE", "en_IN", "en_JM", "en_MH", "en_MP", "en_MT", "en_NA", "en_NZ", "en_PH", "en_PK", "en_SG", "en_TT", "en_UM", "en_US", "en_VI", "en_ZA", "en_ZW", "eo", "eo_US", "es", "es_AR", "es_BO", "es_CL", "es_CO", "es_CR", "es_DO", "es_EC", "es_ES", "es_GT", "es_HN", "es_MX", "es_NI", "es_PA", "es_PE", "es_PR", "es_PY", "es_SV", "es_US", "es_UY", "es_VE", "et", "et_EE", "eu", "eu_ES", "eu_FR", "fi", "fi_FI", "fo", "fo_FO", "fur", "fur_IT", "fy", "fy_DE", "fy_NL", "gl", "gl_ES", "gu", "gu_IN", "ha", "ha_NG", "he", "he_IL", "hu", "hu_HU", "is", "is_IS", "it", "it_CH", "it_IT", "ku", "ku_TR", "lb", "lb_LU", "ml", "ml_IN", "mn", "mn_MN", "mr", "mr_IN", "nah", "nb", "nb_NO", "ne", "ne_NP", "nl", "nl_BE", "nl_NL", "nn", "nn_NO", "no", "om", "om_ET", "om_KE", "or", "or_IN", "pa", "pa_IN", "pa_PK", "pap", "pap_AN", "pap_AW", "pap_CW", "pap_NL", "ps", "ps_AF", "pt", "pt_BR", "pt_PT", "so", "so_DJ", "so_ET", "so_KE", "so_SO", "sq", "sq_AL", "sq_MK", "sv", "sv_FI", "sv_SE", "sw", "sw_KE", "sw_TZ", "ta", "ta_IN", "ta_LK", "te", "te_IN", "tk", "tk_TM", "ur", "ur_IN", "ur_PK", "zu", "zu_ZA": + if number == 1 { + return 0 + } + return 1 + case "am", "am_ET", "bh", "fil", "fil_PH", "fr", "fr_BE", "fr_CA", "fr_CH", "fr_FR", "fr_LU", "fr_MC", "fr_SN", "guz", "guz_KE", "hi", "hi_IN", "hy", "hy_AM", "ln", "ln_CD", "mg", "mg_MG", "nso", "nso_ZA", "ti", "ti_ER", "ti_ET", "wa", "wa_BE", "xbr": + if number == 0 || number == 1 { + return 0 + } + return 1 + case "be", "be_BY", "bs", "bs_BA", "hr", "hr_HR", "ru", "ru_RU", "ru_UA", "sr", "sr_ME", "sr_RS", "uk", "uk_UA": + if number%10 == 1 && number%100 != 11 { + return 0 + } + if number%10 >= 2 && number%10 <= 4 && (number%100 < 10 || number%100 >= 20) { + return 1 + } + return 2 + case "cs", "cs_CZ", "sk", "sk_SK": + if number == 1 { + return 0 + } + if number >= 2 && number <= 4 { + return 1 + } + return 2 + case "ga", "ga_IE": + if number == 1 { + return 0 + } + if number == 2 { + return 1 + } + return 2 + case "lt", "lt_LT": + if number%10 == 1 && number%100 != 11 { + return 0 + } + if number%10 >= 2 && (number%100 < 10 || number%100 >= 20) { + return 1 + } + return 2 + case "sl", "sl_SI": + if number%100 == 1 { + return 0 + } + if number%100 == 2 { + return 1 + } + if number%100 == 3 || number%100 == 4 { + return 2 + } + return 3 + case "mk", "mk_MK": + if number%10 == 1 { + return 0 + } + return 1 + case "mt", "mt_MT": + if number == 1 { + return 0 + } + if number == 0 || (number%100 > 1 && number%100 < 11) { + return 1 + } + if number%100 > 10 && number%100 < 20 { + return 2 + } + return 3 + case "lv", "lv_LV": + if number == 0 { + return 0 + } + if number%10 == 1 && number%100 != 11 { + return 1 + } + return 2 + case "pl", "pl_PL": + if number == 1 { + return 0 + } + if number%10 >= 2 && number%10 <= 4 && (number%100 < 12 || number%100 > 14) { + return 1 + } + return 2 + case "cy", "cy_GB": + if number == 1 { + return 0 + } + if number == 2 { + return 1 + } + if number == 8 || number == 11 { + return 2 + } + return 3 + case "ro", "ro_RO": + if number == 1 { + return 0 + } + if number == 0 || (number%100 > 0 && number%100 < 20) { + return 1 + } + return 2 + case "ar", "ar_AE", "ar_BH", "ar_DZ", "ar_EG", "ar_IN", "ar_IQ", "ar_JO", "ar_KW", "ar_LB", "ar_LY", "ar_MA", "ar_OM", "ar_QA", "ar_SA", "ar_SD", "ar_SY", "ar_TN", "ar_YE": + if number == 0 { + return 0 + } + if number == 1 { + return 1 + } + if number == 2 { + return 2 + } + if number%100 >= 3 && number%100 <= 10 { + return 3 + } + if number%100 >= 11 && number%100 <= 99 { + return 4 + } + return 5 + default: + return 0 + } +} diff --git a/translation/message_selector_test.go b/translation/message_selector_test.go new file mode 100644 index 000000000..c88f88730 --- /dev/null +++ b/translation/message_selector_test.go @@ -0,0 +1,205 @@ +package translation + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type MessageSelectorTestSuite struct { + suite.Suite + selector *MessageSelector +} + +func TestMessageSelectorTestSuite(t *testing.T) { + suite.Run(t, new(MessageSelectorTestSuite)) +} + +func (m *MessageSelectorTestSuite) SetUpTest() { + m.selector = NewMessageSelector() +} + +func (m *MessageSelectorTestSuite) TestChoose() { + tests := []struct { + expected string + message string + number int + locale string + }{ + {"first", "first", 0, "en"}, + {"first", "first", 10, "en"}, + {"first", "first|second", 1, "en"}, + {"second", "first|second", 10, "en"}, + {"second", "first|second", 0, "en"}, + + {"first", "{0} first|{1}second", 0, "en"}, + {"first", "{1}first|{2}second", 1, "en"}, + {"second", "{1}first|{2}second", 2, "en"}, + {"first", "{2}first|{1}second", 2, "en"}, + {"second", "{9}first|{10}second", 0, "en"}, + {"first", "{9}first|{10}second", 1, "en"}, + {"", "{0}|{1}second", 0, "en"}, // there is some problem with it + {"", "{0}first|{1}", 1, "en"}, // same + {"first\nline", "{1}first\nline|{2}second", 1, "en"}, + {"first \nline", "{1}first \nline|{2}second", 1, "en"}, + {"first", "{0}first|[1,9]second", 0, "en"}, + {"second", "{0}first|[1,9]second", 1, "en"}, + {"second", "{0}first|[1,9]second", 10, "en"}, + {"first", "{0}first|[2,9]second", 1, "en"}, + {"second", "[4,*]first|[1,3]second", 1, "en"}, + {"first", "[4,*]first|[1,3]second", 100, "en"}, + {"second", "[1,5]first|[6,10]second", 7, "en"}, + {"first", "[*,4]first|[5,*]second", 1, "en"}, + {"second", "[5,*]first|[*,4]second", 1, "en"}, + {"second", "[5,*]first|[*,4]second", 0, "en"}, + + {"first", "{0}first|[1,3]second|[4,*]third", 0, "en"}, + {"second", "{0}first|[1,3]second|[4,*]third", 1, "en"}, + {"third", "{0}first|[1,3]second|[4,*]third", 9, "en"}, + + {"first", "first|second|third", 1, "en"}, + {"second", "first|second|third", 9, "en"}, + {"second", "first|second|third", 0, "en"}, + + {"first", "{0} first | { 1 } second", 0, "en"}, + {"first", "[4,*]first | [1,3] second", 100, "en"}, + } + + for _, test := range tests { + m.Equal(test.expected, m.selector.Choose(test.message, test.number, test.locale)) + } +} + +func (m *MessageSelectorTestSuite) TestExtract() { + tests := []struct { + segments []string + number int + expected *string + }{ + {[]string{"{0} first", "{1}second"}, 0, stringPtr(" first")}, + {[]string{"{1}first", "{2}second"}, 0, nil}, + {[]string{"{0}first", "{1}second"}, 0, stringPtr("first")}, + {[]string{"[4,*]first", "[1,3]second"}, 100, stringPtr("first")}, + } + for _, test := range tests { + value := m.selector.extract(test.segments, test.number) + if value == nil { + m.Equal(test.expected, value) + continue + } + m.Equal(*test.expected, *value) + } + +} + +func (m *MessageSelectorTestSuite) TestExtractFromString() { + var tests = []struct { + segment string + number int + expected *string + }{ + {"{0}first", 0, stringPtr("first")}, + {"[4,*]first", 5, stringPtr("first")}, + {"[1,3]second", 0, nil}, + {"[*,4]second", 3, stringPtr("second")}, + {"[*,*]second", 0, stringPtr("second")}, + } + + for _, test := range tests { + value := m.selector.extractFromString(test.segment, test.number) + if value == nil { + m.Equal(test.expected, value) + continue + } + m.Equal(*test.expected, *value) + } +} + +func (m *MessageSelectorTestSuite) TestStripConditions() { + tests := []struct { + segments []string + expected []string + }{ + {[]string{"{0}first", "[2,9]second"}, []string{"first", "second"}}, + {[]string{"[4,*]first", "[1,3]second"}, []string{"first", "second"}}, + {[]string{"first", "second"}, []string{"first", "second"}}, + } + + for _, test := range tests { + m.Equal(test.expected, stripConditions(test.segments)) + } +} + +func (m *MessageSelectorTestSuite) TestGetPluralIndex() { + tests := []struct { + locale string + number int + expected int + }{ + {"az", 0, 0}, + {"af", 1, 0}, + {"af", 10, 1}, + {"am", 0, 0}, + {"am", 1, 0}, + {"am", 10, 1}, + {"be", 1, 0}, + {"be", 3, 1}, + {"be", 23, 1}, + {"be", 5, 2}, + {"cs", 1, 0}, + {"cs", 3, 1}, + {"cs", 10, 2}, + {"ga", 1, 0}, + {"ga", 2, 1}, + {"ga", 5, 2}, + {"lt", 1, 0}, + {"lt", 3, 1}, + {"lt", 3, 1}, + {"lt", 10, 2}, + {"sl", 1, 0}, + {"sl", 2, 1}, + {"sl", 3, 2}, + {"sl", 4, 2}, + {"sl", 5, 3}, + {"sl", 10, 3}, + {"mk", 1, 0}, + {"mk", 2, 1}, + {"mt", 1, 0}, + {"mt", 0, 1}, + {"mt", 2, 1}, + {"mt", 11, 2}, + {"mt", 20, 3}, + {"lv", 0, 0}, + {"lv", 21, 1}, + {"lv", 11, 2}, + {"lv", 2, 2}, + {"pl", 1, 0}, + {"pl", 2, 1}, + {"pl", 5, 2}, + {"pl", 10, 2}, + {"cy", 1, 0}, + {"cy", 2, 1}, + {"cy", 8, 2}, + {"cy", 11, 2}, + {"cy", 20, 3}, + {"ro", 1, 0}, + {"ro", 0, 1}, + {"ro", 112, 1}, + {"ro", 21, 2}, + {"ar", 0, 0}, + {"ar", 1, 1}, + {"ar", 2, 2}, + {"ar", 4, 3}, + {"ar", 112, 4}, + {"ar", 102, 5}, + {"else", 10, 0}, + } + + for _, test := range tests { + m.Equal(test.expected, getPluralIndex(test.number, test.locale)) + } +} + +func stringPtr(s string) *string { + return &s +} diff --git a/translation/service_provider.go b/translation/service_provider.go new file mode 100644 index 000000000..9221ec2b0 --- /dev/null +++ b/translation/service_provider.go @@ -0,0 +1,29 @@ +package translation + +import ( + "context" + "path/filepath" + + "github.com/goravel/framework/contracts/foundation" +) + +const Binding = "goravel.translation" + +type ServiceProvider struct { +} + +func (translation *ServiceProvider) Register(app foundation.Application) { + app.BindWith(Binding, func(app foundation.Application, parameters map[string]any) (any, error) { + config := app.MakeConfig() + locale := config.GetString("app.locale") + fallback := config.GetString("app.fallback_locale") + loader := NewFileLoader([]string{filepath.Join("lang")}) + trans := NewTranslator(parameters["ctx"].(context.Context), loader, locale, fallback) + trans.SetLocale(locale) + return trans, nil + }) +} + +func (translation *ServiceProvider) Boot(app foundation.Application) { + +} diff --git a/translation/translator.go b/translation/translator.go new file mode 100644 index 000000000..6062d6dec --- /dev/null +++ b/translation/translator.go @@ -0,0 +1,207 @@ +package translation + +import ( + "context" + "strconv" + "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/language" + + "github.com/goravel/framework/contracts/http" + translationcontract "github.com/goravel/framework/contracts/translation" +) + +type Translator struct { + ctx context.Context + loader translationcontract.Loader + locale string + fallback string + loaded map[string]map[string]map[string]string + selector *MessageSelector + key string +} + +// contextKey is an unexported type for keys defined in this package. +type contextKey string + +const fallbackLocaleKey = contextKey("fallback_locale") +const localeKey = contextKey("locale") + +func NewTranslator(ctx context.Context, loader translationcontract.Loader, locale string, fallback string) *Translator { + return &Translator{ + ctx: ctx, + loader: loader, + locale: locale, + fallback: fallback, + loaded: make(map[string]map[string]map[string]string), + selector: NewMessageSelector(), + } +} + +func (t *Translator) Choice(key string, number int, options ...translationcontract.Option) (string, error) { + line, err := t.Get(key, options...) + if err != nil { + return "", err + } + + replace := map[string]string{ + "count": strconv.Itoa(number), + } + + locale := t.GetLocale() + if len(options) > 0 && options[0].Locale != "" { + locale = options[0].Locale + } + + return makeReplacements(t.selector.Choose(line, number, locale), replace), nil +} + +func (t *Translator) Get(key string, options ...translationcontract.Option) (string, error) { + if t.key == "" { + t.key = key + } + + locale := t.GetLocale() + // Check if a custom locale is provided in options. + if len(options) > 0 && options[0].Locale != "" { + locale = options[0].Locale + } + + fallback := true + // Check if a custom fallback is provided in options. + if len(options) > 0 && options[0].Fallback != nil { + fallback = *options[0].Fallback + } + + // Parse the key into folder and key parts. + folder, keyPart := parseKey(key) + + // For JSON translations, there is only one file per locale, so we will + // simply load the file and return the line if it exists. + // If the file doesn't exist, we will return fallback if it is enabled. + // Otherwise, we will return the key as the line. + if err := t.load(folder, locale); err != nil && err != ErrFileNotExist { + return "", err + } + + line := t.loaded[folder][locale][keyPart] + if line == "" { + fallbackFolder, fallbackLocale := parseKey(t.GetFallback()) + // If the fallback locale is different from the current locale, we will + // load in the lines for the fallback locale and try to retrieve the + // translation for the given key.If it is translated, we will return it. + // Otherwise, we can finally return the key as that will be the final + // fallback. + if (folder+locale != fallbackFolder+fallbackLocale) && fallback { + var fallbackOptions translationcontract.Option + if len(options) > 0 { + fallbackOptions = options[0] + } + fallbackOptions.Fallback = translationcontract.Bool(false) + fallbackOptions.Locale = fallbackLocale + return t.Get(fallbackFolder+"."+keyPart, fallbackOptions) + } + return t.key, nil + } + + // If the line doesn't contain any placeholders, we can return it right + // away.Otherwise, we will make the replacements on the line and return + // the result. + if len(options) > 0 { + return makeReplacements(line, options[0].Replace), nil + } + + return line, nil +} + +func (t *Translator) GetFallback() string { + if fallback, ok := t.ctx.Value(string(fallbackLocaleKey)).(string); ok { + return fallback + } + return t.fallback +} + +func (t *Translator) GetLocale() string { + if locale, ok := t.ctx.Value(string(localeKey)).(string); ok { + return locale + } + return t.locale +} + +func (t *Translator) Has(key string, options ...translationcontract.Option) bool { + line, err := t.Get(key, options...) + return err == nil && line != key +} + +func (t *Translator) SetFallback(locale string) context.Context { + t.fallback = locale + //nolint:all + t.ctx = context.WithValue(t.ctx, string(fallbackLocaleKey), locale) + + return t.ctx +} + +func (t *Translator) SetLocale(locale string) context.Context { + t.locale = locale + if ctx, ok := t.ctx.(http.Context); ok { + ctx.WithValue(string(localeKey), locale) + t.ctx = ctx + } else { + //nolint:all + t.ctx = context.WithValue(t.ctx, string(localeKey), locale) + } + return t.ctx +} + +func (t *Translator) load(folder string, locale string) error { + if t.isLoaded(folder, locale) { + return nil + } + + translations, err := t.loader.Load(folder, locale) + if err != nil { + return err + } + t.loaded[folder] = translations + return nil +} + +func (t *Translator) isLoaded(folder string, locale string) bool { + if _, ok := t.loaded[folder]; !ok { + return false + } + + if _, ok := t.loaded[folder][locale]; !ok { + return false + } + + return true +} + +func makeReplacements(line string, replace map[string]string) string { + if len(replace) == 0 { + return line + } + + var shouldReplace []string + casesTitle := cases.Title(language.Und) + for k, v := range replace { + shouldReplace = append(shouldReplace, ":"+k, v) + shouldReplace = append(shouldReplace, ":"+casesTitle.String(k), casesTitle.String(v)) + shouldReplace = append(shouldReplace, ":"+strings.ToUpper(k), strings.ToUpper(v)) + } + + return strings.NewReplacer(shouldReplace...).Replace(line) +} + +func parseKey(key string) (folder, keyPart string) { + parts := strings.Split(key, ".") + folder = "*" + keyPart = key + if len(parts) > 1 { + folder = strings.Join(parts[:len(parts)-1], ".") + keyPart = parts[len(parts)-1] + } + return folder, keyPart +} diff --git a/translation/translator_test.go b/translation/translator_test.go new file mode 100644 index 000000000..c352912bb --- /dev/null +++ b/translation/translator_test.go @@ -0,0 +1,346 @@ +package translation + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + translationcontract "github.com/goravel/framework/contracts/translation" + "github.com/goravel/framework/http" + mockloader "github.com/goravel/framework/mocks/translation" +) + +type TranslatorTestSuite struct { + suite.Suite + mockLoader *mockloader.Loader + ctx context.Context +} + +func TestTranslatorTestSuite(t *testing.T) { + suite.Run(t, &TranslatorTestSuite{}) +} + +func (t *TranslatorTestSuite) SetupTest() { + t.mockLoader = mockloader.NewLoader(t.T()) + t.ctx = context.Background() +} + +func (t *TranslatorTestSuite) TestChoice() { + translator := NewTranslator(t.ctx, t.mockLoader, "en", "en") + t.mockLoader.On("Load", "*", "en").Once().Return(map[string]map[string]string{ + "en": { + "foo": "{0} first|{1}second", + }, + }, nil) + translation, err := translator.Choice("foo", 1) + t.NoError(err) + t.Equal("second", translation) + + // test atomic replacements + translator = NewTranslator(t.ctx, t.mockLoader, "en", "en") + t.mockLoader.On("Load", "*", "fr").Once().Return(map[string]map[string]string{ + "en": { + "foo": "{0} first|{1}Hello, :foo!", + }, + }, nil) + translation, err = translator.Choice("foo", 1, translationcontract.Option{ + Replace: map[string]string{ + "foo": "baz:bar", + "bar": "abcdef", + }, + Locale: "fr", + }) + t.NoError(err) + t.Equal("Hello, baz:bar!", translation) + + translator = NewTranslator(t.ctx, t.mockLoader, "en", "en") + t.mockLoader.On("Load", "*", "en").Once().Return(nil, errors.New("some error")) + translation, err = translator.Choice("foo", 1) + t.EqualError(err, "some error") + t.Equal("", translation) +} + +func (t *TranslatorTestSuite) TestGet() { + translator := NewTranslator(t.ctx, t.mockLoader, "en", "en") + t.mockLoader.On("Load", "*", "en").Once().Return(map[string]map[string]string{ + "en": { + "foo": "one", + }, + }, nil) + translation, err := translator.Get("foo") + t.NoError(err) + t.Equal("one", translation) + + // Case: when file exists but there is some error + translator = NewTranslator(t.ctx, t.mockLoader, "en", "en") + t.mockLoader.On("Load", "*", "en").Once().Return(nil, errors.New("some error")) + translation, err = translator.Get("foo") + t.EqualError(err, "some error") + t.Equal("", translation) + + // Get json replacement + translator = NewTranslator(t.ctx, t.mockLoader, "en", "en") + t.mockLoader.On("Load", "*", "en").Once().Return(map[string]map[string]string{ + "en": { + "foo": "Hello, :name! Welcome to :location.", + }, + }, nil) + translation, err = translator.Get("foo", translationcontract.Option{ + Replace: map[string]string{ + "name": "krishan", + "location": "india", + }, + }) + t.NoError(err) + t.Equal("Hello, krishan! Welcome to india.", translation) + + // test atomic replacements + translator = NewTranslator(t.ctx, t.mockLoader, "en", "en") + t.mockLoader.On("Load", "*", "en").Once().Return(map[string]map[string]string{ + "en": { + "foo": "Hello, :foo!", + }, + }, nil) + translation, err = translator.Get("foo", translationcontract.Option{ + Replace: map[string]string{ + "foo": "baz:bar", + "bar": "abcdef", + }, + }) + t.NoError(err) + t.Equal("Hello, baz:bar!", translation) + + // preserve order of replacements + translator = NewTranslator(t.ctx, t.mockLoader, "en", "en") + t.mockLoader.On("Load", "*", "en").Once().Return(map[string]map[string]string{ + "en": { + "foo": ":greeting :name", + }, + }, nil) + translation, err = translator.Get("foo", translationcontract.Option{ + Replace: map[string]string{ + "name": "krishan", + "greeting": "Hello", + }, + }) + t.NoError(err) + t.Equal("Hello krishan", translation) + + // non-existing json key looks for regular keys + translator = NewTranslator(t.ctx, t.mockLoader, "en", "en") + t.mockLoader.On("Load", "foo", "en").Once().Return(map[string]map[string]string{ + "en": { + "bar": "one", + }, + }, nil) + translation, err = translator.Get("foo.bar") + t.NoError(err) + t.Equal("one", translation) + + // empty fallback + translator = NewTranslator(t.ctx, t.mockLoader, "en", "en") + t.mockLoader.On("Load", "*", "en").Once().Return(map[string]map[string]string{}, nil) + translation, err = translator.Get("foo") + t.NoError(err) + t.Equal("foo", translation) + + // Case: Fallback to a different locale + translator = NewTranslator(t.ctx, t.mockLoader, "en", "fr") + t.mockLoader.On("Load", "*", "en").Once().Return(map[string]map[string]string{}, nil) + t.mockLoader.On("Load", "*", "fr").Once().Return(map[string]map[string]string{ + "fr": { + "nonexistentKey": "French translation", + }, + }, nil) + translation, err = translator.Get("nonexistentKey", translationcontract.Option{ + Fallback: translationcontract.Bool(true), + Locale: "en", + }) + t.NoError(err) + t.Equal("French translation", translation) +} + +func (t *TranslatorTestSuite) TestGetLocale() { + translator := NewTranslator(t.ctx, t.mockLoader, "en", "en") + + // Case: Get locale initially set + locale := translator.GetLocale() + t.Equal("en", locale) + + // Case: Set locale using SetLocale and then get it + translator.SetLocale("fr") + locale = translator.GetLocale() + t.Equal("fr", locale) +} + +func (t *TranslatorTestSuite) TestGetFallback() { + translator := NewTranslator(t.ctx, t.mockLoader, "en", "en") + + // Case: No explicit fallback set + fallback := translator.GetFallback() + t.Equal("en", fallback) + + // Case: Set fallback using SetFallback + newCtx := translator.SetFallback("fr") + fallback = translator.GetFallback() + t.Equal("fr", fallback) + t.Equal("fr", newCtx.Value(string(fallbackLocaleKey))) +} + +func (t *TranslatorTestSuite) TestHas() { + // Case: Key exists in translations + translator := NewTranslator(t.ctx, t.mockLoader, "en", "en") + t.mockLoader.On("Load", "*", "en").Once().Return(map[string]map[string]string{ + "en": { + "hello": "world", + }, + }, nil) + hasKey := translator.Has("hello") + t.True(hasKey) + + // Case: Key does not exist in translations + translator = NewTranslator(t.ctx, t.mockLoader, "en", "en") + t.mockLoader.On("Load", "*", "en").Once().Return(map[string]map[string]string{ + "en": { + "name": "Bowen", + }, + }, nil) + hasKey = translator.Has("email") + t.False(hasKey) + + // Case: Key exists, but translation is the same as the key + translator = NewTranslator(t.ctx, t.mockLoader, "en", "en") + t.mockLoader.On("Load", "*", "en").Once().Return(map[string]map[string]string{ + "en": { + "sameKey": "sameKey", + }, + }, nil) + hasKey = translator.Has("sameKey") + t.False(hasKey) +} + +func (t *TranslatorTestSuite) TestSetFallback() { + translator := NewTranslator(t.ctx, t.mockLoader, "en", "en") + + // Case: Set fallback using SetFallback + newCtx := translator.SetFallback("fr") + t.Equal("fr", translator.fallback) + t.Equal("fr", newCtx.Value(string(fallbackLocaleKey))) +} + +func (t *TranslatorTestSuite) TestSetLocale() { + translator := NewTranslator(t.ctx, t.mockLoader, "en", "en") + + // Case: Set locale using SetLocale + newCtx := translator.SetLocale("fr") + t.Equal("fr", translator.locale) + t.Equal("fr", newCtx.Value(string(localeKey))) + + // Case: use http.Context + translator = NewTranslator(http.Background(), t.mockLoader, "en", "en") + newCtx = translator.SetLocale("lv") + t.Equal("lv", translator.locale) + t.Equal("lv", newCtx.Value(string(localeKey))) +} + +func (t *TranslatorTestSuite) TestLoad() { + translator := NewTranslator(t.ctx, t.mockLoader, "en", "en") + t.mockLoader.On("Load", "test", "en").Once().Return(map[string]map[string]string{ + "en": { + "foo": "one", + "bar": "two", + }, + }, nil) + + // Case: Not loaded, successful load + err := translator.load("test", "en") + t.NoError(err) + t.Equal("one", translator.loaded["test"]["en"]["foo"]) + + // Case: Already loaded + err = translator.load("test", "en") + t.NoError(err) + t.Equal("two", translator.loaded["test"]["en"]["bar"]) + + // Case: Not loaded, loader returns an error + t.mockLoader.On("Load", "folder3", "es").Once().Return(nil, ErrFileNotExist) + err = translator.load("folder3", "es") + t.EqualError(err, "translation file does not exist") + t.Nil(translator.loaded["folder3"]) +} + +func (t *TranslatorTestSuite) TestIsLoaded() { + translator := NewTranslator(t.ctx, t.mockLoader, "en", "en") + t.mockLoader.On("Load", "test", "en").Once().Return(map[string]map[string]string{ + "en": { + "foo": "one", + }, + }, nil) + err := translator.load("test", "en") + t.NoError(err) + + // Case: Folder and locale are not loaded + t.False(translator.isLoaded("folder1", "fr")) + + // Case: Folder is loaded, but locale is not loaded + t.False(translator.isLoaded("test", "fr")) + + // Case: Both folder and locale are loaded + t.True(translator.isLoaded("test", "en")) +} + +func TestMakeReplacements(t *testing.T) { + tests := []struct { + line string + replace map[string]string + expected string + }{ + { + line: "Hello, :name! Welcome to :location.", + replace: map[string]string{ + "name": "krishan", + "location": "india", + }, + expected: "Hello, krishan! Welcome to india.", + }, + { + line: "Testing with no replacements.", + replace: map[string]string{}, + expected: "Testing with no replacements.", + }, + { + line: "Replace :mohan with :SOHAM.", + replace: map[string]string{ + "mohan": "lower", + "SOHAM": "UPPER", + }, + expected: "Replace lower with UPPER.", + }, + } + + for _, test := range tests { + result := makeReplacements(test.line, test.replace) + assert.Equal(t, test.expected, result) + } +} + +func TestParseKey(t *testing.T) { + tests := []struct { + key string + folder string + keyPart string + }{ + {key: "foo", folder: "*", keyPart: "foo"}, + {key: "foo.bar", folder: "foo", keyPart: "bar"}, + {key: "foo.bar.baz", folder: "foo.bar", keyPart: "baz"}, + } + + for _, test := range tests { + folder, keyPart := parseKey(test.key) + assert.Equal(t, test.folder, folder) + assert.Equal(t, test.keyPart, keyPart) + } +}