diff --git a/HISTORY.md b/HISTORY.md index 1dba7d51..2bec68fb 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -359,6 +359,8 @@ Response: Other Improvements: +- Implement feature request [Log when I18n Translation Fails?](https://github.com/kataras/iris/issues/1593) by using the new `Application.I18n.DefaultMessageFunc` field **before** `I18n.Load`. [Example of usage](https://github.com/kataras/iris/blob/master/_examples/i18n/main.go#L28-L50). + - Fix [#1594](https://github.com/kataras/iris/issues/1594) and add a new `PathAfterHandler` which can be set to true to enable the old behavior (not recommended though). - New [apps](https://github.com/kataras/iris/tree/master/apps) subpackage. [Example of usage](https://github.com/kataras/iris/tree/master/_examples/routing/subdomains/redirect/multi-instances). diff --git a/_examples/i18n/main.go b/_examples/i18n/main.go index eecf93c0..2b11ac03 100644 --- a/_examples/i18n/main.go +++ b/_examples/i18n/main.go @@ -1,6 +1,8 @@ package main import ( + "fmt" + "github.com/kataras/iris/v12" ) @@ -8,12 +10,7 @@ func newApp() *iris.Application { app := iris.New() // Configure i18n. - // First parameter: Glob filpath patern, - // Second variadic parameter: Optional language tags, the first one is the default/fallback one. - err := app.I18n.Load("./locales/*/*.ini", "en-US", "el-GR", "zh-CN") - if err != nil { - panic(err) - } + // // app.I18n.Subdomain = false to disable resolve lang code from subdomain. // app.I18n.LoadAssets for go-bindata. @@ -27,6 +24,31 @@ func newApp() *iris.Application { // // See `app.I18n.ExtractFunc = func(ctx iris.Context) string` or // `ctx.SetLanguage(langCode string)` to change the extracted language from a request. + // + // Use DefaultMessageFunc to customize the return value of a not found key or lang. + // All language inputs fallback to the default locale if not matched. + // This is why this one accepts both input and matched languages, + // so the caller can be more expressful knowing those. + // Defaults to nil. + app.I18n.DefaultMessageFunc = func(langInput, langMatched, key string, args ...interface{}) string { + msg := fmt.Sprintf("user language input: %s: matched as: %s: not found key: %s: args: %v", langInput, langMatched, key, args) + app.Logger().Warn(msg) + return msg + } + // Load i18n when customizations are set in place. + // + // First parameter: Glob filpath patern, + // Second variadic parameter: Optional language tags, the first one is the default/fallback one. + err := app.I18n.Load("./locales/*/*.ini", "en-US", "el-GR", "zh-CN") + if err != nil { + panic(err) + } + + app.Get("/not-matched", func(ctx iris.Context) { + text := ctx.Tr("not_found_key", "some", "values", 42) + ctx.WriteString(text) + // user language input: en-gb: matched as: en-US: not found key: not_found_key: args: [some values 42] + }) app.Get("/", func(ctx iris.Context) { hi := ctx.Tr("hi", "iris") diff --git a/_examples/i18n/main_test.go b/_examples/i18n/main_test.go index f625c29c..73f86d1d 100644 --- a/_examples/i18n/main_test.go +++ b/_examples/i18n/main_test.go @@ -83,4 +83,6 @@ func TestI18n(t *testing.T) { e.GET("/el-templates").Expect().Status(httptest.StatusNotFound) e.GET("/el/templates").Expect().Status(httptest.StatusOK).Body().Contains(elGR).Contains(zhCN) + + e.GET("/not-matched").WithQuery("lang", "en-gb").Expect().Status(httptest.StatusOK).Body().Equal("user language input: en-gb: matched as: en-US: not found key: not_found_key: args: [some values 42]") } diff --git a/configuration.go b/configuration.go index e9dcf3ab..1e6988f9 100644 --- a/configuration.go +++ b/configuration.go @@ -752,6 +752,11 @@ type Configuration struct { // See `i18n.ExtractFunc` for a more organised way of the same feature. // Defaults to "iris.locale.language". LanguageContextKey string `json:"languageContextKey,omitempty" yaml:"LanguageContextKey" toml:"LanguageContextKey"` + // LanguageInputContextKey is the context key of a language that is given by the end-user. + // It's the real user input of the language string, matched or not. + // + // Defaults to "iris.locale.language.input". + LanguageInputContextKey string `json:"languageInputContextKey,omitempty" yaml:"LanguageInputContextKey" toml:"LanguageInputContextKey"` // VersionContextKey is the context key which an API Version can be modified // via a middleware through `SetVersion` method, e.g. `versioning.SetVersion(ctx, "1.0, 1.1")`. // Defaults to "iris.api.version". @@ -952,6 +957,11 @@ func (c Configuration) GetLanguageContextKey() string { return c.LanguageContextKey } +// GetLanguageInputContextKey returns the LanguageInputContextKey field. +func (c Configuration) GetLanguageInputContextKey() string { + return c.LanguageInputContextKey +} + // GetVersionContextKey returns the VersionContextKey field. func (c Configuration) GetVersionContextKey() string { return c.VersionContextKey @@ -1107,6 +1117,10 @@ func WithConfiguration(c Configuration) Configurator { main.LanguageContextKey = v } + if v := c.LanguageInputContextKey; v != "" { + main.LanguageInputContextKey = v + } + if v := c.VersionContextKey; v != "" { main.VersionContextKey = v } @@ -1184,15 +1198,16 @@ func DefaultConfiguration() Configuration { // The request body the size limit // can be set by the middleware `LimitRequestBodySize` // or `context#SetMaxRequestBodySize`. - PostMaxMemory: 32 << 20, // 32MB - LocaleContextKey: "iris.locale", - LanguageContextKey: "iris.locale.language", - VersionContextKey: "iris.api.version", - ViewEngineContextKey: "iris.view.engine", - ViewLayoutContextKey: "iris.view.layout", - ViewDataContextKey: "iris.view.data", - RemoteAddrHeaders: nil, - RemoteAddrHeadersForce: false, + PostMaxMemory: 32 << 20, // 32MB + LocaleContextKey: "iris.locale", + LanguageContextKey: "iris.locale.language", + LanguageInputContextKey: "iris.locale.language.input", + VersionContextKey: "iris.api.version", + ViewEngineContextKey: "iris.view.engine", + ViewLayoutContextKey: "iris.view.layout", + ViewDataContextKey: "iris.view.data", + RemoteAddrHeaders: nil, + RemoteAddrHeadersForce: false, RemoteAddrPrivateSubnets: []netutil.IPRange{ { Start: net.ParseIP("10.0.0.0"), diff --git a/context/configuration.go b/context/configuration.go index fa9091d7..ea0e7f25 100644 --- a/context/configuration.go +++ b/context/configuration.go @@ -49,6 +49,8 @@ type ConfigurationReadOnly interface { GetLocaleContextKey() string // GetLanguageContextKey returns the LanguageContextKey field. GetLanguageContextKey() string + // GetLanguageInputContextKey returns the LanguageInputContextKey field. + GetLanguageInputContextKey() string // GetVersionContextKey returns the VersionContextKey field. GetVersionContextKey() string diff --git a/context/context.go b/context/context.go index b2d9de41..b2dc3b49 100644 --- a/context/context.go +++ b/context/context.go @@ -1203,6 +1203,7 @@ func (ctx *Context) SetLanguage(langCode string) { } // GetLocale returns the current request's `Locale` found by i18n middleware. +// It always fallbacks to the default one. // See `Tr` too. func (ctx *Context) GetLocale() Locale { // Cache the Locale itself for multiple calls of `Tr` method. @@ -1225,11 +1226,13 @@ func (ctx *Context) GetLocale() Locale { // See `GetLocale` too. // // Example: https://github.com/kataras/iris/tree/master/_examples/i18n -func (ctx *Context) Tr(message string, values ...interface{}) string { // other name could be: Localize. - if locale := ctx.GetLocale(); locale != nil { // TODO: here... I need to change the logic, if not found then call the i18n's get locale and set the value in order to be fastest on routes that are not using (no need to reigster a middleware.) - return locale.GetMessage(message, values...) +func (ctx *Context) Tr(message string, values ...interface{}) string { + if locale := ctx.GetLocale(); locale != nil { + return locale.GetMessageContext(ctx, message, values...) } + // This should never happen as the locale fallbacks to + // the default. return message } diff --git a/context/i18n.go b/context/i18n.go index e856c5e9..31be602c 100644 --- a/context/i18n.go +++ b/context/i18n.go @@ -10,8 +10,8 @@ type I18nReadOnly interface { Tr(lang string, format string, args ...interface{}) string } -// Locale is the interface which returns from a `Localizer.GetLocale` metod. -// It serves the transltions based on "key" or format. See `GetMessage`. +// Locale is the interface which returns from a `Localizer.GetLocale` method. +// It serves the translations based on "key" or format. See `GetMessage`. type Locale interface { // Index returns the current locale index from the languages list. Index() int @@ -23,6 +23,12 @@ type Locale interface { // // Same as `Tag().String()` but it's static. Language() string - // GetMessage should return translated text based n the given "key". + // GetMessage should return translated text based on the given "key". GetMessage(key string, args ...interface{}) string + // GetMessageContext same as GetMessage + // but it accepts the Context as its first input. + // If DefaultMessageFunc was not nil then this Context + // will provide the real language input instead of the locale's which + // may be the default language one. + GetMessageContext(ctx *Context, key string, args ...interface{}) string } diff --git a/i18n/i18n.go b/i18n/i18n.go index dde5d76b..5b38ad17 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -29,6 +29,19 @@ type ( // It may return the default language if nothing else matches based on custom localizer's criteria. GetLocale(index int) context.Locale } + + // MessageFunc is the function type to modify the behavior when a key or language was not found. + // All language inputs fallback to the default locale if not matched. + // This is why this signature accepts both input and matched languages, so caller + // can provide better messages. + // + // The first parameter is set to the client real input of the language, + // the second one is set to the matched language (default one if input wasn't matched) + // and the third and forth are the translation format/key and its optional arguments. + // + // Note: we don't accept the Context here because Tr method and template func {{ tr }} + // have no direct access to it. + MessageFunc func(langInput, langMatched, key string, args ...interface{}) string ) // I18n is the structure which keeps the i18n configuration and implements localization and internationalization features. @@ -43,6 +56,15 @@ type I18n struct { // to extract the language tag name. // Defaults to nil. ExtractFunc func(ctx *context.Context) string + // DefaultMessageFunc is the field which can be used + // to modify the behavior when a key or language was not found. + // All language inputs fallback to the default locale if not matched. + // This is why this one accepts both input and matched languages, + // so the caller can be more expressful knowing those. + // + // Defaults to nil. + DefaultMessageFunc MessageFunc + // If not empty, it is language identifier by url query. // // Defaults to "lang". @@ -115,9 +137,10 @@ func (i *I18n) Reset(loader Loader, languages ...string) error { i.loader = loader i.matcher = &Matcher{ - strict: len(tags) > 0, - Languages: tags, - matcher: language.NewMatcher(tags), + strict: len(tags) > 0, + Languages: tags, + matcher: language.NewMatcher(tags), + defaultMessageFunc: i.DefaultMessageFunc, } return i.reload() @@ -193,6 +216,8 @@ type Matcher struct { strict bool Languages []language.Tag matcher language.Matcher + // defaultMessageFunc passed by the i18n structure. + defaultMessageFunc MessageFunc } var _ language.Matcher = (*Matcher)(nil) @@ -295,24 +320,32 @@ func (i *I18n) TryMatchString(s string) (language.Tag, int, bool) { // Tr returns a translated message based on the "lang" language code // and its key(format) with any optional arguments attached to it. // -// It returns an empty string if "format" not matched. -func (i *I18n) Tr(lang, format string, args ...interface{}) string { +// It returns an empty string if "lang" not matched, unless DefaultMessageFunc. +// It returns the default language's translation if "key" not matched, unless DefaultMessageFunc. +func (i *I18n) Tr(lang, format string, args ...interface{}) (msg string) { _, index, ok := i.TryMatchString(lang) if !ok { index = 0 } + langMatched := "" + loc := i.localizer.GetLocale(index) if loc != nil { - msg := loc.GetMessage(format, args...) - if msg == "" && !i.Strict && index > 0 { + langMatched = loc.Language() + + msg = loc.GetMessage(format, args...) + if msg == "" && i.DefaultMessageFunc == nil && !i.Strict && index > 0 { // it's not the default/fallback language and not message found for that lang:key. - return i.localizer.GetLocale(0).GetMessage(format, args...) + msg = i.localizer.GetLocale(0).GetMessage(format, args...) } - return msg } - return "" + if msg == "" && i.DefaultMessageFunc != nil { + msg = i.DefaultMessageFunc(lang, langMatched, format, args) + } + + return } const acceptLanguageHeaderKey = "Accept-Language" @@ -321,12 +354,19 @@ const acceptLanguageHeaderKey = "Accept-Language" // It will return the first registered language if nothing else matched. func (i *I18n) GetLocale(ctx *context.Context) context.Locale { var ( - index int - ok bool + index int + ok bool + extractedLang string ) + languageInputKey := ctx.Application().ConfigurationReadOnly().GetLanguageInputContextKey() + if contextKey := ctx.Application().ConfigurationReadOnly().GetLanguageContextKey(); contextKey != "" { if v := ctx.Values().GetString(contextKey); v != "" { + if languageInputKey != "" { + ctx.Values().Set(languageInputKey, v) + } + if v == "default" { index = 0 // no need to call `TryMatchString` and spend time. } else { @@ -344,30 +384,35 @@ func (i *I18n) GetLocale(ctx *context.Context) context.Locale { if !ok && i.ExtractFunc != nil { if v := i.ExtractFunc(ctx); v != "" { + extractedLang = v _, index, ok = i.TryMatchString(v) } } if !ok && i.URLParameter != "" { if v := ctx.URLParam(i.URLParameter); v != "" { + extractedLang = v _, index, ok = i.TryMatchString(v) } } if !ok && i.Cookie != "" { if v := ctx.GetCookie(i.Cookie); v != "" { + extractedLang = v _, index, ok = i.TryMatchString(v) // url.QueryUnescape(cookie.Value) } } if !ok && i.Subdomain { if v := ctx.Subdomain(); v != "" { + extractedLang = v _, index, ok = i.TryMatchString(v) } } if !ok { if v := ctx.GetHeader(acceptLanguageHeaderKey); v != "" { + extractedLang = v // note. desired, _, err := language.ParseAcceptLanguage(v) if err == nil { if _, idx, conf := i.matcher.Match(desired...); conf > language.Low { @@ -380,8 +425,14 @@ func (i *I18n) GetLocale(ctx *context.Context) context.Locale { // locale := i.localizer.GetLocale(index) // ctx.Values().Set(ctx.Application().ConfigurationReadOnly().GetLocaleContextKey(), locale) - // // if 0 then it defaults to the first language. - // return locale + if languageInputKey != "" { + // Set the user input we wanna use it on DefaultMessageFunc. + // Even if matched because it may be en-gb or en but if there is a language registered + // as en-us it will be successfully matched ( see TrymatchString and Low conf). + ctx.Values().Set(languageInputKey, extractedLang) + } + + // if index == 0 then it defaults to the first language. locale := i.localizer.GetLocale(index) if locale == nil { return nil @@ -391,18 +442,27 @@ func (i *I18n) GetLocale(ctx *context.Context) context.Locale { } // GetMessage returns the localized text message for this "r" request based on the key "format". -// It returns an empty string if locale or format not found. -func (i *I18n) GetMessage(ctx *context.Context, format string, args ...interface{}) string { +// It returns an empty string if context's locale not matched, unless DefaultMessageFunc. +// It returns the default language's translation if "key" not matched, unless DefaultMessageFunc. +func (i *I18n) GetMessage(ctx *context.Context, format string, args ...interface{}) (msg string) { loc := i.GetLocale(ctx) + langMatched := "" if loc != nil { + langMatched = loc.Language() // it's not the default/fallback language and not message found for that lang:key. - msg := loc.GetMessage(format, args...) - if msg == "" && !i.Strict && loc.Index() > 0 { + msg = loc.GetMessage(format, args...) + if msg == "" && i.DefaultMessageFunc == nil && !i.Strict && loc.Index() > 0 { return i.localizer.GetLocale(0).GetMessage(format, args...) } } - return "" + if msg == "" && i.DefaultMessageFunc != nil { + langInput := ctx.Values().GetString(ctx.Application().ConfigurationReadOnly().GetLanguageInputContextKey()) + + msg = i.DefaultMessageFunc(langInput, langMatched, format, args...) + } + + return } func (i *I18n) setLangWithoutContext(w http.ResponseWriter, r *http.Request, lang string) { diff --git a/i18n/loader.go b/i18n/loader.go index afc7782d..4c3de0a9 100644 --- a/i18n/loader.go +++ b/i18n/loader.go @@ -149,6 +149,8 @@ func load(assetNames []string, asset func(string) ([]byte, error), options ...Lo templateKeys: templateKeys, lineKeys: lineKeys, other: other, + + defaultMessageFunc: m.defaultMessageFunc, } } @@ -217,6 +219,8 @@ type defaultLocale struct { templateKeys map[string]*template.Template lineKeys map[string]string other map[string]interface{} + + defaultMessageFunc MessageFunc } func (l *defaultLocale) Index() int { @@ -232,6 +236,15 @@ func (l *defaultLocale) Language() string { } func (l *defaultLocale) GetMessage(key string, args ...interface{}) string { + return l.getMessage(l.id, key, args...) +} + +func (l *defaultLocale) GetMessageContext(ctx *context.Context, key string, args ...interface{}) string { + langInput := ctx.Values().GetString(ctx.Application().ConfigurationReadOnly().GetLanguageInputContextKey()) + return l.getMessage(langInput, key, args...) +} + +func (l *defaultLocale) getMessage(langInput, key string, args ...interface{}) string { n := len(args) if n > 0 { // search on templates. @@ -254,6 +267,11 @@ func (l *defaultLocale) GetMessage(key string, args ...interface{}) string { return fmt.Sprintf("%v", v) } + if l.defaultMessageFunc != nil { + // let langInput to be empty if that's the case. + return l.defaultMessageFunc(langInput, l.id, key, args...) + } + return "" }