1
0
Fork 0
mirror of https://github.com/miniflux/v2.git synced 2025-08-06 17:41:00 +00:00

refactor(locale): introspect the translation files at load time

Since Go doesn't support unions, and because the translation format is a bit
wacky with the same field having multiple types, we must resort to
introspection to switch between single-item translation (for singular), and
multi-items ones (for plurals).

Previously, introspection was done at runtime, which is not only slow, but will
also only catch typing errors while trying to use the translations. The current
approach is to use a struct with a different field per possible type, and
implement a custom unmarshaller to dispatch the translations to the right one.
This should marginally reduce the memory consumption since interface-boxing
doesn't happen anymore, speed up the translations matching, and enforce proper
typing earlier. This also allows us to remove a bunch of now-useless tests.
This commit is contained in:
Julien Voisin 2025-08-01 04:10:14 +02:00 committed by GitHub
parent f3052eb8ed
commit 181e1341e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 180 additions and 205 deletions

View file

@ -9,7 +9,10 @@ import (
"fmt"
)
type translationDict map[string]any
type translationDict struct {
singulars map[string]string
plurals map[string][]string
}
type catalog map[string]translationDict
var defaultCatalog = make(catalog, len(AvailableLanguages))
@ -21,7 +24,7 @@ func getTranslationDict(language string) (translationDict, error) {
if _, ok := defaultCatalog[language]; !ok {
var err error
if defaultCatalog[language], err = loadTranslationFile(language); err != nil {
return nil, err
return translationDict{}, err
}
}
return defaultCatalog[language], nil
@ -30,21 +33,55 @@ func getTranslationDict(language string) (translationDict, error) {
func loadTranslationFile(language string) (translationDict, error) {
translationFileData, err := translationFiles.ReadFile("translations/" + language + ".json")
if err != nil {
return nil, err
return translationDict{}, err
}
translationMessages, err := parseTranslationMessages(translationFileData)
if err != nil {
return nil, err
return translationDict{}, err
}
return translationMessages, nil
}
func (t *translationDict) UnmarshalJSON(data []byte) error {
var tmpMap map[string]any
err := json.Unmarshal(data, &tmpMap)
if err != nil {
return err
}
m := translationDict{
singulars: make(map[string]string),
plurals: make(map[string][]string),
}
for key, value := range tmpMap {
switch vtype := value.(type) {
case string:
m.singulars[key] = vtype
case []any:
for _, translation := range vtype {
if translationStr, ok := translation.(string); ok {
m.plurals[key] = append(m.plurals[key], translationStr)
} else {
return fmt.Errorf("invalid type for translation in an array: %v", translation)
}
}
default:
return fmt.Errorf("invalid type (%T) for translation: %v", vtype, value)
}
}
*t = m
return nil
}
func parseTranslationMessages(data []byte) (translationDict, error) {
var translationMessages translationDict
if err := json.Unmarshal(data, &translationMessages); err != nil {
return nil, fmt.Errorf(`invalid translation file: %w`, err)
return translationDict{}, fmt.Errorf(`invalid translation file: %w`, err)
}
return translationMessages, nil
}

View file

@ -3,7 +3,9 @@
package locale // import "miniflux.app/v2/internal/locale"
import "testing"
import (
"testing"
)
func TestParserWithInvalidData(t *testing.T) {
_, err := parseTranslationMessages([]byte(`{`))
@ -18,16 +20,12 @@ func TestParser(t *testing.T) {
t.Fatalf(`Unexpected parsing error: %v`, err)
}
if translations == nil {
t.Fatal(`Translations should not be nil`)
}
value, found := translations["k"]
value, found := translations.singulars["k"]
if !found {
t.Fatal(`The translation should contains the defined key`)
t.Fatalf(`The translation %v should contains the defined key`, translations.singulars)
}
if value.(string) != "v" {
if value != "v" {
t.Fatal(`The translation key should contains the defined value`)
}
}
@ -48,20 +46,22 @@ func TestAllKeysHaveValue(t *testing.T) {
t.Fatalf(`Unable to load translation messages for language %q`, language)
}
if len(messages) == 0 {
t.Fatalf(`The language %q doesn't have any messages`, language)
if len(messages.singulars) == 0 {
t.Fatalf(`The language %q doesn't have any messages for singulars`, language)
}
for k, v := range messages {
switch value := v.(type) {
case string:
if value == "" {
t.Errorf(`The key %q for the language %q has an empty string as value`, k, language)
}
case []any:
if len(value) == 0 {
t.Errorf(`The key %q for the language %q has an empty list as value`, k, language)
}
if len(messages.plurals) == 0 {
t.Fatalf(`The language %q doesn't have any messages for plurals`, language)
}
for k, v := range messages.singulars {
if len(v) == 0 {
t.Errorf(`The key %q for singulars for the language %q has an empty list as value`, k, language)
}
}
for k, v := range messages.plurals {
if len(v) == 0 {
t.Errorf(`The key %q for plurals for the language %q has an empty list as value`, k, language)
}
}
}
@ -84,9 +84,14 @@ func TestMissingTranslations(t *testing.T) {
t.Fatalf(`Parsing error for language %q`, language)
}
for key := range references {
if _, found := messages[key]; !found {
t.Errorf(`Translation key %q not found in language %q`, key, language)
for key := range references.singulars {
if _, found := messages.singulars[key]; !found {
t.Errorf(`Translation key %q not found in language %q singulars`, key, language)
}
}
for key := range references.plurals {
if _, found := messages.plurals[key]; !found {
t.Errorf(`Translation key %q not found in language %q plurals`, key, language)
}
}
}
@ -121,11 +126,9 @@ func TestTranslationFilePluralForms(t *testing.T) {
t.Fatalf(`Unable to load translation messages for language %q`, language)
}
for k, v := range messages {
if value, ok := v.([]any); ok {
if len(value) != numberOfPluralFormsPerLanguage[language] {
t.Errorf(`The key %q for the language %q does not have the expected number of plurals, got %d instead of %d`, k, language, len(value), numberOfPluralFormsPerLanguage[language])
}
for k, v := range messages.plurals {
if len(v) != numberOfPluralFormsPerLanguage[language] {
t.Errorf(`The key %q for the language %q does not have the expected number of plurals, got %d instead of %d`, k, language, len(v), numberOfPluralFormsPerLanguage[language])
}
}
}

View file

@ -46,10 +46,14 @@ func TestLocalizedErrorWrapper_Translate(t *testing.T) {
// Set up test catalog
defaultCatalog = catalog{
"en_US": translationDict{
"error.test_key": "Error: %s (code: %d)",
singulars: map[string]string{
"error.test_key": "Error: %s (code: %d)",
},
},
"fr_FR": translationDict{
"error.test_key": "Erreur : %s (code : %d)",
singulars: map[string]string{
"error.test_key": "Erreur : %s (code : %d)",
},
},
}
@ -92,7 +96,9 @@ func TestLocalizedErrorWrapper_TranslateWithEmptyKey(t *testing.T) {
func TestLocalizedErrorWrapper_TranslateWithNoArgs(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"error.simple": "Simple error message",
singulars: map[string]string{
"error.simple": "Simple error message",
},
},
}
@ -128,7 +134,9 @@ func TestNewLocalizedError(t *testing.T) {
func TestLocalizedError_String(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"error.validation": "Validation failed for %s: %s",
singulars: map[string]string{
"error.validation": "Validation failed for %s: %s",
},
},
}
@ -158,7 +166,9 @@ func TestLocalizedError_StringWithMissingTranslation(t *testing.T) {
func TestLocalizedError_Error(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"error.database": "Database connection failed: %s",
singulars: map[string]string{
"error.database": "Database connection failed: %s",
},
},
}
@ -178,10 +188,14 @@ func TestLocalizedError_Error(t *testing.T) {
func TestLocalizedError_Translate(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"error.permission": "Permission denied for %s",
singulars: map[string]string{
"error.permission": "Permission denied for %s",
},
},
"es_ES": translationDict{
"error.permission": "Permiso denegado para %s",
singulars: map[string]string{
"error.permission": "Permiso denegado para %s",
},
},
}
@ -212,10 +226,14 @@ func TestLocalizedError_Translate(t *testing.T) {
func TestLocalizedError_TranslateWithNoArgs(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"error.generic": "An error occurred",
singulars: map[string]string{
"error.generic": "An error occurred",
},
},
"de_DE": translationDict{
"error.generic": "Ein Fehler ist aufgetreten",
singulars: map[string]string{
"error.generic": "Ein Fehler ist aufgetreten",
},
},
}
@ -239,7 +257,9 @@ func TestLocalizedError_TranslateWithNoArgs(t *testing.T) {
func TestLocalizedError_TranslateWithComplexArgs(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"error.complex": "Error %d: %s occurred at %s with severity %s",
singulars: map[string]string{
"error.complex": "Error %d: %s occurred at %s with severity %s",
},
},
}

View file

@ -17,10 +17,8 @@ func NewPrinter(language string) *Printer {
func (p *Printer) Print(key string) string {
if dict, err := getTranslationDict(p.language); err == nil {
if str, ok := dict[key]; ok {
if translation, ok := str.(string); ok {
return translation
}
if str, ok := dict.singulars[key]; ok {
return str
}
}
return key
@ -31,10 +29,8 @@ func (p *Printer) Printf(key string, args ...any) string {
translation := key
if dict, err := getTranslationDict(p.language); err == nil {
if str, ok := dict[key]; ok {
if translation, ok = str.(string); !ok {
translation = key
}
if str, ok := dict.singulars[key]; ok {
translation = str
}
}
@ -42,29 +38,16 @@ func (p *Printer) Printf(key string, args ...any) string {
}
// Plural returns the translation of the given key by using the language plural form.
func (p *Printer) Plural(key string, n int, args ...interface{}) string {
func (p *Printer) Plural(key string, n int, args ...any) string {
dict, err := getTranslationDict(p.language)
if err != nil {
return key
}
if choices, found := dict[key]; found {
var plurals []string
switch v := choices.(type) {
case []string:
plurals = v
case []any:
for _, v := range v {
plurals = append(plurals, fmt.Sprint(v))
}
default:
return key
}
if choices, found := dict.plurals[key]; found {
index := getPluralForm(p.language, n)
if len(plurals) > index {
return fmt.Sprintf(plurals[index], args...)
if len(choices) > index {
return fmt.Sprintf(choices[index], args...)
}
}

View file

@ -17,7 +17,9 @@ func TestPrintfWithMissingLanguage(t *testing.T) {
func TestPrintfWithMissingKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"k": "v",
singulars: map[string]string{
"k": "v",
},
},
}
@ -30,7 +32,9 @@ func TestPrintfWithMissingKey(t *testing.T) {
func TestPrintfWithExistingKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"auth.username": "Login",
singulars: map[string]string{
"auth.username": "Login",
},
},
}
@ -43,10 +47,14 @@ func TestPrintfWithExistingKey(t *testing.T) {
func TestPrintfWithExistingKeyAndPlaceholder(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"key": "Test: %s",
singulars: map[string]string{
"key": "Test: %s",
},
},
"fr_FR": translationDict{
"key": "Test : %s",
singulars: map[string]string{
"key": "Test : %s",
},
},
}
@ -59,10 +67,14 @@ func TestPrintfWithExistingKeyAndPlaceholder(t *testing.T) {
func TestPrintfWithMissingKeyAndPlaceholder(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"auth.username": "Login",
singulars: map[string]string{
"auth.username": "Login",
},
},
"fr_FR": translationDict{
"auth.username": "Identifiant",
singulars: map[string]string{
"auth.username": "Identifiant",
},
},
}
@ -72,22 +84,6 @@ func TestPrintfWithMissingKeyAndPlaceholder(t *testing.T) {
}
}
func TestPrintfWithInvalidValue(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"auth.username": "Login",
},
"fr_FR": translationDict{
"auth.username": true,
},
}
translation := NewPrinter("fr_FR").Printf("auth.username")
if translation != "auth.username" {
t.Errorf(`Wrong translation, got %q`, translation)
}
}
func TestPrintWithMissingLanguage(t *testing.T) {
defaultCatalog = catalog{}
translation := NewPrinter("invalid").Print("missing.key")
@ -100,7 +96,9 @@ func TestPrintWithMissingLanguage(t *testing.T) {
func TestPrintWithMissingKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"existing.key": "value",
singulars: map[string]string{
"existing.key": "value",
},
},
}
@ -113,7 +111,9 @@ func TestPrintWithMissingKey(t *testing.T) {
func TestPrintWithExistingKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"auth.username": "Login",
singulars: map[string]string{
"auth.username": "Login",
},
},
}
@ -126,13 +126,19 @@ func TestPrintWithExistingKey(t *testing.T) {
func TestPrintWithDifferentLanguages(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"greeting": "Hello",
singulars: map[string]string{
"greeting": "Hello",
},
},
"fr_FR": translationDict{
"greeting": "Bonjour",
singulars: map[string]string{
"greeting": "Bonjour",
},
},
"es_ES": translationDict{
"greeting": "Hola",
singulars: map[string]string{
"greeting": "Hola",
},
},
}
@ -153,46 +159,12 @@ func TestPrintWithDifferentLanguages(t *testing.T) {
}
}
func TestPrintWithInvalidTranslationType(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"valid.key": "valid string",
"invalid.key": 12345, // not a string
},
}
printer := NewPrinter("en_US")
// Valid string should work
translation := printer.Print("valid.key")
if translation != "valid string" {
t.Errorf(`Wrong translation for valid key, got %q`, translation)
}
// Invalid type should return the key itself
translation = printer.Print("invalid.key")
if translation != "invalid.key" {
t.Errorf(`Wrong translation for invalid key, got %q`, translation)
}
}
func TestPrintWithNilTranslation(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"nil.key": nil,
},
}
translation := NewPrinter("en_US").Print("nil.key")
if translation != "nil.key" {
t.Errorf(`Wrong translation for nil value, got %q`, translation)
}
}
func TestPrintWithEmptyKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"": "empty key translation",
singulars: map[string]string{
"": "empty key translation",
},
},
}
@ -205,7 +177,9 @@ func TestPrintWithEmptyKey(t *testing.T) {
func TestPrintWithEmptyTranslation(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"empty.value": "",
singulars: map[string]string{
"empty.value": "",
},
},
}
@ -218,10 +192,14 @@ func TestPrintWithEmptyTranslation(t *testing.T) {
func TestPluralWithDefaultRule(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"number_of_users": []string{"%d user (%s)", "%d users (%s)"},
plurals: map[string][]string{
"number_of_users": {"%d user (%s)", "%d users (%s)"},
},
},
"fr_FR": translationDict{
"number_of_users": []string{"%d utilisateur (%s)", "%d utilisateurs (%s)"},
plurals: map[string][]string{
"number_of_users": {"%d utilisateur (%s)", "%d utilisateurs (%s)"},
},
},
}
@ -242,10 +220,14 @@ func TestPluralWithDefaultRule(t *testing.T) {
func TestPluralWithRussianRule(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"time_elapsed.years": []string{"%d year", "%d years"},
plurals: map[string][]string{
"time_elapsed.years": {"%d year", "%d years"},
},
},
"ru_RU": translationDict{
"time_elapsed.years": []string{"%d год назад", "%d года назад", "%d лет назад"},
plurals: map[string][]string{
"time_elapsed.years": {"%d год назад", "%d года назад", "%d лет назад"},
},
},
}
@ -273,7 +255,9 @@ func TestPluralWithRussianRule(t *testing.T) {
func TestPluralWithMissingTranslation(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"number_of_users": []string{"%d user (%s)", "%d users (%s)"},
plurals: map[string][]string{
"number_of_users": {"%d user (%s)", "%d users (%s)"},
},
},
"fr_FR": translationDict{},
}
@ -284,22 +268,6 @@ func TestPluralWithMissingTranslation(t *testing.T) {
}
}
func TestPluralWithInvalidValues(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"number_of_users": []string{"%d user (%s)", "%d users (%s)"},
},
"fr_FR": translationDict{
"number_of_users": "must be a slice",
},
}
translation := NewPrinter("fr_FR").Plural("number_of_users", 2)
expected := "number_of_users"
if translation != expected {
t.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)
}
}
func TestPluralWithMissingLanguage(t *testing.T) {
defaultCatalog = catalog{}
translation := NewPrinter("invalid_language").Plural("test.key", 2)
@ -309,63 +277,21 @@ func TestPluralWithMissingLanguage(t *testing.T) {
}
}
func TestPluralWithAnySliceType(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"test.key": []any{"%d item", "%d items"},
},
}
printer := NewPrinter("en_US")
translation := printer.Plural("test.key", 1, 1)
expected := "1 item"
if translation != expected {
t.Errorf(`Wrong translation for singular, got %q instead of %q`, translation, expected)
}
translation = printer.Plural("test.key", 2, 2)
expected = "2 items"
if translation != expected {
t.Errorf(`Wrong translation for plural, got %q instead of %q`, translation, expected)
}
}
func TestPluralWithMixedAnySliceTypes(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"mixed.key": []any{"single: %s", "multiple: %s", "many: %s"},
},
}
printer := NewPrinter("en_US")
// Test first element (should convert first any element to string)
translation := printer.Plural("mixed.key", 0, "test") // n=0 uses index 0
expected := "single: test"
if translation != expected {
t.Errorf(`Wrong translation for index 0, got %q instead of %q`, translation, expected)
}
// Test second element (should use plural form)
translation = printer.Plural("mixed.key", 2, "items") // plural form for default language
expected = "multiple: items"
if translation != expected {
t.Errorf(`Wrong translation for index 1, got %q instead of %q`, translation, expected)
}
}
func TestPluralWithIndexOutOfBounds(t *testing.T) {
defaultCatalog = catalog{
"test_lang": translationDict{
"limited.key": []string{"only one form"},
plurals: map[string][]string{
"limited.key": {"only one form"},
},
},
}
// Force a scenario where getPluralForm might return an index >= len(plurals)
// We'll create a scenario with Czech language rules
defaultCatalog["cs_CZ"] = translationDict{
"limited.key": []string{"one form only"}, // Only one form, but Czech has 3 plural forms
plurals: map[string][]string{
"limited.key": {"one form only"}, // Only one form, but Czech has 3 plural forms
},
}
printer := NewPrinter("cs_CZ")
@ -380,13 +306,19 @@ func TestPluralWithIndexOutOfBounds(t *testing.T) {
func TestPluralWithVariousLanguageRules(t *testing.T) {
defaultCatalog = catalog{
"ar_AR": translationDict{
"items": []string{"no items", "one item", "two items", "few items", "many items", "other items"},
plurals: map[string][]string{
"items": {"no items", "one item", "two items", "few items", "many items", "other items"},
},
},
"pl_PL": translationDict{
"files": []string{"one file", "few files", "many files"},
plurals: map[string][]string{
"files": {"one file", "few files", "many files"},
},
},
"ja_JP": translationDict{
"photos": []string{"photos"},
plurals: map[string][]string{
"photos": {"photos"},
},
},
}