Files
vikunja/pkg/i18n/i18n.go
Martin Lindvik e3695c17c6 feat: add Swedish for language selection (#2248)
The Swedish translations were finished on crowdin recently but I noticed
that the language selection was still missing so I went ahead and added
it.
2026-02-17 14:32:01 +00:00

327 lines
9.3 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package i18n
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"path"
"strings"
"sync"
"code.vikunja.io/api/pkg/log"
)
//go:embed lang/*.json
var localeFS embed.FS
// TranslationStore represents a collection of translation entries
type TranslationStore map[string]string
// Translator manages translations for different languages
type Translator struct {
translations map[string]TranslationStore // language code -> flattened key-value pairs
fallbackLang string
mu sync.RWMutex
}
var translator = &Translator{
translations: make(map[string]TranslationStore),
fallbackLang: "en",
}
var availableLanguages = map[string]bool{
"en": true,
"de-DE": true,
"de-swiss": true,
"ru-RU": true,
"fr-FR": true,
"vi-VN": true,
"it-IT": true,
"cs-CZ": true,
"pl-PL": true,
"nl-NL": true,
"pt-PT": true,
"zh-CN": true,
"zh-TW": true,
"no-NO": true,
"es-ES": true,
"da-DK": true,
"ja-JP": true,
"hu-HU": true,
"ar-SA": true,
"sl-SI": true,
"pt-BR": true,
"hr-HR": true,
"uk-UA": true,
"lt-LT": true,
"bg-BG": true,
"ko-KR": true,
"tr-TR": true,
"fi-FI": true,
"he-IL": true,
"sv-SE": true,
// IMPORTANT: Also add new languages to the frontend
}
// Init initializes the global translator with translation files
func Init() {
dir := "lang"
entries, err := fs.ReadDir(localeFS, dir)
if err != nil {
log.Fatalf("Failed to read embedded translation directory: %v", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
langCode := strings.TrimSuffix(entry.Name(), ".json")
if !availableLanguages[langCode] {
continue
}
filePath := path.Join(dir, entry.Name())
err = translator.loadFile(localeFS, langCode, filePath)
if err != nil {
log.Fatalf("Failed to load translation file %s: %v", filePath, err)
}
}
}
// loadFile loads a translation file for the specified language from the embedded filesystem
func (t *Translator) loadFile(fs embed.FS, langCode, filePath string) error {
data, err := fs.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
var nestedData map[string]interface{}
if err := json.Unmarshal(data, &nestedData); err != nil {
return fmt.Errorf("failed to parse JSON: %w", err)
}
t.mu.Lock()
// Create or get the flattened map for this language
if _, exists := t.translations[langCode]; !exists {
t.translations[langCode] = make(TranslationStore)
}
// Flatten the nested structure
t.flattenTranslations(langCode, nestedData, "")
t.mu.Unlock()
return nil
}
// flattenTranslations recursively flattens the nested translation structure
func (t *Translator) flattenTranslations(langCode string, data map[string]interface{}, prefix string) {
for key, value := range data {
// Build the full key path
fullKey := key
if prefix != "" {
fullKey = prefix + "." + key
}
// If value is a string, add it to the flattened map
if strValue, ok := value.(string); ok {
t.translations[langCode][fullKey] = strValue
} else if mapValue, ok := value.(map[string]interface{}); ok {
// If value is another map, recurse with the updated prefix
t.flattenTranslations(langCode, mapValue, fullKey)
}
}
}
// GetAvailableLanguages returns a list of available language codes
func GetAvailableLanguages() []string {
translator.mu.RLock()
defer translator.mu.RUnlock()
languages := make([]string, 0, len(translator.translations))
for lang := range translator.translations {
languages = append(languages, lang)
}
return languages
}
// T returns the translation for the specified key using dot notation in the specified language
func T(lang, key string, params ...any) string {
translator.mu.RLock()
defer translator.mu.RUnlock()
// Try requested language
if langMap, exists := translator.translations[lang]; exists {
if translation, found := langMap[key]; found {
if len(params) > 0 {
return fmt.Sprintf(translation, params...)
}
return translation
}
}
// Try fallback language if different from requested
if translator.fallbackLang != lang {
if langMap, exists := translator.translations[translator.fallbackLang]; exists {
if translation, found := langMap[key]; found {
if len(params) > 0 {
return fmt.Sprintf(translation, params...)
}
return translation
}
}
}
// Return the key if no translation found
return key
}
func HasLanguage(lang string) bool {
_, exists := translator.translations[lang]
return exists
}
// TP returns the appropriate pluralized translation string for the specified key, count, and language.
// It expects pluralization rules to be encoded in the translation string using '|' as a separator.
// The specific use of the rules depends on the language.
// For Russian:
// - 3 forms: "one | few | many"
// - 4 forms: "zero | one | few | many"
//
// For other languages:
// - For "singular | plural" (2 forms): uses the first for count 1, second otherwise.
// - For "zero | one | other" (3 forms): uses the first for count 0, second for count 1, third otherwise.
//
// If the translation string for the key does not contain '|', it's returned as is.
// If the key is not found, the key itself is returned.
// If the pluralization string is malformed (e.g. contains '|' but not the expected number of parts), the key is returned and a warning is logged.
// This function does NOT perform any variable interpolation (e.g., replacing "{count}" with the actual number).
func TP(lang, key string, count int64, params ...any) string {
translator.mu.RLock()
defer translator.mu.RUnlock()
var rawTranslation string
var found bool
var usedLang string
// Try requested language
if langMap, exists := translator.translations[lang]; exists {
if translation, keyFound := langMap[key]; keyFound {
rawTranslation = translation
found = true
usedLang = lang
}
}
// Try fallback language if different from requested and not found yet
if !found && translator.fallbackLang != lang {
if langMap, exists := translator.translations[translator.fallbackLang]; exists {
if translation, keyFound := langMap[key]; keyFound {
rawTranslation = translation
found = true
usedLang = translator.fallbackLang
}
}
}
if !found {
return key // Return the key if no translation found
}
// If the string doesn't contain a pipe, it's not a pluralized string according to convention.
// Return it as is.
if !strings.Contains(rawTranslation, "|") {
if len(params) > 0 && strings.Contains(rawTranslation, "%") {
return fmt.Sprintf(rawTranslation, params...)
}
return rawTranslation
}
choices := strings.Split(rawTranslation, "|")
numChoices := len(choices)
var selectedChoiceIndex int
switch usedLang {
case "ru-RU":
switch {
case numChoices == 4 && count == 0:
selectedChoiceIndex = 0
case numChoices == 3 || numChoices == 4:
n := count % 100
if n < 0 {
n = -n
}
switch {
case n > 10 && n < 20:
selectedChoiceIndex = 2
case n%10 == 1:
selectedChoiceIndex = 0
case n%10 >= 2 && n%10 <= 4:
selectedChoiceIndex = 1
default:
selectedChoiceIndex = 2
}
if numChoices == 4 {
selectedChoiceIndex++
}
default:
log.Errorf("Malformed plural string for key '%s' in lang '%s': %d parts found (expected 3 or 4). Raw string: '%s'", key, usedLang, numChoices, rawTranslation)
return key
}
default:
switch numChoices {
case 2: // Example: "car | cars" (singular | plural)
// Handles cases like "1 car" vs "0 cars", "2 cars".
if count == 1 {
selectedChoiceIndex = 0
} else {
selectedChoiceIndex = 1
}
case 3: // Example: "no apples | one apple | {count} apples" (zero | one | other)
// Handles cases like "0 apples", "1 apple", "10 apples".
switch count {
case 0:
selectedChoiceIndex = 0
case 1:
selectedChoiceIndex = 1
default:
selectedChoiceIndex = 2
}
default:
// This case is reached if strings.Contains(rawTranslation, "|") is true,
// but the number of resulting parts from split is not 2 or 3.
// This indicates a malformed pluralization string in the translation file.
log.Errorf("Malformed plural string for key '%s' in lang '%s': %d parts found (expected 2 or 3). Raw string: '%s'", key, usedLang, numChoices, rawTranslation)
return key // Return the key to indicate an issue with the translation data.
}
}
selectedChoice := strings.TrimSpace(choices[selectedChoiceIndex])
if len(params) > 0 && strings.Contains(selectedChoice, "%") {
return fmt.Sprintf(selectedChoice, params...)
}
return selectedChoice
}