mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-04-24 22:25:15 +00:00
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.
327 lines
9.3 KiB
Go
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
|
|
}
|