1
0
Fork 0
mirror of https://codeberg.org/forgejo/forgejo.git synced 2025-01-20 16:50:28 -05:00

Initial support for localization and pluralization with go-i18n-JSON-v2 format

This commit is contained in:
Benedikt Straub 2024-12-30 20:03:37 +01:00
parent 376a2e19ea
commit a2787bb09e
No known key found for this signature in database
GPG key ID: E5C73DDA51986AEC
61 changed files with 1317 additions and 51 deletions

View file

@ -246,6 +246,7 @@ code.gitea.io/gitea/modules/translation
MockLocale.TrString MockLocale.TrString
MockLocale.Tr MockLocale.Tr
MockLocale.TrN MockLocale.TrN
MockLocale.TrPluralString
MockLocale.TrSize MockLocale.TrSize
MockLocale.PrettyNumber MockLocale.PrettyNumber

View file

@ -22,20 +22,7 @@ func (k *KeyLocale) HasKey(trKey string) bool {
// TrHTML implements Locale. // TrHTML implements Locale.
func (k *KeyLocale) TrHTML(trKey string, trArgs ...any) template.HTML { func (k *KeyLocale) TrHTML(trKey string, trArgs ...any) template.HTML {
args := slices.Clone(trArgs) return template.HTML(k.TrString(trKey, PrepareArgsForHTML(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(k.TrString(trKey, args...))
} }
// TrString implements Locale. // TrString implements Locale.
@ -43,6 +30,11 @@ func (k *KeyLocale) TrString(trKey string, trArgs ...any) string {
return FormatDummy(trKey, trArgs...) return FormatDummy(trKey, trArgs...)
} }
// TrPluralString implements Locale.
func (k *KeyLocale) TrPluralString(count any, trKey string, trArgs ...any) template.HTML {
return template.HTML(FormatDummy(trKey, PrepareArgsForHTML(trArgs...)...))
}
func FormatDummy(trKey string, args ...any) string { func FormatDummy(trKey string, args ...any) string {
if len(args) == 0 { if len(args) == 0 {
return fmt.Sprintf("(%s)", trKey) return fmt.Sprintf("(%s)", trKey)

View file

@ -9,5 +9,7 @@ import (
var ( var (
ErrLocaleAlreadyExist = util.SilentWrap{Message: "lang already exists", Err: util.ErrAlreadyExist} ErrLocaleAlreadyExist = util.SilentWrap{Message: "lang already exists", Err: util.ErrAlreadyExist}
ErrLocaleDoesNotExist = util.SilentWrap{Message: "lang does not exist", Err: util.ErrNotExist}
ErrTranslationDoesNotExist = util.SilentWrap{Message: "translation does not exist", Err: util.ErrNotExist}
ErrUncertainArguments = util.SilentWrap{Message: "arguments to i18n should not contain uncertain slices", Err: util.ErrInvalidArgument} ErrUncertainArguments = util.SilentWrap{Message: "arguments to i18n should not contain uncertain slices", Err: util.ErrInvalidArgument}
) )

View file

@ -8,11 +8,28 @@ import (
"io" "io"
) )
type (
PluralFormIndex uint8
PluralFormRule func(int64) PluralFormIndex
)
const (
PluralFormZero PluralFormIndex = iota
PluralFormOne
PluralFormTwo
PluralFormFew
PluralFormMany
PluralFormOther
)
var DefaultLocales = NewLocaleStore() var DefaultLocales = NewLocaleStore()
type Locale interface { type Locale interface {
// TrString translates a given key and arguments for a language // TrString translates a given key and arguments for a language
TrString(trKey string, trArgs ...any) string TrString(trKey string, trArgs ...any) string
// TrPluralString translates a given pluralized key and arguments for a language.
// This function returns an error if new-style support for the given key is not available.
TrPluralString(count any, trKey string, trArgs ...any) template.HTML
// TrHTML translates a given key and arguments for a language, string arguments are escaped to HTML // TrHTML translates a given key and arguments for a language, string arguments are escaped to HTML
TrHTML(trKey string, trArgs ...any) template.HTML TrHTML(trKey string, trArgs ...any) template.HTML
// HasKey reports if a locale has a translation for a given key // HasKey reports if a locale has a translation for a given key
@ -31,8 +48,10 @@ type LocaleStore interface {
Locale(langName string) (Locale, bool) Locale(langName string) (Locale, bool)
// HasLang returns whether a given language is present in the store // HasLang returns whether a given language is present in the store
HasLang(langName string) bool HasLang(langName string) bool
// AddLocaleByIni adds a new language to the store // AddLocaleByIni adds a new old-style language to the store
AddLocaleByIni(langName, langDesc string, source, moreSource []byte) error AddLocaleByIni(langName, langDesc string, pluralRule PluralFormRule, source, moreSource []byte) error
// AddLocaleByJSON adds new-style content to an existing language to the store
AddToLocaleFromJSON(langName string, source []byte) error
} }
// ResetDefaultLocales resets the current default locales // ResetDefaultLocales resets the current default locales

View file

@ -12,6 +12,26 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
var MockPluralRule PluralFormRule = func(n int64) PluralFormIndex {
if n == 0 {
return PluralFormZero
}
if n == 1 {
return PluralFormOne
}
if n >= 2 && n <= 4 {
return PluralFormFew
}
return PluralFormOther
}
var MockPluralRuleEnglish PluralFormRule = func(n int64) PluralFormIndex {
if n == 1 {
return PluralFormOne
}
return PluralFormOther
}
func TestLocaleStore(t *testing.T) { func TestLocaleStore(t *testing.T) {
testData1 := []byte(` testData1 := []byte(`
.dot.name = Dot Name .dot.name = Dot Name
@ -27,11 +47,48 @@ fmt = %[2]s %[1]s
[section] [section]
sub = Changed Sub String sub = Changed Sub String
commits = fallback value for commits
`)
testDataJSON2 := []byte(`
{
"section.json": "the JSON is %s",
"section.commits": {
"one": "one %d commit",
"few": "some %d commits",
"other": "lots of %d commits"
},
"section.incomplete": {
"few": "some %d objects (translated)"
},
"nested": {
"outer": {
"inner": {
"json": "Hello World",
"issue": {
"one": "one %d issue",
"few": "some %d issues",
"other": "lots of %d issues"
}
}
}
}
}
`)
testDataJSON1 := []byte(`
{
"section.incomplete": {
"one": "[untranslated] some %d object",
"other": "[untranslated] some %d objects"
}
}
`) `)
ls := NewLocaleStore() ls := NewLocaleStore()
require.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", testData1, nil)) require.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", MockPluralRuleEnglish, testData1, nil))
require.NoError(t, ls.AddLocaleByIni("lang2", "Lang2", testData2, nil)) require.NoError(t, ls.AddLocaleByIni("lang2", "Lang2", MockPluralRule, testData2, nil))
require.NoError(t, ls.AddToLocaleFromJSON("lang1", testDataJSON1))
require.NoError(t, ls.AddToLocaleFromJSON("lang2", testDataJSON2))
ls.SetDefaultLang("lang1") ls.SetDefaultLang("lang1")
lang1, _ := ls.Locale("lang1") lang1, _ := ls.Locale("lang1")
@ -56,6 +113,45 @@ sub = Changed Sub String
result2 := lang2.TrHTML("section.mixed", "a&b") result2 := lang2.TrHTML("section.mixed", "a&b")
assert.EqualValues(t, `test value; <span style="color: red; background: none;">a&amp;b</span>`, result2) assert.EqualValues(t, `test value; <span style="color: red; background: none;">a&amp;b</span>`, result2)
result = lang2.TrString("section.json", "valid")
assert.Equal(t, "the JSON is valid", result)
result = lang2.TrString("nested.outer.inner.json")
assert.Equal(t, "Hello World", result)
result = lang2.TrString("section.commits")
assert.Equal(t, "lots of %d commits", result)
result2 = lang2.TrPluralString(1, "section.commits", 1)
assert.EqualValues(t, "one 1 commit", result2)
result2 = lang2.TrPluralString(3, "section.commits", 3)
assert.EqualValues(t, "some 3 commits", result2)
result2 = lang2.TrPluralString(8, "section.commits", 8)
assert.EqualValues(t, "lots of 8 commits", result2)
result2 = lang2.TrPluralString(0, "section.commits")
assert.EqualValues(t, "section.commits", result2)
result2 = lang2.TrPluralString(1, "nested.outer.inner.issue", 1)
assert.EqualValues(t, "one 1 issue", result2)
result2 = lang2.TrPluralString(3, "nested.outer.inner.issue", 3)
assert.EqualValues(t, "some 3 issues", result2)
result2 = lang2.TrPluralString(9, "nested.outer.inner.issue", 9)
assert.EqualValues(t, "lots of 9 issues", result2)
result2 = lang2.TrPluralString(3, "section.incomplete", 3)
assert.EqualValues(t, "some 3 objects (translated)", result2)
result2 = lang2.TrPluralString(1, "section.incomplete", 1)
assert.EqualValues(t, "[untranslated] some 1 object", result2)
result2 = lang2.TrPluralString(7, "section.incomplete", 7)
assert.EqualValues(t, "[untranslated] some 7 objects", result2)
langs, descs := ls.ListLangNameDesc() langs, descs := ls.ListLangNameDesc()
assert.ElementsMatch(t, []string{"lang1", "lang2"}, langs) assert.ElementsMatch(t, []string{"lang1", "lang2"}, langs)
assert.ElementsMatch(t, []string{"Lang1", "Lang2"}, descs) assert.ElementsMatch(t, []string{"Lang1", "Lang2"}, descs)
@ -77,7 +173,7 @@ c=22
`) `)
ls := NewLocaleStore() ls := NewLocaleStore()
require.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", testData1, testData2)) require.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", MockPluralRule, testData1, testData2))
lang1, _ := ls.Locale("lang1") lang1, _ := ls.Locale("lang1")
assert.Equal(t, "11", lang1.TrString("a")) assert.Equal(t, "11", lang1.TrString("a"))
assert.Equal(t, "21", lang1.TrString("b")) assert.Equal(t, "21", lang1.TrString("b"))
@ -118,7 +214,7 @@ func (e *errorPointerReceiver) Error() string {
func TestLocaleWithTemplate(t *testing.T) { func TestLocaleWithTemplate(t *testing.T) {
ls := NewLocaleStore() ls := NewLocaleStore()
require.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", []byte(`key=<a>%s</a>`), nil)) require.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", MockPluralRule, []byte(`key=<a>%s</a>`), nil))
lang1, _ := ls.Locale("lang1") lang1, _ := ls.Locale("lang1")
tmpl := template.New("test").Funcs(template.FuncMap{"tr": lang1.TrHTML}) tmpl := template.New("test").Funcs(template.FuncMap{"tr": lang1.TrHTML})
@ -181,7 +277,7 @@ func TestLocaleStoreQuirks(t *testing.T) {
for _, testData := range testDataList { for _, testData := range testDataList {
ls := NewLocaleStore() ls := NewLocaleStore()
err := ls.AddLocaleByIni("lang1", "Lang1", []byte("a="+testData.in), nil) err := ls.AddLocaleByIni("lang1", "Lang1", nil, []byte("a="+testData.in), nil)
lang1, _ := ls.Locale("lang1") lang1, _ := ls.Locale("lang1")
require.NoError(t, err, testData.hint) require.NoError(t, err, testData.hint)
assert.Equal(t, testData.out, lang1.TrString("a"), testData.hint) assert.Equal(t, testData.out, lang1.TrString("a"), testData.hint)

View file

@ -8,8 +8,10 @@ import (
"html/template" "html/template"
"slices" "slices"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
) )
// This file implements the static LocaleStore that will not watch for changes // This file implements the static LocaleStore that will not watch for changes
@ -18,6 +20,9 @@ type locale struct {
store *localeStore store *localeStore
langName string langName string
idxToMsgMap map[int]string // the map idx is generated by store's trKeyToIdxMap idxToMsgMap map[int]string // the map idx is generated by store's trKeyToIdxMap
newStyleMessages map[string]string
pluralRule PluralFormRule
} }
var _ Locale = (*locale)(nil) var _ Locale = (*locale)(nil)
@ -38,8 +43,19 @@ func NewLocaleStore() LocaleStore {
return &localeStore{localeMap: make(map[string]*locale), trKeyToIdxMap: make(map[string]int)} return &localeStore{localeMap: make(map[string]*locale), trKeyToIdxMap: make(map[string]int)}
} }
const (
PluralFormSeparator string = "\036"
)
// A note about pluralization rules.
// go-i18n supports plural rules in theory.
// In practice, it relies on another library that hardcodes a list of common languages
// and their plural rules, and does not support languages not hardcoded there.
// So we pretend that all languages are English and use our own function to extract
// the correct plural form for a given count and language.
// AddLocaleByIni adds locale by ini into the store // AddLocaleByIni adds locale by ini into the store
func (store *localeStore) AddLocaleByIni(langName, langDesc string, source, moreSource []byte) error { func (store *localeStore) AddLocaleByIni(langName, langDesc string, pluralRule PluralFormRule, source, moreSource []byte) error {
if _, ok := store.localeMap[langName]; ok { if _, ok := store.localeMap[langName]; ok {
return ErrLocaleAlreadyExist return ErrLocaleAlreadyExist
} }
@ -47,7 +63,7 @@ func (store *localeStore) AddLocaleByIni(langName, langDesc string, source, more
store.langNames = append(store.langNames, langName) store.langNames = append(store.langNames, langName)
store.langDescs = append(store.langDescs, langDesc) store.langDescs = append(store.langDescs, langDesc)
l := &locale{store: store, langName: langName, idxToMsgMap: make(map[int]string)} l := &locale{store: store, langName: langName, idxToMsgMap: make(map[int]string), pluralRule: pluralRule, newStyleMessages: make(map[string]string)}
store.localeMap[l.langName] = l store.localeMap[l.langName] = l
iniFile, err := setting.NewConfigProviderForLocale(source, moreSource) iniFile, err := setting.NewConfigProviderForLocale(source, moreSource)
@ -78,6 +94,98 @@ func (store *localeStore) AddLocaleByIni(langName, langDesc string, source, more
return nil return nil
} }
func RecursivelyAddTranslationsFromJSON(locale *locale, object map[string]any, prefix string) error {
for key, value := range object {
var fullkey string
if prefix != "" {
fullkey = prefix + "." + key
} else {
fullkey = key
}
switch v := value.(type) {
case string:
// Check whether we are adding a plural form to the parent object, or a new nested JSON object.
if key == "zero" || key == "one" || key == "two" || key == "few" || key == "many" {
locale.newStyleMessages[prefix+PluralFormSeparator+key] = v
} else if key == "other" {
locale.newStyleMessages[prefix] = v
} else {
locale.newStyleMessages[fullkey] = v
}
case map[string]any:
err := RecursivelyAddTranslationsFromJSON(locale, v, fullkey)
if err != nil {
return err
}
case nil:
default:
return fmt.Errorf("Unrecognized JSON value '%s'", value)
}
}
return nil
}
func (store *localeStore) AddToLocaleFromJSON(langName string, source []byte) error {
locale, ok := store.localeMap[langName]
if !ok {
return ErrLocaleDoesNotExist
}
var result map[string]any
if err := json.Unmarshal(source, &result); err != nil {
return err
}
return RecursivelyAddTranslationsFromJSON(locale, result, "")
}
func (l *locale) LookupNewStyleMessage(trKey string) string {
if msg, ok := l.newStyleMessages[trKey]; ok {
return msg
}
return ""
}
func (l *locale) LookupPlural(trKey string, count any) string {
n, err := util.ToInt64(count)
if err != nil {
log.Error("Invalid plural count '%s'", count)
return ""
}
pluralForm := l.pluralRule(n)
suffix := ""
switch pluralForm {
case PluralFormZero:
suffix = PluralFormSeparator + "zero"
case PluralFormOne:
suffix = PluralFormSeparator + "one"
case PluralFormTwo:
suffix = PluralFormSeparator + "two"
case PluralFormFew:
suffix = PluralFormSeparator + "few"
case PluralFormMany:
suffix = PluralFormSeparator + "many"
case PluralFormOther:
// No suffix for the "other" string.
default:
log.Error("Invalid plural form index %d for count %d", pluralForm, count)
return ""
}
if result, ok := l.newStyleMessages[trKey+suffix]; ok {
return result
}
log.Error("Missing translation for plural form index %d for count %d", pluralForm, count)
return ""
}
func (store *localeStore) HasLang(langName string) bool { func (store *localeStore) HasLang(langName string) bool {
_, ok := store.localeMap[langName] _, ok := store.localeMap[langName]
return ok return ok
@ -113,23 +221,38 @@ func (store *localeStore) Close() error {
func (l *locale) TrString(trKey string, trArgs ...any) string { func (l *locale) TrString(trKey string, trArgs ...any) string {
format := trKey format := trKey
if msg := l.LookupNewStyleMessage(trKey); msg != "" {
format = msg
} else {
// First fallback: old-style translation
idx, ok := l.store.trKeyToIdxMap[trKey] idx, ok := l.store.trKeyToIdxMap[trKey]
found := false found := false
if ok { if ok {
if msg, ok := l.idxToMsgMap[idx]; ok { if msg, ok := l.idxToMsgMap[idx]; ok {
format = msg // use the found translation format = msg // use the found translation
found = true 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 {
if !found {
// Second fallback: new-style default language
if defaultLang, ok := l.store.localeMap[l.store.defaultLang]; ok {
if msg := defaultLang.LookupNewStyleMessage(trKey); msg != "" {
format = msg
} else {
// Third fallback: old-style default language
if msg, ok := defaultLang.idxToMsgMap[idx]; ok {
format = msg format = msg
found = true found = true
} }
} }
} }
if !found { if !found {
log.Error("Missing translation %q", trKey) log.Error("Missing translation %q", trKey)
} }
}
}
msg, err := Format(format, trArgs...) msg, err := Format(format, trArgs...)
if err != nil { if err != nil {
@ -138,7 +261,7 @@ func (l *locale) TrString(trKey string, trArgs ...any) string {
return msg return msg
} }
func (l *locale) TrHTML(trKey string, trArgs ...any) template.HTML { func PrepareArgsForHTML(trArgs ...any) []any {
args := slices.Clone(trArgs) args := slices.Clone(trArgs)
for i, v := range args { for i, v := range args {
switch v := v.(type) { switch v := v.(type) {
@ -152,7 +275,30 @@ func (l *locale) TrHTML(trKey string, trArgs ...any) template.HTML {
args[i] = template.HTMLEscapeString(fmt.Sprint(v)) args[i] = template.HTMLEscapeString(fmt.Sprint(v))
} }
} }
return template.HTML(l.TrString(trKey, args...)) return args
}
func (l *locale) TrHTML(trKey string, trArgs ...any) template.HTML {
return template.HTML(l.TrString(trKey, PrepareArgsForHTML(trArgs...)...))
}
func (l *locale) TrPluralString(count any, trKey string, trArgs ...any) template.HTML {
message := l.LookupPlural(trKey, count)
if message == "" {
if defaultLang, ok := l.store.localeMap[l.store.defaultLang]; ok {
message = defaultLang.LookupPlural(trKey, count)
}
if message == "" {
message = trKey
}
}
message, err := Format(message, PrepareArgsForHTML(trArgs...)...)
if err != nil {
log.Error("Error whilst formatting %q in %s: %v", trKey, l.langName, err)
}
return template.HTML(message)
} }
// HasKey returns whether a key is present in this locale or not // HasKey returns whether a key is present in this locale or not

View file

@ -31,6 +31,10 @@ func (l MockLocale) TrN(cnt any, key1, keyN string, args ...any) template.HTML {
return template.HTML(key1) return template.HTML(key1)
} }
func (l MockLocale) TrPluralString(count any, trKey string, trArgs ...any) template.HTML {
return template.HTML(trKey)
}
func (l MockLocale) TrSize(s int64) ReadableSize { func (l MockLocale) TrSize(s int64) ReadableSize {
return ReadableSize{fmt.Sprint(s), ""} return ReadableSize{fmt.Sprint(s), ""}
} }

View file

@ -0,0 +1,253 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Some useful links:
// https://www.unicode.org/cldr/charts/46/supplemental/language_plural_rules.html
// https://translate.codeberg.org/languages/$LANGUAGE_CODE/#information
// https://github.com/WeblateOrg/language-data/blob/main/languages.csv
// Note that in some cases there is ambiguity about the correct form for a given language. In this case, ask the locale's translators.
package translation
import (
"strings"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/translation/i18n"
)
// The constants refer to indices below in `PluralRules` and also in i18n.js, keep them in sync!
const (
PluralRuleDefault = 0
PluralRuleBengali = 1
PluralRuleIcelandic = 2
PluralRuleFilipino = 3
PluralRuleOneForm = 4
PluralRuleCzech = 5
PluralRuleRussian = 6
PluralRulePolish = 7
PluralRuleLatvian = 8
PluralRuleLithuanian = 9
PluralRuleFrench = 10
PluralRuleCatalan = 11
PluralRuleSlovenian = 12
PluralRuleArabic = 13
)
func GetPluralRuleImpl(langName string) int {
// First, check for languages with country-specific plural rules.
switch langName {
case "pt-BR":
return PluralRuleFrench
case "pt-PT":
return PluralRuleCatalan
default:
break
}
// Remove the country portion of the locale name.
langName = strings.Split(strings.Split(langName, "_")[0], "-")[0]
// When adding a new language not in the list, add its plural rule definition here.
switch langName {
case "en", "aa", "ab", "abr", "ada", "ae", "aeb", "af", "afh", "aii", "ain", "akk", "ale", "aln", "alt", "ami", "an", "ang", "anp", "apc", "arc", "arp", "arq", "arw", "arz", "asa", "ast", "av", "avk", "awa", "ayc", "az", "azb", "ba", "bal", "ban", "bar", "bas", "bbc", "bci", "bej", "bem", "ber", "bew", "bez", "bg", "bgc", "bgn", "bhb", "bhi", "bi", "bik", "bin", "bjj", "bjn", "bla", "bnt", "bqi", "bra", "brb", "brh", "brx", "bua", "bug", "bum", "byn", "cad", "cak", "car", "ce", "cgg", "ch", "chb", "chg", "chk", "chm", "chn", "cho", "chp", "chr", "chy", "ckb", "co", "cop", "cpe", "cpf", "cr", "crp", "cu", "cv", "da", "dak", "dar", "dcc", "de", "del", "den", "dgr", "din", "dje", "dnj", "dnk", "dru", "dry", "dua", "dum", "dv", "dyu", "ee", "efi", "egl", "egy", "eka", "el", "elx", "enm", "eo", "et", "eu", "ewo", "ext", "fan", "fat", "fbl", "ffm", "fi", "fj", "fo", "fon", "frk", "frm", "fro", "frr", "frs", "fuq", "fur", "fuv", "fvr", "fy", "gaa", "gay", "gba", "gbm", "gez", "gil", "gl", "glk", "gmh", "gn", "goh", "gom", "gon", "gor", "got", "grb", "gsw", "guc", "gum", "gur", "guz", "gwi", "ha", "hai", "haw", "haz", "hil", "hit", "hmn", "hnd", "hne", "hno", "ho", "hoc", "hoj", "hrx", "ht", "hu", "hup", "hus", "hz", "ia", "iba", "ibb", "ie", "ik", "ilo", "inh", "io", "jam", "jgo", "jmc", "jpr", "jrb", "ka", "kaa", "kac", "kaj", "kam", "kaw", "kbd", "kcg", "kfr", "kfy", "kg", "kha", "khn", "kho", "ki", "kj", "kk", "kkj", "kl", "kln", "kmb", "kmr", "kok", "kpe", "kr", "krc", "kri", "krl", "kru", "ks", "ksb", "ku", "kum", "kut", "kv", "kxm", "ky", "la", "lad", "laj", "lam", "lb", "lez", "lfn", "lg", "li", "lij", "ljp", "lki", "lmn", "lmo", "lol", "loz", "lrc", "lu", "lua", "lui", "lun", "luo", "lus", "luy", "luz", "mad", "mag", "mai", "mak", "man", "mas", "mdf", "mdh", "mdr", "men", "mer", "mfa", "mga", "mgh", "mgo", "mh", "mhr", "mic", "min", "mjw", "ml", "mn", "mnc", "mni", "mnw", "moe", "moh", "mos", "mr", "mrh", "mtr", "mus", "mwk", "mwl", "mwr", "mxc", "myv", "myx", "mzn", "na", "nah", "nap", "nb", "nd", "ndc", "nds", "ne", "new", "ng", "ngl", "nia", "nij", "niu", "nl", "nn", "nnh", "nod", "noe", "nog", "non", "nr", "nuk", "nv", "nwc", "ny", "nym", "nyn", "nyo", "nzi", "oj", "om", "or", "os", "ota", "otk", "ovd", "pag", "pal", "pam", "pap", "pau", "pbb", "pdt", "peo", "phn", "pi", "pms", "pon", "pro", "ps", "pwn", "qu", "quc", "qug", "qya", "raj", "rap", "rar", "rcf", "rej", "rhg", "rif", "rkt", "rm", "rmt", "rn", "rng", "rof", "rom", "rue", "rup", "rw", "rwk", "sad", "sai", "sam", "saq", "sas", "sc", "sck", "sco", "sd", "sdh", "sef", "seh", "sel", "sga", "sgn", "sgs", "shn", "sid", "sjd", "skr", "sm", "sml", "sn", "snk", "so", "sog", "sou", "sq", "srn", "srr", "ss", "ssy", "st", "suk", "sus", "sux", "sv", "sw", "swg", "swv", "sxu", "syc", "syl", "syr", "szy", "ta", "tay", "tcy", "te", "tem", "teo", "ter", "tet", "tig", "tiv", "tk", "tkl", "tli", "tly", "tmh", "tn", "tog", "tr", "trv", "ts", "tsg", "tsi", "tsj", "tts", "tum", "tvl", "tw", "ty", "tyv", "tzj", "tzl", "udm", "ug", "uga", "umb", "und", "unr", "ur", "uz", "vai", "ve", "vls", "vmf", "vmw", "vo", "vot", "vro", "vun", "wae", "wal", "war", "was", "wbq", "wbr", "wep", "wtm", "xal", "xh", "xnr", "xog", "yao", "yap", "yi", "yua", "za", "zap", "zbl", "zen", "zgh", "zun", "zza":
return PluralRuleDefault
case "ach", "ady", "ak", "am", "arn", "as", "bh", "bho", "bn", "csw", "doi", "fa", "ff", "frc", "frp", "gu", "gug", "gun", "guw", "hi", "hy", "kab", "kn", "ln", "mfe", "mg", "mi", "mia", "nso", "oc", "pa", "pcm", "pt", "qdt", "qtp", "si", "tg", "ti", "wa", "zu":
return PluralRuleBengali
case "is":
return PluralRuleIcelandic
case "fil":
return PluralRuleFilipino
case "ace", "ay", "bm", "bo", "cdo", "cpx", "crh", "dz", "gan", "hak", "hnj", "hsn", "id", "ig", "ii", "ja", "jbo", "jv", "kde", "kea", "km", "ko", "kos", "lkt", "lo", "lzh", "ms", "my", "nan", "nqo", "osa", "sah", "ses", "sg", "son", "su", "th", "tlh", "to", "tok", "tpi", "tt", "vi", "wo", "wuu", "yo", "yue", "zh":
return PluralRuleOneForm
case "cpp", "cs", "sk":
return PluralRuleCzech
case "be", "bs", "cnr", "hr", "ru", "sr", "uk", "wen":
return PluralRuleRussian
case "csb", "pl", "szl":
return PluralRulePolish
case "lv", "prg":
return PluralRuleLatvian
case "lt":
return PluralRuleLithuanian
case "fr":
return PluralRuleFrench
case "ca", "es", "it":
return PluralRuleCatalan
case "sl":
return PluralRuleSlovenian
case "ar":
return PluralRuleArabic
default:
break
}
log.Error("No plural rule defined for language %s", langName)
return PluralRuleDefault
}
var PluralRules = []i18n.PluralFormRule{
// [ 0] Common 2-form, e.g. English, German
func(n int64) i18n.PluralFormIndex {
if n != 1 {
return i18n.PluralFormOther
}
return i18n.PluralFormOne
},
// [ 1] Bengali
func(n int64) i18n.PluralFormIndex {
if n > 1 {
return i18n.PluralFormOther
}
return i18n.PluralFormOne
},
// [ 2] Icelandic
func(n int64) i18n.PluralFormIndex {
if n%10 != 1 || n%100 == 11 {
return i18n.PluralFormOther
}
return i18n.PluralFormOne
},
// [ 3] Filipino
func(n int64) i18n.PluralFormIndex {
if n != 1 && n != 2 && n != 3 && (n%10 == 4 || n%10 == 6 || n%10 == 9) {
return i18n.PluralFormOther
}
return i18n.PluralFormOne
},
// [ 4] OneForm
func(n int64) i18n.PluralFormIndex {
return i18n.PluralFormOther
},
// [ 5] Czech
func(n int64) i18n.PluralFormIndex {
if n == 1 {
return i18n.PluralFormOne
}
if n >= 2 && n <= 4 {
return i18n.PluralFormFew
}
return i18n.PluralFormOther
},
// [ 6] Russian
func(n int64) i18n.PluralFormIndex {
if n%10 == 1 && n%100 != 11 {
return i18n.PluralFormOne
}
if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
return i18n.PluralFormFew
}
return i18n.PluralFormMany
},
// [ 7] Polish
func(n int64) i18n.PluralFormIndex {
if n == 1 {
return i18n.PluralFormOne
}
if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
return i18n.PluralFormFew
}
return i18n.PluralFormMany
},
// [ 8] Latvian
func(n int64) i18n.PluralFormIndex {
if n%10 == 0 || n%100 >= 11 && n%100 <= 19 {
return i18n.PluralFormZero
}
if n%10 == 1 && n%100 != 11 {
return i18n.PluralFormOne
}
return i18n.PluralFormOther
},
// [ 9] Lithuanian
func(n int64) i18n.PluralFormIndex {
if n%10 == 1 && (n%100 < 11 || n%100 > 19) {
return i18n.PluralFormOne
}
if n%10 >= 2 && n%10 <= 9 && (n%100 < 11 || n%100 > 19) {
return i18n.PluralFormFew
}
return i18n.PluralFormMany
},
// [10] French
func(n int64) i18n.PluralFormIndex {
if n == 0 || n == 1 {
return i18n.PluralFormOne
}
if n != 0 && n%1000000 == 0 {
return i18n.PluralFormMany
}
return i18n.PluralFormOther
},
// [11] Catalan
func(n int64) i18n.PluralFormIndex {
if n == 1 {
return i18n.PluralFormOne
}
if n != 0 && n%1000000 == 0 {
return i18n.PluralFormMany
}
return i18n.PluralFormOther
},
// [12] Slovenian
func(n int64) i18n.PluralFormIndex {
if n%100 == 1 {
return i18n.PluralFormOne
}
if n%100 == 2 {
return i18n.PluralFormTwo
}
if n%100 == 3 || n%100 == 4 {
return i18n.PluralFormFew
}
return i18n.PluralFormOther
},
// [13] Arabic
func(n int64) i18n.PluralFormIndex {
if n == 0 {
return i18n.PluralFormZero
}
if n == 1 {
return i18n.PluralFormOne
}
if n == 2 {
return i18n.PluralFormTwo
}
if n%100 >= 3 && n%100 <= 10 {
return i18n.PluralFormFew
}
if n%100 >= 11 {
return i18n.PluralFormMany
}
return i18n.PluralFormOther
},
}

View file

@ -32,6 +32,9 @@ type Locale interface {
TrString(string, ...any) string TrString(string, ...any) string
Tr(key string, args ...any) template.HTML Tr(key string, args ...any) template.HTML
// New-style pluralized strings
TrPluralString(count any, trKey string, trArgs ...any) template.HTML
// Old-style pseudo-pluralized strings, deprecated
TrN(cnt any, key1, keyN string, args ...any) template.HTML TrN(cnt any, key1, keyN string, args ...any) template.HTML
TrSize(size int64) ReadableSize TrSize(size int64) ReadableSize
@ -100,8 +103,17 @@ func InitLocales(ctx context.Context) {
} }
key := "locale_" + setting.Langs[i] + ".ini" key := "locale_" + setting.Langs[i] + ".ini"
if err = i18n.DefaultLocales.AddLocaleByIni(setting.Langs[i], setting.Names[i], localeDataBase, localeData[key]); err != nil { if err = i18n.DefaultLocales.AddLocaleByIni(setting.Langs[i], setting.Names[i], PluralRules[GetPluralRuleImpl(setting.Langs[i])], localeDataBase, localeData[key]); err != nil {
log.Error("Failed to set messages to %s: %v", setting.Langs[i], err) log.Error("Failed to set old-style messages to %s: %v", setting.Langs[i], err)
}
key = "locale_next/locale_" + setting.Langs[i] + ".json"
if bytes, err := options.AssetFS().ReadFile(key); err == nil {
if err = i18n.DefaultLocales.AddToLocaleFromJSON(setting.Langs[i], bytes); err != nil {
log.Error("Failed to add new-style messages to %s: %v", setting.Langs[i], err)
}
} else {
log.Error("Failed to open new-style messages for %s: %v", setting.Langs[i], err)
} }
} }
if len(setting.Langs) != 0 { if len(setting.Langs) != 0 {

View file

@ -48,3 +48,111 @@ func TestPrettyNumber(t *testing.T) {
assert.EqualValues(t, "1,000,000", l.PrettyNumber(1000000)) 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.1", l.PrettyNumber(1000000.1))
} }
func TestGetPluralRule(t *testing.T) {
assert.Equal(t, PluralRuleDefault, GetPluralRuleImpl("en"))
assert.Equal(t, PluralRuleDefault, GetPluralRuleImpl("en-US"))
assert.Equal(t, PluralRuleDefault, GetPluralRuleImpl("en_UK"))
assert.Equal(t, PluralRuleDefault, GetPluralRuleImpl("nds"))
assert.Equal(t, PluralRuleDefault, GetPluralRuleImpl("de-DE"))
assert.Equal(t, PluralRuleOneForm, GetPluralRuleImpl("zh"))
assert.Equal(t, PluralRuleOneForm, GetPluralRuleImpl("ja"))
assert.Equal(t, PluralRuleBengali, GetPluralRuleImpl("bn"))
assert.Equal(t, PluralRuleIcelandic, GetPluralRuleImpl("is"))
assert.Equal(t, PluralRuleFilipino, GetPluralRuleImpl("fil"))
assert.Equal(t, PluralRuleCzech, GetPluralRuleImpl("cs"))
assert.Equal(t, PluralRuleRussian, GetPluralRuleImpl("ru"))
assert.Equal(t, PluralRulePolish, GetPluralRuleImpl("pl"))
assert.Equal(t, PluralRuleLatvian, GetPluralRuleImpl("lv"))
assert.Equal(t, PluralRuleLithuanian, GetPluralRuleImpl("lt"))
assert.Equal(t, PluralRuleFrench, GetPluralRuleImpl("fr"))
assert.Equal(t, PluralRuleCatalan, GetPluralRuleImpl("ca"))
assert.Equal(t, PluralRuleSlovenian, GetPluralRuleImpl("sl"))
assert.Equal(t, PluralRuleArabic, GetPluralRuleImpl("ar"))
assert.Equal(t, PluralRuleCatalan, GetPluralRuleImpl("pt-PT"))
assert.Equal(t, PluralRuleFrench, GetPluralRuleImpl("pt-BR"))
assert.Equal(t, PluralRuleDefault, GetPluralRuleImpl("invalid"))
}
func TestApplyPluralRule(t *testing.T) {
testCases := []struct {
expect i18n.PluralFormIndex
pluralRule int
values []int64
}{
{i18n.PluralFormOne, PluralRuleDefault, []int64{1}},
{i18n.PluralFormOther, PluralRuleDefault, []int64{0, 2, 10, 256}},
{i18n.PluralFormOther, PluralRuleOneForm, []int64{0, 1, 2}},
{i18n.PluralFormOne, PluralRuleBengali, []int64{0, 1}},
{i18n.PluralFormOther, PluralRuleBengali, []int64{2, 10, 256}},
{i18n.PluralFormOne, PluralRuleIcelandic, []int64{1, 21, 31}},
{i18n.PluralFormOther, PluralRuleIcelandic, []int64{0, 2, 11, 15, 256}},
{i18n.PluralFormOne, PluralRuleFilipino, []int64{0, 1, 2, 3, 5, 7, 8, 10, 11, 12, 257}},
{i18n.PluralFormOther, PluralRuleFilipino, []int64{4, 6, 9, 14, 16, 19, 256}},
{i18n.PluralFormOne, PluralRuleCzech, []int64{1}},
{i18n.PluralFormFew, PluralRuleCzech, []int64{2, 3, 4}},
{i18n.PluralFormOther, PluralRuleCzech, []int64{5, 0, 12, 78, 254}},
{i18n.PluralFormOne, PluralRuleRussian, []int64{1, 21, 31}},
{i18n.PluralFormFew, PluralRuleRussian, []int64{2, 23, 34}},
{i18n.PluralFormMany, PluralRuleRussian, []int64{0, 5, 11, 37, 111, 256}},
{i18n.PluralFormOne, PluralRulePolish, []int64{1}},
{i18n.PluralFormFew, PluralRulePolish, []int64{2, 23, 34}},
{i18n.PluralFormMany, PluralRulePolish, []int64{0, 5, 11, 21, 37, 256}},
{i18n.PluralFormZero, PluralRuleLatvian, []int64{0, 10, 11, 17}},
{i18n.PluralFormOne, PluralRuleLatvian, []int64{1, 21, 71}},
{i18n.PluralFormOther, PluralRuleLatvian, []int64{2, 7, 22, 23, 256}},
{i18n.PluralFormOne, PluralRuleLithuanian, []int64{1, 21, 31}},
{i18n.PluralFormFew, PluralRuleLithuanian, []int64{2, 5, 9, 23, 34, 256}},
{i18n.PluralFormMany, PluralRuleLithuanian, []int64{0, 10, 11, 18}},
{i18n.PluralFormOne, PluralRuleFrench, []int64{0, 1}},
{i18n.PluralFormMany, PluralRuleFrench, []int64{1000000, 2000000}},
{i18n.PluralFormOther, PluralRuleFrench, []int64{2, 4, 10, 256}},
{i18n.PluralFormOne, PluralRuleCatalan, []int64{1}},
{i18n.PluralFormMany, PluralRuleCatalan, []int64{1000000, 2000000}},
{i18n.PluralFormOther, PluralRuleCatalan, []int64{0, 2, 4, 10, 256}},
{i18n.PluralFormOne, PluralRuleSlovenian, []int64{1, 101, 201, 501}},
{i18n.PluralFormTwo, PluralRuleSlovenian, []int64{2, 102, 202, 502}},
{i18n.PluralFormFew, PluralRuleSlovenian, []int64{3, 103, 203, 503, 4, 104, 204, 504}},
{i18n.PluralFormOther, PluralRuleSlovenian, []int64{0, 5, 11, 12, 20, 256}},
{i18n.PluralFormZero, PluralRuleArabic, []int64{0}},
{i18n.PluralFormOne, PluralRuleArabic, []int64{1}},
{i18n.PluralFormTwo, PluralRuleArabic, []int64{2}},
{i18n.PluralFormFew, PluralRuleArabic, []int64{3, 4, 9, 10, 103, 104}},
{i18n.PluralFormMany, PluralRuleArabic, []int64{11, 12, 13, 14, 17, 111, 256}},
{i18n.PluralFormOther, PluralRuleArabic, []int64{100, 101, 102}},
}
for _, tc := range testCases {
for _, n := range tc.values {
assert.Equal(t, tc.expect, PluralRules[tc.pluralRule](n), "Testcase for plural rule %d, value %d", tc.pluralRule, n)
}
}
}

View file

@ -190,7 +190,6 @@ commit_kind = Search commits...
runner_kind = Search runners... runner_kind = Search runners...
no_results = No matching results found. no_results = No matching results found.
issue_kind = Search issues... issue_kind = Search issues...
milestone_kind = Search milestones...
pull_kind = Search pulls... pull_kind = Search pulls...
keyword_search_unavailable = Searching by keyword is currently not available. Please contact the site administrator. keyword_search_unavailable = Searching by keyword is currently not available. Please contact the site administrator.
@ -1887,10 +1886,6 @@ pulls.nothing_to_compare_have_tag = The selected branch/tag are equal.
pulls.nothing_to_compare_and_allow_empty_pr = These branches are equal. This PR will be empty. pulls.nothing_to_compare_and_allow_empty_pr = These branches are equal. This PR will be empty.
pulls.has_pull_request = `A pull request between these branches already exists: <a href="%[1]s">%[2]s#%[3]d</a>` pulls.has_pull_request = `A pull request between these branches already exists: <a href="%[1]s">%[2]s#%[3]d</a>`
pulls.create = Create pull request pulls.create = Create pull request
pulls.title_desc_one = wants to merge %[1]d commit from <code>%[2]s</code> into <code id="%[4]s">%[3]s</code>
pulls.title_desc_few = wants to merge %[1]d commits from <code>%[2]s</code> into <code id="%[4]s">%[3]s</code>
pulls.merged_title_desc_one = merged %[1]d commit from <code>%[2]s</code> into <code>%[3]s</code> %[4]s
pulls.merged_title_desc_few = merged %[1]d commits from <code>%[2]s</code> into <code>%[3]s</code> %[4]s
pulls.change_target_branch_at = `changed target branch from <b>%s</b> to <b>%s</b> %s` pulls.change_target_branch_at = `changed target branch from <b>%s</b> to <b>%s</b> %s`
pulls.tab_conversation = Conversation pulls.tab_conversation = Conversation
pulls.tab_commits = Commits pulls.tab_commits = Commits

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1,14 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"one": "сля %[1]d подаване от <code>%[2]s</code> в <code>%[3]s</code> %[4]s",
"other": "сля %[1]d подавания от <code>%[2]s</code> в <code>%[3]s</code> %[4]s"
},
"title_desc": {
"one": "иска да слее %[1]d подаване от <code>%[2]s</code> в <code id=\"%[4]s\">%[3]s</code>",
"other": "иска да слее %[1]d подавания от <code>%[2]s</code> в <code id=\"%[4]s\">%[3]s</code>"
}
}
}
}

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1,5 @@
{
"search": {
"milestone_kind": "Cerca fites..."
}
}

View file

@ -0,0 +1,17 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"one": "sloučil %[1]d commit z <code>%[2]s</code> do <code>%[3]s</code> %[4]s",
"other": "sloučil %[1]d commity z větve <code>%[2]s</code> do větve <code>%[3]s</code> před %[4]s"
},
"title_desc": {
"one": "žádá o sloučení %[1]d commitu z <code>%[2]s</code> do <code id=\"%[4]s\">%[3]s</code>",
"other": "chce sloučit %[1]d commity z větve <code>%[2]s</code> do <code id=\"%[4]s\">%[3]s</code>"
}
}
},
"search": {
"milestone_kind": "Hledat milníky..."
}
}

View file

@ -0,0 +1,5 @@
{
"search": {
"milestone_kind": "Søg milepæle..."
}
}

View file

@ -0,0 +1,17 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"one": "hat %[1]d Commit von <code>%[2]s</code> nach <code>%[3]s</code> %[4]s zusammengeführt",
"other": "hat %[1]d Commits von <code>%[2]s</code> nach <code>%[3]s</code> %[4]s zusammengeführt"
},
"title_desc": {
"one": "möchte %[1]d Commit von <code>%[2]s</code> nach <code id=\"%[4]s\">%[3]s</code> zusammenführen",
"other": "möchte %[1]d Commits von <code>%[2]s</code> nach <code id=\"%[4]s\">%[3]s</code> zusammenführen"
}
}
},
"search": {
"milestone_kind": "Meilensteine suchen …"
}
}

View file

@ -0,0 +1,17 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"one": "συγχώνευσε %[1]d υποβολή από τον κλάδο <code>%[2]s</code> στον κλάδο <code>%[3]s</code> %[4]s",
"other": "συγχώνευσε %[1]d υποβολές από <code>%[2]s</code> σε <code>%[3]s</code> %[4]s"
},
"title_desc": {
"one": ": θα ήθελε να συγχωνεύσει %[1]d υποβολή από τον κλάδο <code>%[2]s</code> στον κλάδο <code id=\"%[4]s\">%[3]s</code>",
"other": "θέλει να συγχωνεύσει %[1]d υποβολές από <code>%[2]s</code> σε <code id=\"%[4]s\">%[3]s</code>"
}
}
},
"search": {
"milestone_kind": "Αναζήτηση ορόσημων..."
}
}

View file

@ -0,0 +1,17 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"one": "merged %[1]d commit from <code>%[2]s</code> into <code>%[3]s</code> %[4]s",
"other": "merged %[1]d commits from <code>%[2]s</code> into <code>%[3]s</code> %[4]s"
},
"title_desc": {
"one": "wants to merge %[1]d commit from <code>%[2]s</code> into <code id=\"%[4]s\">%[3]s</code>",
"other": "wants to merge %[1]d commits from <code>%[2]s</code> into <code id=\"%[4]s\">%[3]s</code>"
}
}
},
"search": {
"milestone_kind": "Search milestones..."
}
}

View file

@ -0,0 +1,5 @@
{
"search": {
"milestone_kind": "Serĉi celojn..."
}
}

View file

@ -0,0 +1,17 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"one": "fusionó %[1]d commit de <code>%[2]s</code> en <code>%[3]s</code> %[4]s",
"other": "fusionó %[1]d commits de <code>%[2]s</code> en <code>%[3]s</code> %[4]s"
},
"title_desc": {
"one": "quiere fusionar %[1]d commit de <code>%[2]s</code> en <code id=\"%[4]s\">%[3]s</code>",
"other": "quiere fusionar %[1]d commits de <code>%[2]s</code> en <code id=\"%[4]s\">%[3]s</code>"
}
}
},
"search": {
"milestone_kind": "Buscar hitos…"
}
}

View file

@ -0,0 +1,5 @@
{
"search": {
"milestone_kind": "Otsi verstapostid..."
}
}

View file

@ -0,0 +1,12 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"other": "%[1]d کامیت ادغام شده از <code>%[2]s</code> به <code>%[3]s</code> %[4]s"
},
"title_desc": {
"other": "قصد ادغام %[1]d تغییر را از <code>%[2]s</code> به <code id=\"%[4]s\">%[3]s</code> دارد"
}
}
}
}

View file

@ -0,0 +1,15 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"other": "yhdistetty %[1]d committia lähteestä <code>%[2]s</code> kohteeseen <code>%[3]s</code> %[4]s"
},
"title_desc": {
"other": "haluaa yhdistää %[1]d committia lähteestä <code>%[2]s</code> kohteeseen <code id=\"%[4]s\">%[3]s</code>"
}
}
},
"search": {
"milestone_kind": "Etsi merkkipaaluja..."
}
}

View file

@ -0,0 +1,17 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"one": "isinali ang %[1]d commit mula<code>%[2]s</code> patungong <code>%[3]s</code> %[4]s",
"other": "isinali ang %[1]d mga commit mula sa <code>%[2]s</code> patungong <code>%[3]s</code> %[4]s"
},
"title_desc": {
"one": "hinihiling na isama ang %[1]d commit mula <code>%[2]s</code> patungong <code id=\"%[4]s\">%[3]s</code>",
"other": "hiniling na isama ang %[1]d mga commit mula sa <code>%[2]s</code> patungong <code id=\"%[4]s\">%[3]s</code>"
}
}
},
"search": {
"milestone_kind": "Maghanap ng mga milestone…"
}
}

View file

@ -0,0 +1,17 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"one": "fusionné %[1]d commit depuis <code>%[2]s</code> vers <code>%[3]s</code> %[4]s",
"other": "a fusionné %[1]d révision(s) à partir de <code>%[2]s</code> vers <code>%[3]s</code> %[4]s"
},
"title_desc": {
"one": "veut fusionner %[1]d commit depuis <code>%[2]s</code> vers <code id=\"%[4]s\">%[3]s</code>",
"other": "souhaite fusionner %[1]d révision(s) depuis <code>%[2]s</code> vers <code id=\"%[4]s\">%[3]s</code>"
}
}
},
"search": {
"milestone_kind": "Recherche dans les jalons..."
}
}

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1,15 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"other": "egyesítve %[1]d változás(ok) a <code>%[2]s</code>-ból <code>%[3]s</code>-ba %[4]s"
},
"title_desc": {
"other": "egyesíteni szeretné %[1]d változás(oka)t a(z) <code>%[2]s</code>-ból <code id=\"%[4]s\">%[3]s</code>-ba"
}
}
},
"search": {
"milestone_kind": "Mérföldkövek keresése..."
}
}

View file

@ -0,0 +1,12 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"other": "commit %[1]d telah digabungkan dari <code>%[2]s</code> menjadi <code>%[3]s</code> %[4]s"
},
"title_desc": {
"other": "ingin menggabungkan komit %[1]d dari <code>%[2]s</code> menuju <code id=\"%[4]s\">%[3]s</code>"
}
}
}
}

View file

@ -0,0 +1,9 @@
{
"repo": {
"pulls": {
"title_desc": {
"other": "vill sameina %[1]d framlög frá <code>%[2]s</code> í <code id=\"%[4]s\">%[3]s</code>"
}
}
}
}

View file

@ -0,0 +1,17 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"one": "ha fuso %[1]d commit da <code>%[2]s</code> in <code>%[3]s</code> %[4]s",
"other": "ha unito %[1]d commit da <code>%[2]s</code> a <code>%[3]s</code> %[4]s"
},
"title_desc": {
"one": "vuole fondere %[1]d commit da <code>%[2]s</code> in <code id=\"%[4]s\">%[3]s</code>",
"other": "vuole unire %[1]d commit da <code>%[2]s</code> a <code id=\"%[4]s\">%[3]s</code>"
}
}
},
"search": {
"milestone_kind": "Ricerca tappe..."
}
}

View file

@ -0,0 +1,15 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"other": "が %[1]d 個のコミットを <code>%[2]s</code> から <code>%[3]s</code> へマージ %[4]s"
},
"title_desc": {
"other": "が <code>%[2]s</code> から <code id=\"%[4]s\">%[3]s</code> への %[1]d コミットのマージを希望しています"
}
}
},
"search": {
"milestone_kind": "マイルストーンを検索..."
}
}

View file

@ -0,0 +1,12 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"other": "님이 <code>%[2]s</code> 에서 <code>%[3]s</code> 로 %[1]d 커밋을 %[4]s 병합함"
},
"title_desc": {
"other": "<code>%[2]s</code> 에서 <code id=\"%[4]s\">%[3]s</code> 로 %[1]d개의 커밋들을 병합하려함"
}
}
}
}

View file

@ -0,0 +1,5 @@
{
"search": {
"milestone_kind": "Ieškoti gairių..."
}
}

View file

@ -0,0 +1,17 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"one": "iekļāva %[1]d iesūtījumu no <code>%[2]s</code> <code>%[3]s</code> %[4]s",
"other": "Iekļāva %[1]d iesūtījumus no <code>%[2]s</code> zarā <code>%[3]s</code> %[4]s"
},
"title_desc": {
"one": "vēlas iekļaut %[1]d iesūtījumu no <code>%[2]s</code> <code id=\"%[4]s\">%[3]s</code>",
"other": "vēlas iekļaut %[1]d iesūtījumus no <code>%[2]s</code> zarā <code id=\"%[4]s\">%[3]s</code>"
}
}
},
"search": {
"milestone_kind": "Meklēt atskaites punktus..."
}
}

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1,17 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"one": "hett %[1]d Kommitteren vun <code>%[2]s</code> na <code>%[3]s</code> %[4]s tosamenföhrt",
"other": "hett %[1]d Kommitterens vun <code>%[2]s</code> na <code>%[3]s</code> %[4]s tosamenföhrt"
},
"title_desc": {
"one": "will %[1]d Kommitteren vun <code>%[2]s</code> na <code id=\"%[4]s\">%[3]s</code> tosamenföhren",
"other": "will %[1]d Kommitterens vun <code>%[2]s</code> na <code id=\"%[4]s\">%[3]s</code> tosamenföhren"
}
}
},
"search": {
"milestone_kind": "In Markstenen söken …"
}
}

View file

@ -0,0 +1,17 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"one": "heeft %[1]d commit van <code>%[2]s</code> samengevoegd in <code>%[3]s</code> %[4]s",
"other": "heeft %[1]d commits samengevoegd van <code>%[2]s</code> naar <code>%[3]s</code> %[4]s"
},
"title_desc": {
"one": "wilt %[1]d commit van <code>%[2]s</code> samenvoegen in <code id=\"%[4]s\">%[3]s</code>",
"other": "wilt %[1]d commits van <code>%[2]s</code> samenvoegen met <code id=\"%[4]s\">%[3]s</code>"
}
}
},
"search": {
"milestone_kind": "Zoek mijlpalen..."
}
}

View file

@ -0,0 +1,15 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"many": "scala %[1]d commity/ów z <code>%[2]s</code> do <code>%[3]s</code> %[4]s"
},
"title_desc": {
"many": "chce scalić %[1]d commity/ów z <code>%[2]s</code> do <code id=\"%[4]s\">%[3]s</code>"
}
}
},
"search": {
"milestone_kind": "Wyszukaj kamienie milowe..."
}
}

View file

@ -0,0 +1,17 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"one": "mesclou %[1]d commit de <code>%[2]s</code> em <code>%[3]s</code> %[4]s",
"other": "mesclou %[1]d commits de <code>%[2]s</code> em <code>%[3]s</code> %[4]s"
},
"title_desc": {
"one": "quer mesclar %[1]d commit de <code>%[2]s</code> em <code id=\"%[4]s\">%[3]s</code>",
"other": "quer mesclar %[1]d commits de <code>%[2]s</code> em <code id=\"%[4]s\">%[3]s</code>"
}
}
},
"search": {
"milestone_kind": "Pesquisar marcos..."
}
}

View file

@ -0,0 +1,17 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"one": "integrou %[1]d cometimento do ramo <code>%[2]s</code> no ramo <code>%[3]s</code> %[4]s",
"other": "integrou %[1]d cometimento(s) do ramo <code>%[2]s</code> no ramo <code>%[3]s</code> %[4]s"
},
"title_desc": {
"one": "quer integrar %[1]d cometimento do ramo <code>%[2]s</code> no ramo <code id=\"%[4]s\">%[3]s</code>",
"other": "quer integrar %[1]d cometimento(s) do ramo <code>%[2]s</code> no ramo <code id=\"%[4]s\">%[3]s</code>"
}
}
},
"search": {
"milestone_kind": "Procurar etapas..."
}
}

View file

@ -0,0 +1,17 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"one": "слит %[1]d коммит из <code>%[2]s</code> в <code>%[3]s</code> %[4]s",
"many": "слито %[1]d коммит(ов) из <code>%[2]s</code> в <code>%[3]s</code> %[4]s"
},
"title_desc": {
"one": "хочет влить %[1]d коммит из <code>%[2]s</code> в <code id=\"%[4]s\">%[3]s</code>",
"many": "хочет влить %[1]d коммит(ов) из <code>%[2]s</code> в <code id=\"%[4]s\">%[3]s</code>"
}
}
},
"search": {
"milestone_kind": "Найти этапы..."
}
}

View file

@ -0,0 +1,12 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"other": "මර්ජ්%[1]d සිට <code>%[2]s</code> දක්වා <code>%[3]s</code> %[4]s"
},
"title_desc": {
"other": "%[1]d සිට <code>%[2]s</code> දක්වා <code id=\"%[4]s\">%[3]s</code>"
}
}
}
}

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1,15 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"other": "sammanfogade %[1]d incheckningar från <code>%[2]s</code> in i <code>%[3]s</code> %[4]s"
},
"title_desc": {
"other": "vill sammanfoga %[1]d incheckningar från <code>s[2]s</code> in i <code id=\"%[4]s\">%[3]s</code>"
}
}
},
"search": {
"milestone_kind": "Sök milstolpar..."
}
}

View file

@ -0,0 +1,15 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"other": "%[4]s <code>%[2]s</code> içindeki %[1]d işlemeyi <code>%[3]s</code> ile birleştirdi"
},
"title_desc": {
"other": "<code>%[2]s</code> içindeki %[1]d işlemeyi <code id=\"%[4]s\">%[3]s</code> ile birleştirmek istiyor"
}
}
},
"search": {
"milestone_kind": "Kilometre taşlarını ara..."
}
}

View file

@ -0,0 +1,17 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"one": "об'єднав %[1]d коміт з <code>%[2]s</code> в <code>%[3]s</code> %[4]s",
"many": "об'єднав %[1]d комітів з <code>%[2]s</code> в <code>%[3]s</code> %[4]s"
},
"title_desc": {
"one": "хоче об'єднати %[1]d коміт з <code>%[2]s</code> в <code id=\"%[4]s\">%[3]s</code>",
"many": "хоче об'єднати %[1]d комітів з <code>%[2]s</code> в <code id=\"%[4]s\">%[3]s</code>"
}
}
},
"search": {
"milestone_kind": "Шукати віхи..."
}
}

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1,15 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"other": "于 %[4]s 将 %[1]d 次代码提交从 <code>%[2]s</code>合并至 <code>%[3]s</code>"
},
"title_desc": {
"other": "请求将 %[1]d 次代码提交从 <code>%[2]s</code> 合并至 <code id=\"%[4]s\">%[3]s</code>"
}
}
},
"search": {
"milestone_kind": "搜索里程碑…"
}
}

View file

@ -0,0 +1,9 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"other": "於 %[4]s 將 %[1]d 次代碼提交從 <code>%[2]s</code>合併至 <code>%[3]s</code>"
}
}
}
}

View file

@ -0,0 +1,15 @@
{
"repo": {
"pulls": {
"merged_title_desc": {
"other": "將 %[1]d 次提交從 <code>%[2]s</code> 合併至 <code>%[3]s</code> %[4]s"
},
"title_desc": {
"other": "請求將 %[1]d 次程式碼提交從 <code>%[2]s</code> 合併至 <code id=\"%[4]s\">%[3]s</code>"
}
}
},
"search": {
"milestone_kind": "搜尋里程碑..."
}
}

View file

@ -63,10 +63,10 @@
{{$mergedStr:= DateUtils.TimeSince .Issue.PullRequest.MergedUnix}} {{$mergedStr:= DateUtils.TimeSince .Issue.PullRequest.MergedUnix}}
{{if .Issue.OriginalAuthor}} {{if .Issue.OriginalAuthor}}
{{.Issue.OriginalAuthor}} {{.Issue.OriginalAuthor}}
<span class="pull-desc">{{ctx.Locale.TrN .NumCommits "repo.pulls.merged_title_desc_one" "repo.pulls.merged_title_desc_few" .NumCommits $headHref $baseHref $mergedStr}}</span> <span class="pull-desc">{{ctx.Locale.TrPluralString .NumCommits "repo.pulls.merged_title_desc" .NumCommits $headHref $baseHref $mergedStr}}</span>
{{else}} {{else}}
<a {{if gt .Issue.PullRequest.Merger.ID 0}}href="{{.Issue.PullRequest.Merger.HomeLink}}"{{end}}>{{.Issue.PullRequest.Merger.GetDisplayName}}</a> <a {{if gt .Issue.PullRequest.Merger.ID 0}}href="{{.Issue.PullRequest.Merger.HomeLink}}"{{end}}>{{.Issue.PullRequest.Merger.GetDisplayName}}</a>
<span class="pull-desc">{{ctx.Locale.TrN .NumCommits "repo.pulls.merged_title_desc_one" "repo.pulls.merged_title_desc_few" .NumCommits $headHref $baseHref $mergedStr}}</span> <span class="pull-desc">{{ctx.Locale.TrPluralString .NumCommits "repo.pulls.merged_title_desc" .NumCommits $headHref $baseHref $mergedStr}}</span>
{{end}} {{end}}
{{if .MadeUsingAGit}} {{if .MadeUsingAGit}}
{{/* TODO: Move documentation link to the instructions at the bottom of the PR, show instructions when clicking label */}} {{/* TODO: Move documentation link to the instructions at the bottom of the PR, show instructions when clicking label */}}
@ -79,11 +79,11 @@
{{end}} {{end}}
{{else}} {{else}}
{{if .Issue.OriginalAuthor}} {{if .Issue.OriginalAuthor}}
<span id="pull-desc-display" class="pull-desc">{{.Issue.OriginalAuthor}} {{ctx.Locale.TrN .NumCommits "repo.pulls.title_desc_one" "repo.pulls.title_desc_few" .NumCommits $headHref $baseHref "branch_target"}}</span> <span id="pull-desc-display" class="pull-desc">{{.Issue.OriginalAuthor}} {{ctx.Locale.TrPluralString .NumCommits "repo.pulls.title_desc" .NumCommits $headHref $baseHref "branch_target"}}</span>
{{else}} {{else}}
<span id="pull-desc-display" class="pull-desc"> <span id="pull-desc-display" class="pull-desc">
<a {{if gt .Issue.Poster.ID 0}}href="{{.Issue.Poster.HomeLink}}"{{end}}>{{.Issue.Poster.GetDisplayName}}</a> <a {{if gt .Issue.Poster.ID 0}}href="{{.Issue.Poster.HomeLink}}"{{end}}>{{.Issue.Poster.GetDisplayName}}</a>
{{ctx.Locale.TrN .NumCommits "repo.pulls.title_desc_one" "repo.pulls.title_desc_few" .NumCommits $headHref $baseHref "branch_target"}} {{ctx.Locale.TrPluralString .NumCommits "repo.pulls.title_desc" .NumCommits $headHref $baseHref "branch_target"}}
</span> </span>
{{end}} {{end}}
{{if .MadeUsingAGit}} {{if .MadeUsingAGit}}

145
tools/migrate_locales.sh Executable file
View file

@ -0,0 +1,145 @@
#!/bin/bash
# Copyright 2024 The Forgejo Authors. All rights reserved.
# SPDX-License-Identifier: MIT
if [ -z "$1" ] || [ -z "$2" ]
then
echo "USAGE: $0 section key [key1 [keyN]]"
exit 1
fi
if ! [ -d ../options/locale_next ]
then
echo 'Call this script from the `tools` directory.'
exit 1
fi
destsection="$1"
keyJSON="$destsection.$2"
key1=""
keyN=""
if [ -n "$3" ]
then
key1="$3"
else
key1="$2"
fi
if [ -n "$4" ]
then
keyN="$4"
fi
cd ../options/locale
# Migrate the string in one file.
function process() {
file="$1"
exec 3<$file
val1=""
valN=""
cursection=""
line1=0
lineN=0
lineNumber=0
# Parse the file
while read -u 3 line
do
((++lineNumber))
if [[ $line =~ ^\[[-._a-zA-Z0-9]+\]$ ]]
then
cursection="${line#[}"
cursection="${cursection%]}"
elif [ "$cursection" = "$destsection" ]
then
key="${line%%=*}"
value="${line#*=}"
key="$(echo $key)" # Trim leading/trailing whitespace
value="$(echo $value)"
if [ "$key" = "$key1" ]
then
val1="$value"
line1=$lineNumber
fi
if [ -n "$keyN" ] && [ "$key" = "$keyN" ]
then
valN="$value"
lineN=$lineNumber
fi
if [ -n "$val1" ] && ( [ -n "$valN" ] || [ -z "$keyN" ] )
then
# Found all desired strings
break
fi
fi
done
if [ -n "$val1" ] || [ -n "$valN" ]
then
localename="${file#locale_}"
localename="${localename%.ini}"
localename="${localename%-*}"
if [ "$file" = "locale_en-US.ini" ]
then
# Delete migrated string from source file
if [ $line1 -gt 0 ] && [ $lineN -gt 0 ] && [ $lineN -ne $line1 ]
then
sed -i "${line1}d;${lineN}d" "$file"
elif [ $line1 -gt 0 ]
then
sed -i "${line1}d" "$file"
elif [ $lineN -gt 0 ]
then
sed -i "${lineN}d" "$file"
fi
fi
# Write JSON
jsonfile="../locale_next/${file/.ini/.json}"
pluralform="other"
oneform="one"
case $localename in
"be" | "bs" | "cnr" | "csb" | "hr" | "lt" | "pl" | "ru" | "sr" | "szl" | "uk" | "wen")
# These languages have no "other" form and use "many" instead.
pluralform="many"
;;
"ace" | "ay" | "bm" | "bo" | "cdo" | "cpx" | "crh" | "dz" | "gan" | "hak" | "hnj" | "hsn" | "id" | "ig" | "ii" | "ja" | "jbo" | "jv" | "kde" | "kea" | "km" | "ko" | "kos" | "lkt" | "lo" | "lzh" | "ms" | "my" | "nan" | "nqo" | "osa" | "sah" | "ses" | "sg" | "son" | "su" | "th" | "tlh" | "to" | "tok" | "tpi" | "tt" | "vi" | "wo" | "wuu" | "yo" | "yue" | "zh")
# These languages have no singular form.
oneform=""
;;
*)
;;
esac
content=""
if [ -z "$keyN" ]
then
content="$(jq --arg val "$val1" ".$keyJSON = \$val" < "$jsonfile")"
else
object='{}'
if [ -n "$val1" ] && [ -n "$oneform" ]
then
object=$(jq --arg val "$val1" ".$oneform = \$val" <<< "$object")
fi
if [ -n "$valN" ]
then
object=$(jq --arg val "$valN" ".$pluralform = \$val" <<< "$object")
fi
content="$(jq --argjson val "$object" ".$keyJSON = \$val" < "$jsonfile")"
fi
jq . <<< "$content" > "$jsonfile"
fi
}
for file in *.ini
do
process "$file" &
done
wait