diff --git a/internal/locale/catalog.go b/internal/locale/catalog.go index c0b22337..7647b782 100644 --- a/internal/locale/catalog.go +++ b/internal/locale/catalog.go @@ -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 } diff --git a/internal/locale/catalog_test.go b/internal/locale/catalog_test.go index 3d7d631c..b69aa6b8 100644 --- a/internal/locale/catalog_test.go +++ b/internal/locale/catalog_test.go @@ -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]) } } } diff --git a/internal/locale/error_test.go b/internal/locale/error_test.go index 9981f594..858d7021 100644 --- a/internal/locale/error_test.go +++ b/internal/locale/error_test.go @@ -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", + }, }, } diff --git a/internal/locale/printer.go b/internal/locale/printer.go index a4d97e0d..238f2372 100644 --- a/internal/locale/printer.go +++ b/internal/locale/printer.go @@ -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...) } } diff --git a/internal/locale/printer_test.go b/internal/locale/printer_test.go index 528f593b..cf10b375 100644 --- a/internal/locale/printer_test.go +++ b/internal/locale/printer_test.go @@ -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"}, + }, }, }