diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-10-11 10:27:00 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-10-11 10:27:00 +0000 |
commit | 65aa53fc52ff15efe54df4147564828d535837f8 (patch) | |
tree | 31c51dad04fdcca80e6d3043c8bd49d2f1a51f83 /modules/translation | |
parent | Initial commit. (diff) | |
download | forgejo-upstream.tar.xz forgejo-upstream.zip |
Adding upstream version 8.0.3.HEADupstream/8.0.3upstreamdebian
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'modules/translation')
-rw-r--r-- | modules/translation/i18n/errors.go | 13 | ||||
-rw-r--r-- | modules/translation/i18n/format.go | 41 | ||||
-rw-r--r-- | modules/translation/i18n/i18n.go | 50 | ||||
-rw-r--r-- | modules/translation/i18n/i18n_test.go | 204 | ||||
-rw-r--r-- | modules/translation/i18n/localestore.go | 166 | ||||
-rw-r--r-- | modules/translation/mock.go | 40 | ||||
-rw-r--r-- | modules/translation/translation.go | 303 | ||||
-rw-r--r-- | modules/translation/translation_test.go | 50 |
8 files changed, 867 insertions, 0 deletions
diff --git a/modules/translation/i18n/errors.go b/modules/translation/i18n/errors.go new file mode 100644 index 00000000..7f64ccf9 --- /dev/null +++ b/modules/translation/i18n/errors.go @@ -0,0 +1,13 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package i18n + +import ( + "code.gitea.io/gitea/modules/util" +) + +var ( + ErrLocaleAlreadyExist = util.SilentWrap{Message: "lang already exists", Err: util.ErrAlreadyExist} + ErrUncertainArguments = util.SilentWrap{Message: "arguments to i18n should not contain uncertain slices", Err: util.ErrInvalidArgument} +) diff --git a/modules/translation/i18n/format.go b/modules/translation/i18n/format.go new file mode 100644 index 00000000..e5e22183 --- /dev/null +++ b/modules/translation/i18n/format.go @@ -0,0 +1,41 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package i18n + +import ( + "fmt" + "reflect" +) + +// Format formats provided arguments for a given translated message +func Format(format string, args ...any) (msg string, err error) { + if len(args) == 0 { + return format, nil + } + + fmtArgs := make([]any, 0, len(args)) + for _, arg := range args { + val := reflect.ValueOf(arg) + if val.Kind() == reflect.Slice { + // Previously, we would accept Tr(lang, key, a, [b, c], d, [e, f]) as Sprintf(msg, a, b, c, d, e, f) + // but this is an unstable behavior. + // + // So we restrict the accepted arguments to either: + // + // 1. Tr(lang, key, [slice-items]) as Sprintf(msg, items...) + // 2. Tr(lang, key, args...) as Sprintf(msg, args...) + if len(args) == 1 { + for i := 0; i < val.Len(); i++ { + fmtArgs = append(fmtArgs, val.Index(i).Interface()) + } + } else { + err = ErrUncertainArguments + break + } + } else { + fmtArgs = append(fmtArgs, arg) + } + } + return fmt.Sprintf(format, fmtArgs...), err +} diff --git a/modules/translation/i18n/i18n.go b/modules/translation/i18n/i18n.go new file mode 100644 index 00000000..1555cd96 --- /dev/null +++ b/modules/translation/i18n/i18n.go @@ -0,0 +1,50 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package i18n + +import ( + "html/template" + "io" +) + +var DefaultLocales = NewLocaleStore() + +type Locale interface { + // TrString translates a given key and arguments for a language + TrString(trKey string, trArgs ...any) string + // TrHTML translates a given key and arguments for a language, string arguments are escaped to HTML + TrHTML(trKey string, trArgs ...any) template.HTML + // HasKey reports if a locale has a translation for a given key + HasKey(trKey string) bool +} + +// LocaleStore provides the functions common to all locale stores +type LocaleStore interface { + io.Closer + + // SetDefaultLang sets the default language to fall back to + SetDefaultLang(lang string) + // ListLangNameDesc provides paired slices of language names to descriptors + ListLangNameDesc() (names, desc []string) + // Locale return the locale for the provided language or the default language if not found + Locale(langName string) (Locale, bool) + // HasLang returns whether a given language is present in the store + HasLang(langName string) bool + // AddLocaleByIni adds a new language to the store + AddLocaleByIni(langName, langDesc string, source, moreSource []byte) error +} + +// ResetDefaultLocales resets the current default locales +// NOTE: this is not synchronized +func ResetDefaultLocales() { + if DefaultLocales != nil { + _ = DefaultLocales.Close() + } + DefaultLocales = NewLocaleStore() +} + +// GetLocale returns the locale from the default locales +func GetLocale(lang string) (Locale, bool) { + return DefaultLocales.Locale(lang) +} diff --git a/modules/translation/i18n/i18n_test.go b/modules/translation/i18n/i18n_test.go new file mode 100644 index 00000000..244f6ffb --- /dev/null +++ b/modules/translation/i18n/i18n_test.go @@ -0,0 +1,204 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package i18n + +import ( + "html/template" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLocaleStore(t *testing.T) { + testData1 := []byte(` +.dot.name = Dot Name +fmt = %[1]s %[2]s + +[section] +sub = Sub String +mixed = test value; <span style="color: red\; background: none;">%s</span> +`) + + testData2 := []byte(` +fmt = %[2]s %[1]s + +[section] +sub = Changed Sub String +`) + + ls := NewLocaleStore() + require.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", testData1, nil)) + require.NoError(t, ls.AddLocaleByIni("lang2", "Lang2", testData2, nil)) + ls.SetDefaultLang("lang1") + + lang1, _ := ls.Locale("lang1") + lang2, _ := ls.Locale("lang2") + + result := lang1.TrString("fmt", "a", "b") + assert.Equal(t, "a b", result) + + result = lang2.TrString("fmt", "a", "b") + assert.Equal(t, "b a", result) + + result = lang1.TrString("section.sub") + assert.Equal(t, "Sub String", result) + + result = lang2.TrString("section.sub") + assert.Equal(t, "Changed Sub String", result) + + langNone, _ := ls.Locale("none") + result = langNone.TrString(".dot.name") + assert.Equal(t, "Dot Name", result) + + result2 := lang2.TrHTML("section.mixed", "a&b") + assert.EqualValues(t, `test value; <span style="color: red; background: none;">a&b</span>`, result2) + + langs, descs := ls.ListLangNameDesc() + assert.ElementsMatch(t, []string{"lang1", "lang2"}, langs) + assert.ElementsMatch(t, []string{"Lang1", "Lang2"}, descs) + + found := lang1.HasKey("no-such") + assert.False(t, found) + require.NoError(t, ls.Close()) +} + +func TestLocaleStoreMoreSource(t *testing.T) { + testData1 := []byte(` +a=11 +b=12 +`) + + testData2 := []byte(` +b=21 +c=22 +`) + + ls := NewLocaleStore() + require.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", testData1, testData2)) + lang1, _ := ls.Locale("lang1") + assert.Equal(t, "11", lang1.TrString("a")) + assert.Equal(t, "21", lang1.TrString("b")) + assert.Equal(t, "22", lang1.TrString("c")) +} + +type stringerPointerReceiver struct { + s string +} + +func (s *stringerPointerReceiver) String() string { + return s.s +} + +type stringerStructReceiver struct { + s string +} + +func (s stringerStructReceiver) String() string { + return s.s +} + +type errorStructReceiver struct { + s string +} + +func (e errorStructReceiver) Error() string { + return e.s +} + +type errorPointerReceiver struct { + s string +} + +func (e *errorPointerReceiver) Error() string { + return e.s +} + +func TestLocaleWithTemplate(t *testing.T) { + ls := NewLocaleStore() + require.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", []byte(`key=<a>%s</a>`), nil)) + lang1, _ := ls.Locale("lang1") + + tmpl := template.New("test").Funcs(template.FuncMap{"tr": lang1.TrHTML}) + tmpl = template.Must(tmpl.Parse(`{{tr "key" .var}}`)) + + cases := []struct { + in any + want string + }{ + {"<str>", "<a><str></a>"}, + {[]byte("<bytes>"), "<a>[60 98 121 116 101 115 62]</a>"}, + {template.HTML("<html>"), "<a><html></a>"}, + {stringerPointerReceiver{"<stringerPointerReceiver>"}, "<a>{<stringerPointerReceiver>}</a>"}, + {&stringerPointerReceiver{"<stringerPointerReceiver ptr>"}, "<a><stringerPointerReceiver ptr></a>"}, + {stringerStructReceiver{"<stringerStructReceiver>"}, "<a><stringerStructReceiver></a>"}, + {&stringerStructReceiver{"<stringerStructReceiver ptr>"}, "<a><stringerStructReceiver ptr></a>"}, + {errorStructReceiver{"<errorStructReceiver>"}, "<a><errorStructReceiver></a>"}, + {&errorStructReceiver{"<errorStructReceiver ptr>"}, "<a><errorStructReceiver ptr></a>"}, + {errorPointerReceiver{"<errorPointerReceiver>"}, "<a>{<errorPointerReceiver>}</a>"}, + {&errorPointerReceiver{"<errorPointerReceiver ptr>"}, "<a><errorPointerReceiver ptr></a>"}, + } + + buf := &strings.Builder{} + for _, c := range cases { + buf.Reset() + require.NoError(t, tmpl.Execute(buf, map[string]any{"var": c.in})) + assert.Equal(t, c.want, buf.String()) + } +} + +func TestLocaleStoreQuirks(t *testing.T) { + const nl = "\n" + q := func(q1, s string, q2 ...string) string { + return q1 + s + strings.Join(q2, "") + } + testDataList := []struct { + in string + out string + hint string + }{ + {` xx`, `xx`, "simple, no quote"}, + {`" xx"`, ` xx`, "simple, double-quote"}, + {`' xx'`, ` xx`, "simple, single-quote"}, + {"` xx`", ` xx`, "simple, back-quote"}, + + {`x\"y`, `x\"y`, "no unescape, simple"}, + {q(`"`, `x\"y`, `"`), `"x\"y"`, "unescape, double-quote"}, + {q(`'`, `x\"y`, `'`), `x\"y`, "no unescape, single-quote"}, + {q("`", `x\"y`, "`"), `x\"y`, "no unescape, back-quote"}, + + {q(`"`, `x\"y`) + nl + "b=", `"x\"y`, "half open, double-quote"}, + {q(`'`, `x\"y`) + nl + "b=", `'x\"y`, "half open, single-quote"}, + {q("`", `x\"y`) + nl + "b=`", `x\"y` + nl + "b=", "half open, back-quote, multi-line"}, + + {`x ; y`, `x ; y`, "inline comment (;)"}, + {`x # y`, `x # y`, "inline comment (#)"}, + {`x \; y`, `x ; y`, `inline comment (\;)`}, + {`x \# y`, `x # y`, `inline comment (\#)`}, + } + + for _, testData := range testDataList { + ls := NewLocaleStore() + err := ls.AddLocaleByIni("lang1", "Lang1", []byte("a="+testData.in), nil) + lang1, _ := ls.Locale("lang1") + require.NoError(t, err, testData.hint) + assert.Equal(t, testData.out, lang1.TrString("a"), testData.hint) + require.NoError(t, ls.Close()) + } + + // TODO: Crowdin needs the strings to be quoted correctly and doesn't like incomplete quotes + // and Crowdin always outputs quoted strings if there are quotes in the strings. + // So, Gitea's `key="quoted" unquoted` content shouldn't be used on Crowdin directly, + // it should be converted to `key="\"quoted\" unquoted"` first. + // TODO: We can not use UnescapeValueDoubleQuotes=true, because there are a lot of back-quotes in en-US.ini, + // then Crowdin will output: + // > key = "`x \" y`" + // Then Gitea will read a string with back-quotes, which is incorrect. + // TODO: Crowdin might generate multi-line strings, quoted by double-quote, it's not supported by LocaleStore + // LocaleStore uses back-quote for multi-line strings, it's not supported by Crowdin. + // TODO: Crowdin doesn't support back-quote as string quoter, it mainly uses double-quote + // so, the following line will be parsed as: value="`first", comment="second`" on Crowdin + // > a = `first; second` +} diff --git a/modules/translation/i18n/localestore.go b/modules/translation/i18n/localestore.go new file mode 100644 index 00000000..0e6ddab4 --- /dev/null +++ b/modules/translation/i18n/localestore.go @@ -0,0 +1,166 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package i18n + +import ( + "fmt" + "html/template" + "slices" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +// This file implements the static LocaleStore that will not watch for changes + +type locale struct { + store *localeStore + langName string + idxToMsgMap map[int]string // the map idx is generated by store's trKeyToIdxMap +} + +var _ Locale = (*locale)(nil) + +type localeStore struct { + // After initializing has finished, these fields are read-only. + langNames []string + langDescs []string + + localeMap map[string]*locale + trKeyToIdxMap map[string]int + + defaultLang string +} + +// NewLocaleStore creates a static locale store +func NewLocaleStore() LocaleStore { + return &localeStore{localeMap: make(map[string]*locale), trKeyToIdxMap: make(map[string]int)} +} + +// AddLocaleByIni adds locale by ini into the store +func (store *localeStore) AddLocaleByIni(langName, langDesc string, source, moreSource []byte) error { + if _, ok := store.localeMap[langName]; ok { + return ErrLocaleAlreadyExist + } + + store.langNames = append(store.langNames, langName) + store.langDescs = append(store.langDescs, langDesc) + + l := &locale{store: store, langName: langName, idxToMsgMap: make(map[int]string)} + store.localeMap[l.langName] = l + + iniFile, err := setting.NewConfigProviderForLocale(source, moreSource) + if err != nil { + return fmt.Errorf("unable to load ini: %w", err) + } + + for _, section := range iniFile.Sections() { + for _, key := range section.Keys() { + var trKey string + // see https://codeberg.org/forgejo/discussions/issues/104 + // https://github.com/WeblateOrg/weblate/issues/10831 + // for an explanation of why "common" is an alternative + if section.Name() == "" || section.Name() == "DEFAULT" || section.Name() == "common" { + trKey = key.Name() + } else { + trKey = section.Name() + "." + key.Name() + } + idx, ok := store.trKeyToIdxMap[trKey] + if !ok { + idx = len(store.trKeyToIdxMap) + store.trKeyToIdxMap[trKey] = idx + } + l.idxToMsgMap[idx] = key.Value() + } + } + + return nil +} + +func (store *localeStore) HasLang(langName string) bool { + _, ok := store.localeMap[langName] + return ok +} + +func (store *localeStore) ListLangNameDesc() (names, desc []string) { + return store.langNames, store.langDescs +} + +// SetDefaultLang sets default language as a fallback +func (store *localeStore) SetDefaultLang(lang string) { + store.defaultLang = lang +} + +// Locale returns the locale for the lang or the default language +func (store *localeStore) Locale(lang string) (Locale, bool) { + l, found := store.localeMap[lang] + if !found { + var ok bool + l, ok = store.localeMap[store.defaultLang] + if !ok { + // no default - return an empty locale + l = &locale{store: store, idxToMsgMap: make(map[int]string)} + } + } + return l, found +} + +func (store *localeStore) Close() error { + return nil +} + +func (l *locale) TrString(trKey string, trArgs ...any) string { + format := trKey + + idx, ok := l.store.trKeyToIdxMap[trKey] + found := false + if ok { + if msg, ok := l.idxToMsgMap[idx]; ok { + format = msg // use the found translation + found = true + } else if def, ok := l.store.localeMap[l.store.defaultLang]; ok { + // try to use default locale's translation + if msg, ok := def.idxToMsgMap[idx]; ok { + format = msg + found = true + } + } + } + if !found { + log.Error("Missing translation %q", trKey) + } + + msg, err := Format(format, trArgs...) + if err != nil { + log.Error("Error whilst formatting %q in %s: %v", trKey, l.langName, err) + } + return msg +} + +func (l *locale) TrHTML(trKey string, trArgs ...any) template.HTML { + args := slices.Clone(trArgs) + for i, v := range args { + switch v := v.(type) { + case nil, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, template.HTML: + // for most basic types (including template.HTML which is safe), just do nothing and use it + case string: + args[i] = template.HTMLEscapeString(v) + case fmt.Stringer: + args[i] = template.HTMLEscapeString(v.String()) + default: + args[i] = template.HTMLEscapeString(fmt.Sprint(v)) + } + } + return template.HTML(l.TrString(trKey, args...)) +} + +// HasKey returns whether a key is present in this locale or not +func (l *locale) HasKey(trKey string) bool { + idx, ok := l.store.trKeyToIdxMap[trKey] + if !ok { + return false + } + _, ok = l.idxToMsgMap[idx] + return ok +} diff --git a/modules/translation/mock.go b/modules/translation/mock.go new file mode 100644 index 00000000..fe3a1502 --- /dev/null +++ b/modules/translation/mock.go @@ -0,0 +1,40 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package translation + +import ( + "fmt" + "html/template" +) + +// MockLocale provides a mocked locale without any translations +type MockLocale struct { + Lang, LangName string // these fields are used directly in templates: ctx.Locale.Lang +} + +var _ Locale = (*MockLocale)(nil) + +func (l MockLocale) Language() string { + return "en" +} + +func (l MockLocale) TrString(s string, _ ...any) string { + return s +} + +func (l MockLocale) Tr(s string, a ...any) template.HTML { + return template.HTML(s) +} + +func (l MockLocale) TrN(cnt any, key1, keyN string, args ...any) template.HTML { + return template.HTML(key1) +} + +func (l MockLocale) TrSize(s int64) ReadableSize { + return ReadableSize{fmt.Sprint(s), ""} +} + +func (l MockLocale) PrettyNumber(v any) string { + return fmt.Sprint(v) +} diff --git a/modules/translation/translation.go b/modules/translation/translation.go new file mode 100644 index 00000000..16eb55e2 --- /dev/null +++ b/modules/translation/translation.go @@ -0,0 +1,303 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package translation + +import ( + "context" + "html/template" + "sort" + "strings" + "sync" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/options" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/translation/i18n" + "code.gitea.io/gitea/modules/util" + + "github.com/dustin/go-humanize" + "golang.org/x/text/language" + "golang.org/x/text/message" + "golang.org/x/text/number" +) + +type contextKey struct{} + +var ContextKey any = &contextKey{} + +// Locale represents an interface to translation +type Locale interface { + Language() string + TrString(string, ...any) string + + Tr(key string, args ...any) template.HTML + TrN(cnt any, key1, keyN string, args ...any) template.HTML + + TrSize(size int64) ReadableSize + + PrettyNumber(v any) string +} + +// LangType represents a lang type +type LangType struct { + Lang, Name string // these fields are used directly in templates: {{range .AllLangs}}{{.Lang}}{{.Name}}{{end}} +} + +var ( + lock *sync.RWMutex + + allLangs []*LangType + allLangMap map[string]*LangType + + matcher language.Matcher + supportedTags []language.Tag +) + +// AllLangs returns all supported languages sorted by name +func AllLangs() []*LangType { + return allLangs +} + +// InitLocales loads the locales +func InitLocales(ctx context.Context) { + if lock != nil { + lock.Lock() + defer lock.Unlock() + } else if !setting.IsProd && lock == nil { + lock = &sync.RWMutex{} + } + + refreshLocales := func() { + i18n.ResetDefaultLocales() + localeNames, err := options.AssetFS().ListFiles("locale", true) + if err != nil { + log.Fatal("Failed to list locale files: %v", err) + } + + localeData := make(map[string][]byte, len(localeNames)) + for _, name := range localeNames { + localeData[name], err = options.Locale(name) + if err != nil { + log.Fatal("Failed to load %s locale file. %v", name, err) + } + } + + supportedTags = make([]language.Tag, len(setting.Langs)) + for i, lang := range setting.Langs { + supportedTags[i] = language.Raw.Make(lang) + } + + matcher = language.NewMatcher(supportedTags) + for i := range setting.Names { + var localeDataBase []byte + if i == 0 && setting.Langs[0] != "en-US" { + // Only en-US has complete translations. When use other language as default, the en-US should still be used as fallback. + localeDataBase = localeData["locale_en-US.ini"] + if localeDataBase == nil { + log.Fatal("Failed to load locale_en-US.ini file.") + } + } + + key := "locale_" + setting.Langs[i] + ".ini" + if err = i18n.DefaultLocales.AddLocaleByIni(setting.Langs[i], setting.Names[i], localeDataBase, localeData[key]); err != nil { + log.Error("Failed to set messages to %s: %v", setting.Langs[i], err) + } + } + if len(setting.Langs) != 0 { + defaultLangName := setting.Langs[0] + if defaultLangName != "en-US" { + log.Info("Use the first locale (%s) in LANGS setting option as default", defaultLangName) + } + i18n.DefaultLocales.SetDefaultLang(defaultLangName) + } + } + + refreshLocales() + + langs, descs := i18n.DefaultLocales.ListLangNameDesc() + allLangs = make([]*LangType, 0, len(langs)) + allLangMap = map[string]*LangType{} + for i, v := range langs { + l := &LangType{v, descs[i]} + allLangs = append(allLangs, l) + allLangMap[v] = l + } + + // Sort languages case-insensitive according to their name - needed for the user settings + sort.Slice(allLangs, func(i, j int) bool { + return strings.ToLower(allLangs[i].Name) < strings.ToLower(allLangs[j].Name) + }) + + if !setting.IsProd { + go options.AssetFS().WatchLocalChanges(ctx, func() { + lock.Lock() + defer lock.Unlock() + refreshLocales() + }) + } +} + +// Match matches accept languages +func Match(tags ...language.Tag) language.Tag { + _, i, _ := matcher.Match(tags...) + return supportedTags[i] +} + +// locale represents the information of localization. +type locale struct { + i18n.Locale + Lang, LangName string // these fields are used directly in templates: ctx.Locale.Lang + msgPrinter *message.Printer +} + +var _ Locale = (*locale)(nil) + +// NewLocale return a locale +func NewLocale(lang string) Locale { + if lock != nil { + lock.RLock() + defer lock.RUnlock() + } + + langName := "unknown" + if l, ok := allLangMap[lang]; ok { + langName = l.Name + } else if len(setting.Langs) > 0 { + lang = setting.Langs[0] + langName = setting.Names[0] + } + + i18nLocale, _ := i18n.GetLocale(lang) + l := &locale{ + Locale: i18nLocale, + Lang: lang, + LangName: langName, + } + if langTag, err := language.Parse(lang); err != nil { + log.Error("Failed to parse language tag from name %q: %v", l.Lang, err) + l.msgPrinter = message.NewPrinter(language.English) + } else { + l.msgPrinter = message.NewPrinter(langTag) + } + return l +} + +func (l *locale) Language() string { + return l.Lang +} + +// Language specific rules for translating plural texts +var trNLangRules = map[string]func(int64) int{ + // the default rule is "en-US" if a language isn't listed here + "en-US": func(cnt int64) int { + if cnt == 1 { + return 0 + } + return 1 + }, + "lv-LV": func(cnt int64) int { + if cnt%10 == 1 && cnt%100 != 11 { + return 0 + } + return 1 + }, + "ru-RU": func(cnt int64) int { + if cnt%10 == 1 && cnt%100 != 11 { + return 0 + } + return 1 + }, + "zh-CN": func(cnt int64) int { + return 0 + }, + "zh-HK": func(cnt int64) int { + return 0 + }, + "zh-TW": func(cnt int64) int { + return 0 + }, + "fr-FR": func(cnt int64) int { + if cnt > -2 && cnt < 2 { + return 0 + } + return 1 + }, +} + +func (l *locale) Tr(s string, args ...any) template.HTML { + return l.TrHTML(s, args...) +} + +// TrN returns translated message for plural text translation +func (l *locale) TrN(cnt any, key1, keyN string, args ...any) template.HTML { + var c int64 + if t, ok := cnt.(int); ok { + c = int64(t) + } else if t, ok := cnt.(int16); ok { + c = int64(t) + } else if t, ok := cnt.(int32); ok { + c = int64(t) + } else if t, ok := cnt.(int64); ok { + c = t + } else { + return l.Tr(keyN, args...) + } + + ruleFunc, ok := trNLangRules[l.Lang] + if !ok { + ruleFunc = trNLangRules["en-US"] + } + + if ruleFunc(c) == 0 { + return l.Tr(key1, args...) + } + return l.Tr(keyN, args...) +} + +type ReadableSize struct { + PrettyNumber string + TranslatedUnit string +} + +func (bs ReadableSize) String() string { + return bs.PrettyNumber + " " + bs.TranslatedUnit +} + +// TrSize returns array containing pretty formatted size and localized output of FileSize +// output of humanize.IBytes has to be split in order to be localized +func (l *locale) TrSize(s int64) ReadableSize { + us := uint64(s) + if s < 0 { + us = uint64(-s) + } + untranslated := humanize.IBytes(us) + if s < 0 { + untranslated = "-" + untranslated + } + numberVal, unitVal, found := strings.Cut(untranslated, " ") + if !found { + log.Error("no space in go-humanized size of %d: %q", s, untranslated) + } + numberVal = l.PrettyNumber(numberVal) + unitVal = l.TrString("munits.data." + strings.ToLower(unitVal)) + return ReadableSize{numberVal, unitVal} +} + +func (l *locale) PrettyNumber(v any) string { + // TODO: this mechanism is not good enough, the complete solution is to switch the translation system to ICU message format + if s, ok := v.(string); ok { + if num, err := util.ToInt64(s); err == nil { + v = num + } else if num, err := util.ToFloat64(s); err == nil { + v = num + } + } + return l.msgPrinter.Sprintf("%v", number.Decimal(v)) +} + +func init() { + // prepare a default matcher, especially for tests + supportedTags = []language.Tag{language.English} + matcher = language.NewMatcher(supportedTags) +} diff --git a/modules/translation/translation_test.go b/modules/translation/translation_test.go new file mode 100644 index 00000000..bffbb155 --- /dev/null +++ b/modules/translation/translation_test.go @@ -0,0 +1,50 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package translation + +// TODO: make this package friendly to testing + +import ( + "testing" + + "code.gitea.io/gitea/modules/translation/i18n" + + "github.com/stretchr/testify/assert" +) + +func TestTrSize(t *testing.T) { + l := NewLocale("") + size := int64(1) + assert.EqualValues(t, "1 munits.data.b", l.TrSize(size).String()) + size *= 2048 + assert.EqualValues(t, "2 munits.data.kib", l.TrSize(size).String()) + size *= 2048 + assert.EqualValues(t, "4 munits.data.mib", l.TrSize(size).String()) + size *= 2048 + assert.EqualValues(t, "8 munits.data.gib", l.TrSize(size).String()) + size *= 2048 + assert.EqualValues(t, "16 munits.data.tib", l.TrSize(size).String()) + size *= 2048 + assert.EqualValues(t, "32 munits.data.pib", l.TrSize(size).String()) + size *= 128 + assert.EqualValues(t, "4 munits.data.eib", l.TrSize(size).String()) +} + +func TestPrettyNumber(t *testing.T) { + i18n.ResetDefaultLocales() + + allLangMap = make(map[string]*LangType) + allLangMap["id-ID"] = &LangType{Lang: "id-ID", Name: "Bahasa Indonesia"} + + l := NewLocale("id-ID") + assert.EqualValues(t, "1.000.000", l.PrettyNumber(1000000)) + assert.EqualValues(t, "1.000.000,1", l.PrettyNumber(1000000.1)) + assert.EqualValues(t, "1.000.000", l.PrettyNumber("1000000")) + assert.EqualValues(t, "1.000.000", l.PrettyNumber("1000000.0")) + assert.EqualValues(t, "1.000.000,1", l.PrettyNumber("1000000.1")) + + l = NewLocale("nosuch") + assert.EqualValues(t, "1,000,000", l.PrettyNumber(1000000)) + assert.EqualValues(t, "1,000,000.1", l.PrettyNumber(1000000.1)) +} |