From a2787bb09edcf00f29a65c4f405de6371b4d434e Mon Sep 17 00:00:00 2001
From: Benedikt Straub <benedikt-straub@web.de>
Date: Mon, 30 Dec 2024 20:03:37 +0100
Subject: [PATCH] Initial support for localization and pluralization with
 go-i18n-JSON-v2 format

---
 .deadcode-out                           |   1 +
 modules/translation/i18n/dummy.go       |  20 +-
 modules/translation/i18n/errors.go      |   6 +-
 modules/translation/i18n/i18n.go        |  23 ++-
 modules/translation/i18n/i18n_test.go   | 106 +++++++++-
 modules/translation/i18n/localestore.go | 180 +++++++++++++++--
 modules/translation/mock.go             |   4 +
 modules/translation/plural_rules.go     | 253 ++++++++++++++++++++++++
 modules/translation/translation.go      |  16 +-
 modules/translation/translation_test.go | 108 ++++++++++
 options/locale/locale_en-US.ini         |   5 -
 options/locale_next/locale_ar.json      |   1 +
 options/locale_next/locale_be.json      |   1 +
 options/locale_next/locale_bg.json      |  14 ++
 options/locale_next/locale_bn.json      |   1 +
 options/locale_next/locale_bs.json      |   1 +
 options/locale_next/locale_ca.json      |   5 +
 options/locale_next/locale_cs-CZ.json   |  17 ++
 options/locale_next/locale_da.json      |   5 +
 options/locale_next/locale_de-DE.json   |  17 ++
 options/locale_next/locale_el-GR.json   |  17 ++
 options/locale_next/locale_en-US.json   |  17 ++
 options/locale_next/locale_eo.json      |   5 +
 options/locale_next/locale_es-ES.json   |  17 ++
 options/locale_next/locale_et.json      |   5 +
 options/locale_next/locale_fa-IR.json   |  12 ++
 options/locale_next/locale_fi-FI.json   |  15 ++
 options/locale_next/locale_fil.json     |  17 ++
 options/locale_next/locale_fr-FR.json   |  17 ++
 options/locale_next/locale_gl.json      |   1 +
 options/locale_next/locale_hi.json      |   1 +
 options/locale_next/locale_hu-HU.json   |  15 ++
 options/locale_next/locale_id-ID.json   |  12 ++
 options/locale_next/locale_is-IS.json   |   9 +
 options/locale_next/locale_it-IT.json   |  17 ++
 options/locale_next/locale_ja-JP.json   |  15 ++
 options/locale_next/locale_ko-KR.json   |  12 ++
 options/locale_next/locale_lt.json      |   5 +
 options/locale_next/locale_lv-LV.json   |  17 ++
 options/locale_next/locale_ml-IN.json   |   1 +
 options/locale_next/locale_nb_NO.json   |   1 +
 options/locale_next/locale_nds.json     |  17 ++
 options/locale_next/locale_nl-NL.json   |  17 ++
 options/locale_next/locale_pl-PL.json   |  15 ++
 options/locale_next/locale_pt-BR.json   |  17 ++
 options/locale_next/locale_pt-PT.json   |  17 ++
 options/locale_next/locale_ru-RU.json   |  17 ++
 options/locale_next/locale_si-LK.json   |  12 ++
 options/locale_next/locale_sk-SK.json   |   1 +
 options/locale_next/locale_sl.json      |   1 +
 options/locale_next/locale_sr-SP.json   |   1 +
 options/locale_next/locale_sv-SE.json   |  15 ++
 options/locale_next/locale_tr-TR.json   |  15 ++
 options/locale_next/locale_uk-UA.json   |  17 ++
 options/locale_next/locale_vi.json      |   1 +
 options/locale_next/locale_yi.json      |   1 +
 options/locale_next/locale_zh-CN.json   |  15 ++
 options/locale_next/locale_zh-HK.json   |   9 +
 options/locale_next/locale_zh-TW.json   |  15 ++
 templates/repo/issue/view_title.tmpl    |   8 +-
 tools/migrate_locales.sh                | 145 ++++++++++++++
 61 files changed, 1317 insertions(+), 51 deletions(-)
 create mode 100644 modules/translation/plural_rules.go
 create mode 100644 options/locale_next/locale_ar.json
 create mode 100644 options/locale_next/locale_be.json
 create mode 100644 options/locale_next/locale_bg.json
 create mode 100644 options/locale_next/locale_bn.json
 create mode 100644 options/locale_next/locale_bs.json
 create mode 100644 options/locale_next/locale_ca.json
 create mode 100644 options/locale_next/locale_cs-CZ.json
 create mode 100644 options/locale_next/locale_da.json
 create mode 100644 options/locale_next/locale_de-DE.json
 create mode 100644 options/locale_next/locale_el-GR.json
 create mode 100644 options/locale_next/locale_en-US.json
 create mode 100644 options/locale_next/locale_eo.json
 create mode 100644 options/locale_next/locale_es-ES.json
 create mode 100644 options/locale_next/locale_et.json
 create mode 100644 options/locale_next/locale_fa-IR.json
 create mode 100644 options/locale_next/locale_fi-FI.json
 create mode 100644 options/locale_next/locale_fil.json
 create mode 100644 options/locale_next/locale_fr-FR.json
 create mode 100644 options/locale_next/locale_gl.json
 create mode 100644 options/locale_next/locale_hi.json
 create mode 100644 options/locale_next/locale_hu-HU.json
 create mode 100644 options/locale_next/locale_id-ID.json
 create mode 100644 options/locale_next/locale_is-IS.json
 create mode 100644 options/locale_next/locale_it-IT.json
 create mode 100644 options/locale_next/locale_ja-JP.json
 create mode 100644 options/locale_next/locale_ko-KR.json
 create mode 100644 options/locale_next/locale_lt.json
 create mode 100644 options/locale_next/locale_lv-LV.json
 create mode 100644 options/locale_next/locale_ml-IN.json
 create mode 100644 options/locale_next/locale_nb_NO.json
 create mode 100644 options/locale_next/locale_nds.json
 create mode 100644 options/locale_next/locale_nl-NL.json
 create mode 100644 options/locale_next/locale_pl-PL.json
 create mode 100644 options/locale_next/locale_pt-BR.json
 create mode 100644 options/locale_next/locale_pt-PT.json
 create mode 100644 options/locale_next/locale_ru-RU.json
 create mode 100644 options/locale_next/locale_si-LK.json
 create mode 100644 options/locale_next/locale_sk-SK.json
 create mode 100644 options/locale_next/locale_sl.json
 create mode 100644 options/locale_next/locale_sr-SP.json
 create mode 100644 options/locale_next/locale_sv-SE.json
 create mode 100644 options/locale_next/locale_tr-TR.json
 create mode 100644 options/locale_next/locale_uk-UA.json
 create mode 100644 options/locale_next/locale_vi.json
 create mode 100644 options/locale_next/locale_yi.json
 create mode 100644 options/locale_next/locale_zh-CN.json
 create mode 100644 options/locale_next/locale_zh-HK.json
 create mode 100644 options/locale_next/locale_zh-TW.json
 create mode 100755 tools/migrate_locales.sh

diff --git a/.deadcode-out b/.deadcode-out
index 64741ec7ac..a44599b6f1 100644
--- a/.deadcode-out
+++ b/.deadcode-out
@@ -246,6 +246,7 @@ code.gitea.io/gitea/modules/translation
 	MockLocale.TrString
 	MockLocale.Tr
 	MockLocale.TrN
+	MockLocale.TrPluralString
 	MockLocale.TrSize
 	MockLocale.PrettyNumber
 
diff --git a/modules/translation/i18n/dummy.go b/modules/translation/i18n/dummy.go
index fe15c250f4..861672c619 100644
--- a/modules/translation/i18n/dummy.go
+++ b/modules/translation/i18n/dummy.go
@@ -22,20 +22,7 @@ func (k *KeyLocale) HasKey(trKey string) bool {
 
 // TrHTML implements Locale.
 func (k *KeyLocale) 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(k.TrString(trKey, args...))
+	return template.HTML(k.TrString(trKey, PrepareArgsForHTML(trArgs...)...))
 }
 
 // TrString implements Locale.
@@ -43,6 +30,11 @@ func (k *KeyLocale) TrString(trKey string, trArgs ...any) string {
 	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 {
 	if len(args) == 0 {
 		return fmt.Sprintf("(%s)", trKey)
diff --git a/modules/translation/i18n/errors.go b/modules/translation/i18n/errors.go
index 7f64ccf908..ee9436a8f7 100644
--- a/modules/translation/i18n/errors.go
+++ b/modules/translation/i18n/errors.go
@@ -8,6 +8,8 @@ import (
 )
 
 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}
+	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}
 )
diff --git a/modules/translation/i18n/i18n.go b/modules/translation/i18n/i18n.go
index 1555cd961e..e447502a3b 100644
--- a/modules/translation/i18n/i18n.go
+++ b/modules/translation/i18n/i18n.go
@@ -8,11 +8,28 @@ import (
 	"io"
 )
 
+type (
+	PluralFormIndex uint8
+	PluralFormRule  func(int64) PluralFormIndex
+)
+
+const (
+	PluralFormZero PluralFormIndex = iota
+	PluralFormOne
+	PluralFormTwo
+	PluralFormFew
+	PluralFormMany
+	PluralFormOther
+)
+
 var DefaultLocales = NewLocaleStore()
 
 type Locale interface {
 	// TrString translates a given key and arguments for a language
 	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(trKey string, trArgs ...any) template.HTML
 	// HasKey reports if a locale has a translation for a given key
@@ -31,8 +48,10 @@ type LocaleStore interface {
 	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
+	// AddLocaleByIni adds a new old-style language to the store
+	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
diff --git a/modules/translation/i18n/i18n_test.go b/modules/translation/i18n/i18n_test.go
index 244f6ffbb3..41f85931aa 100644
--- a/modules/translation/i18n/i18n_test.go
+++ b/modules/translation/i18n/i18n_test.go
@@ -12,6 +12,26 @@ import (
 	"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) {
 	testData1 := []byte(`
 .dot.name = Dot Name
@@ -27,11 +47,48 @@ fmt = %[2]s %[1]s
 
 [section]
 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()
-	require.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", testData1, nil))
-	require.NoError(t, ls.AddLocaleByIni("lang2", "Lang2", testData2, nil))
+	require.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", MockPluralRuleEnglish, testData1, 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")
 
 	lang1, _ := ls.Locale("lang1")
@@ -56,6 +113,45 @@ sub = Changed Sub String
 	result2 := lang2.TrHTML("section.mixed", "a&b")
 	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()
 	assert.ElementsMatch(t, []string{"lang1", "lang2"}, langs)
 	assert.ElementsMatch(t, []string{"Lang1", "Lang2"}, descs)
@@ -77,7 +173,7 @@ c=22
 `)
 
 	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")
 	assert.Equal(t, "11", lang1.TrString("a"))
 	assert.Equal(t, "21", lang1.TrString("b"))
@@ -118,7 +214,7 @@ func (e *errorPointerReceiver) Error() string {
 
 func TestLocaleWithTemplate(t *testing.T) {
 	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")
 
 	tmpl := template.New("test").Funcs(template.FuncMap{"tr": lang1.TrHTML})
@@ -181,7 +277,7 @@ func TestLocaleStoreQuirks(t *testing.T) {
 
 	for _, testData := range testDataList {
 		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")
 		require.NoError(t, err, testData.hint)
 		assert.Equal(t, testData.out, lang1.TrString("a"), testData.hint)
diff --git a/modules/translation/i18n/localestore.go b/modules/translation/i18n/localestore.go
index 0e6ddab401..e80b2592ae 100644
--- a/modules/translation/i18n/localestore.go
+++ b/modules/translation/i18n/localestore.go
@@ -8,8 +8,10 @@ import (
 	"html/template"
 	"slices"
 
+	"code.gitea.io/gitea/modules/json"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/util"
 )
 
 // This file implements the static LocaleStore that will not watch for changes
@@ -18,6 +20,9 @@ type locale struct {
 	store       *localeStore
 	langName    string
 	idxToMsgMap map[int]string // the map idx is generated by store's trKeyToIdxMap
+
+	newStyleMessages map[string]string
+	pluralRule       PluralFormRule
 }
 
 var _ Locale = (*locale)(nil)
@@ -38,8 +43,19 @@ func NewLocaleStore() LocaleStore {
 	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
-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 {
 		return ErrLocaleAlreadyExist
 	}
@@ -47,7 +63,7 @@ func (store *localeStore) AddLocaleByIni(langName, langDesc string, source, more
 	store.langNames = append(store.langNames, langName)
 	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
 
 	iniFile, err := setting.NewConfigProviderForLocale(source, moreSource)
@@ -78,6 +94,98 @@ func (store *localeStore) AddLocaleByIni(langName, langDesc string, source, more
 	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 {
 	_, ok := store.localeMap[langName]
 	return ok
@@ -113,22 +221,37 @@ func (store *localeStore) Close() error {
 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
+	if msg := l.LookupNewStyleMessage(trKey); msg != "" {
+		format = msg
+	} else {
+		// First fallback: old-style translation
+		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
 			}
 		}
-	}
-	if !found {
-		log.Error("Missing translation %q", trKey)
+
+		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
+						found = true
+					}
+				}
+			}
+
+			if !found {
+				log.Error("Missing translation %q", trKey)
+			}
+		}
 	}
 
 	msg, err := Format(format, trArgs...)
@@ -138,7 +261,7 @@ func (l *locale) TrString(trKey string, trArgs ...any) string {
 	return msg
 }
 
-func (l *locale) TrHTML(trKey string, trArgs ...any) template.HTML {
+func PrepareArgsForHTML(trArgs ...any) []any {
 	args := slices.Clone(trArgs)
 	for i, v := range args {
 		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))
 		}
 	}
-	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
diff --git a/modules/translation/mock.go b/modules/translation/mock.go
index fe3a1502ea..4d9acce26f 100644
--- a/modules/translation/mock.go
+++ b/modules/translation/mock.go
@@ -31,6 +31,10 @@ func (l MockLocale) TrN(cnt any, key1, keyN string, args ...any) template.HTML {
 	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 {
 	return ReadableSize{fmt.Sprint(s), ""}
 }
diff --git a/modules/translation/plural_rules.go b/modules/translation/plural_rules.go
new file mode 100644
index 0000000000..b8c00ceef7
--- /dev/null
+++ b/modules/translation/plural_rules.go
@@ -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
+	},
+}
diff --git a/modules/translation/translation.go b/modules/translation/translation.go
index 6687d3d817..7d1c627c84 100644
--- a/modules/translation/translation.go
+++ b/modules/translation/translation.go
@@ -32,6 +32,9 @@ type Locale interface {
 	TrString(string, ...any) string
 
 	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
 
 	TrSize(size int64) ReadableSize
@@ -100,8 +103,17 @@ func InitLocales(ctx context.Context) {
 			}
 
 			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 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 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 {
diff --git a/modules/translation/translation_test.go b/modules/translation/translation_test.go
index bffbb155ca..5b3eefb355 100644
--- a/modules/translation/translation_test.go
+++ b/modules/translation/translation_test.go
@@ -48,3 +48,111 @@ func TestPrettyNumber(t *testing.T) {
 	assert.EqualValues(t, "1,000,000", l.PrettyNumber(1000000))
 	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)
+		}
+	}
+}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index eaeab11a9d..127e629e80 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -190,7 +190,6 @@ commit_kind = Search commits...
 runner_kind = Search runners...
 no_results = No matching results found.
 issue_kind = Search issues...
-milestone_kind = Search milestones...
 pull_kind = Search pulls...
 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.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.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.tab_conversation = Conversation
 pulls.tab_commits = Commits
diff --git a/options/locale_next/locale_ar.json b/options/locale_next/locale_ar.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/options/locale_next/locale_ar.json
@@ -0,0 +1 @@
+{}
diff --git a/options/locale_next/locale_be.json b/options/locale_next/locale_be.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/options/locale_next/locale_be.json
@@ -0,0 +1 @@
+{}
diff --git a/options/locale_next/locale_bg.json b/options/locale_next/locale_bg.json
new file mode 100644
index 0000000000..bec72c556a
--- /dev/null
+++ b/options/locale_next/locale_bg.json
@@ -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>"
+      }
+    }
+  }
+}
diff --git a/options/locale_next/locale_bn.json b/options/locale_next/locale_bn.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/options/locale_next/locale_bn.json
@@ -0,0 +1 @@
+{}
diff --git a/options/locale_next/locale_bs.json b/options/locale_next/locale_bs.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/options/locale_next/locale_bs.json
@@ -0,0 +1 @@
+{}
diff --git a/options/locale_next/locale_ca.json b/options/locale_next/locale_ca.json
new file mode 100644
index 0000000000..8aee80092d
--- /dev/null
+++ b/options/locale_next/locale_ca.json
@@ -0,0 +1,5 @@
+{
+  "search": {
+    "milestone_kind": "Cerca fites..."
+  }
+}
diff --git a/options/locale_next/locale_cs-CZ.json b/options/locale_next/locale_cs-CZ.json
new file mode 100644
index 0000000000..373b9dc31e
--- /dev/null
+++ b/options/locale_next/locale_cs-CZ.json
@@ -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..."
+  }
+}
diff --git a/options/locale_next/locale_da.json b/options/locale_next/locale_da.json
new file mode 100644
index 0000000000..834f66024f
--- /dev/null
+++ b/options/locale_next/locale_da.json
@@ -0,0 +1,5 @@
+{
+  "search": {
+    "milestone_kind": "Søg milepæle..."
+  }
+}
diff --git a/options/locale_next/locale_de-DE.json b/options/locale_next/locale_de-DE.json
new file mode 100644
index 0000000000..82e4ea54d4
--- /dev/null
+++ b/options/locale_next/locale_de-DE.json
@@ -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 …"
+  }
+}
diff --git a/options/locale_next/locale_el-GR.json b/options/locale_next/locale_el-GR.json
new file mode 100644
index 0000000000..9fa112cf0f
--- /dev/null
+++ b/options/locale_next/locale_el-GR.json
@@ -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": "Αναζήτηση ορόσημων..."
+  }
+}
diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json
new file mode 100644
index 0000000000..64e3e50abc
--- /dev/null
+++ b/options/locale_next/locale_en-US.json
@@ -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..."
+  }
+}
diff --git a/options/locale_next/locale_eo.json b/options/locale_next/locale_eo.json
new file mode 100644
index 0000000000..c57b462d5f
--- /dev/null
+++ b/options/locale_next/locale_eo.json
@@ -0,0 +1,5 @@
+{
+  "search": {
+    "milestone_kind": "Serĉi celojn..."
+  }
+}
diff --git a/options/locale_next/locale_es-ES.json b/options/locale_next/locale_es-ES.json
new file mode 100644
index 0000000000..7313cdc0a7
--- /dev/null
+++ b/options/locale_next/locale_es-ES.json
@@ -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…"
+  }
+}
diff --git a/options/locale_next/locale_et.json b/options/locale_next/locale_et.json
new file mode 100644
index 0000000000..ac009856db
--- /dev/null
+++ b/options/locale_next/locale_et.json
@@ -0,0 +1,5 @@
+{
+  "search": {
+    "milestone_kind": "Otsi verstapostid..."
+  }
+}
diff --git a/options/locale_next/locale_fa-IR.json b/options/locale_next/locale_fa-IR.json
new file mode 100644
index 0000000000..6964db4934
--- /dev/null
+++ b/options/locale_next/locale_fa-IR.json
@@ -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> دارد"
+      }
+    }
+  }
+}
diff --git a/options/locale_next/locale_fi-FI.json b/options/locale_next/locale_fi-FI.json
new file mode 100644
index 0000000000..88a2110bcb
--- /dev/null
+++ b/options/locale_next/locale_fi-FI.json
@@ -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..."
+  }
+}
diff --git a/options/locale_next/locale_fil.json b/options/locale_next/locale_fil.json
new file mode 100644
index 0000000000..6be27dbb8c
--- /dev/null
+++ b/options/locale_next/locale_fil.json
@@ -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…"
+  }
+}
diff --git a/options/locale_next/locale_fr-FR.json b/options/locale_next/locale_fr-FR.json
new file mode 100644
index 0000000000..995a2be8bb
--- /dev/null
+++ b/options/locale_next/locale_fr-FR.json
@@ -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..."
+  }
+}
diff --git a/options/locale_next/locale_gl.json b/options/locale_next/locale_gl.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/options/locale_next/locale_gl.json
@@ -0,0 +1 @@
+{}
diff --git a/options/locale_next/locale_hi.json b/options/locale_next/locale_hi.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/options/locale_next/locale_hi.json
@@ -0,0 +1 @@
+{}
diff --git a/options/locale_next/locale_hu-HU.json b/options/locale_next/locale_hu-HU.json
new file mode 100644
index 0000000000..2a21d68095
--- /dev/null
+++ b/options/locale_next/locale_hu-HU.json
@@ -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..."
+  }
+}
diff --git a/options/locale_next/locale_id-ID.json b/options/locale_next/locale_id-ID.json
new file mode 100644
index 0000000000..13580f3cfe
--- /dev/null
+++ b/options/locale_next/locale_id-ID.json
@@ -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>"
+      }
+    }
+  }
+}
diff --git a/options/locale_next/locale_is-IS.json b/options/locale_next/locale_is-IS.json
new file mode 100644
index 0000000000..40d5a7e7aa
--- /dev/null
+++ b/options/locale_next/locale_is-IS.json
@@ -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>"
+      }
+    }
+  }
+}
diff --git a/options/locale_next/locale_it-IT.json b/options/locale_next/locale_it-IT.json
new file mode 100644
index 0000000000..61cb012433
--- /dev/null
+++ b/options/locale_next/locale_it-IT.json
@@ -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..."
+  }
+}
diff --git a/options/locale_next/locale_ja-JP.json b/options/locale_next/locale_ja-JP.json
new file mode 100644
index 0000000000..447ee8ae22
--- /dev/null
+++ b/options/locale_next/locale_ja-JP.json
@@ -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": "マイルストーンを検索..."
+  }
+}
diff --git a/options/locale_next/locale_ko-KR.json b/options/locale_next/locale_ko-KR.json
new file mode 100644
index 0000000000..1beaec4627
--- /dev/null
+++ b/options/locale_next/locale_ko-KR.json
@@ -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개의 커밋들을 병합하려함"
+      }
+    }
+  }
+}
diff --git a/options/locale_next/locale_lt.json b/options/locale_next/locale_lt.json
new file mode 100644
index 0000000000..cce5782546
--- /dev/null
+++ b/options/locale_next/locale_lt.json
@@ -0,0 +1,5 @@
+{
+  "search": {
+    "milestone_kind": "Ieškoti gairių..."
+  }
+}
diff --git a/options/locale_next/locale_lv-LV.json b/options/locale_next/locale_lv-LV.json
new file mode 100644
index 0000000000..e338556aab
--- /dev/null
+++ b/options/locale_next/locale_lv-LV.json
@@ -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..."
+  }
+}
diff --git a/options/locale_next/locale_ml-IN.json b/options/locale_next/locale_ml-IN.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/options/locale_next/locale_ml-IN.json
@@ -0,0 +1 @@
+{}
diff --git a/options/locale_next/locale_nb_NO.json b/options/locale_next/locale_nb_NO.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/options/locale_next/locale_nb_NO.json
@@ -0,0 +1 @@
+{}
diff --git a/options/locale_next/locale_nds.json b/options/locale_next/locale_nds.json
new file mode 100644
index 0000000000..a141362816
--- /dev/null
+++ b/options/locale_next/locale_nds.json
@@ -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 …"
+  }
+}
diff --git a/options/locale_next/locale_nl-NL.json b/options/locale_next/locale_nl-NL.json
new file mode 100644
index 0000000000..9bbfc0fecd
--- /dev/null
+++ b/options/locale_next/locale_nl-NL.json
@@ -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..."
+  }
+}
diff --git a/options/locale_next/locale_pl-PL.json b/options/locale_next/locale_pl-PL.json
new file mode 100644
index 0000000000..1f2def3ea0
--- /dev/null
+++ b/options/locale_next/locale_pl-PL.json
@@ -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..."
+  }
+}
diff --git a/options/locale_next/locale_pt-BR.json b/options/locale_next/locale_pt-BR.json
new file mode 100644
index 0000000000..e7758ef1bf
--- /dev/null
+++ b/options/locale_next/locale_pt-BR.json
@@ -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..."
+  }
+}
diff --git a/options/locale_next/locale_pt-PT.json b/options/locale_next/locale_pt-PT.json
new file mode 100644
index 0000000000..475023d461
--- /dev/null
+++ b/options/locale_next/locale_pt-PT.json
@@ -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..."
+  }
+}
diff --git a/options/locale_next/locale_ru-RU.json b/options/locale_next/locale_ru-RU.json
new file mode 100644
index 0000000000..de310505a3
--- /dev/null
+++ b/options/locale_next/locale_ru-RU.json
@@ -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": "Найти этапы..."
+  }
+}
diff --git a/options/locale_next/locale_si-LK.json b/options/locale_next/locale_si-LK.json
new file mode 100644
index 0000000000..25e149f9b8
--- /dev/null
+++ b/options/locale_next/locale_si-LK.json
@@ -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>"
+      }
+    }
+  }
+}
diff --git a/options/locale_next/locale_sk-SK.json b/options/locale_next/locale_sk-SK.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/options/locale_next/locale_sk-SK.json
@@ -0,0 +1 @@
+{}
diff --git a/options/locale_next/locale_sl.json b/options/locale_next/locale_sl.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/options/locale_next/locale_sl.json
@@ -0,0 +1 @@
+{}
diff --git a/options/locale_next/locale_sr-SP.json b/options/locale_next/locale_sr-SP.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/options/locale_next/locale_sr-SP.json
@@ -0,0 +1 @@
+{}
diff --git a/options/locale_next/locale_sv-SE.json b/options/locale_next/locale_sv-SE.json
new file mode 100644
index 0000000000..fe68f161c2
--- /dev/null
+++ b/options/locale_next/locale_sv-SE.json
@@ -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..."
+  }
+}
diff --git a/options/locale_next/locale_tr-TR.json b/options/locale_next/locale_tr-TR.json
new file mode 100644
index 0000000000..ef2cdb6584
--- /dev/null
+++ b/options/locale_next/locale_tr-TR.json
@@ -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..."
+  }
+}
diff --git a/options/locale_next/locale_uk-UA.json b/options/locale_next/locale_uk-UA.json
new file mode 100644
index 0000000000..aded9786b4
--- /dev/null
+++ b/options/locale_next/locale_uk-UA.json
@@ -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": "Шукати віхи..."
+  }
+}
diff --git a/options/locale_next/locale_vi.json b/options/locale_next/locale_vi.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/options/locale_next/locale_vi.json
@@ -0,0 +1 @@
+{}
diff --git a/options/locale_next/locale_yi.json b/options/locale_next/locale_yi.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/options/locale_next/locale_yi.json
@@ -0,0 +1 @@
+{}
diff --git a/options/locale_next/locale_zh-CN.json b/options/locale_next/locale_zh-CN.json
new file mode 100644
index 0000000000..091b3fe609
--- /dev/null
+++ b/options/locale_next/locale_zh-CN.json
@@ -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": "搜索里程碑…"
+  }
+}
diff --git a/options/locale_next/locale_zh-HK.json b/options/locale_next/locale_zh-HK.json
new file mode 100644
index 0000000000..dd7b954559
--- /dev/null
+++ b/options/locale_next/locale_zh-HK.json
@@ -0,0 +1,9 @@
+{
+  "repo": {
+    "pulls": {
+      "merged_title_desc": {
+        "other": "於 %[4]s 將 %[1]d 次代碼提交從 <code>%[2]s</code>合併至 <code>%[3]s</code>"
+      }
+    }
+  }
+}
diff --git a/options/locale_next/locale_zh-TW.json b/options/locale_next/locale_zh-TW.json
new file mode 100644
index 0000000000..4d31a713c0
--- /dev/null
+++ b/options/locale_next/locale_zh-TW.json
@@ -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": "搜尋里程碑..."
+  }
+}
diff --git a/templates/repo/issue/view_title.tmpl b/templates/repo/issue/view_title.tmpl
index 5e30cf3684..936df9d3d2 100644
--- a/templates/repo/issue/view_title.tmpl
+++ b/templates/repo/issue/view_title.tmpl
@@ -63,10 +63,10 @@
 					{{$mergedStr:= DateUtils.TimeSince .Issue.PullRequest.MergedUnix}}
 					{{if .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}}
 						<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}}
 					{{if .MadeUsingAGit}}
 						{{/* TODO: Move documentation link to the instructions at the bottom of the PR, show instructions when clicking label */}}
@@ -79,11 +79,11 @@
 					{{end}}
 				{{else}}
 					{{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}}
 						<span id="pull-desc-display" class="pull-desc">
 							<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>
 					{{end}}
 					{{if .MadeUsingAGit}}
diff --git a/tools/migrate_locales.sh b/tools/migrate_locales.sh
new file mode 100755
index 0000000000..f02fe702cc
--- /dev/null
+++ b/tools/migrate_locales.sh
@@ -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
+