mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-12 22:10:25 +00:00
**Backport:** https://codeberg.org/forgejo/forgejo/pulls/12183 Followup to https://codeberg.org/forgejo/forgejo/pulls/6203 Currently it is logging an error wherever a template is rendered in language that doesn't have all plural strings covered. For example, Esperanto isn't well maintained. Since more plural strings were migrated in v15 to new format, these errors became much more common. However, for all languages but the base one (English) they are completely harmless and just indicate an incomplete translation. However, for base (English) they indicate a bug in either template or en-US.json, which should be still logged as an error. The error is being logged by `LookupPluralByForm`, which is called by `TrPluralStringAllForms` and (`TrPluralString` through `LookupPluralByCount`). I originally intended to just pass log func directly to `LookupPluralByForm` from both, but since `TrPluralString` isn't calling `LookupPluralByForm` directly, it didn't look clean, so I went with passing a flag around instead and implemented logging logic in `LookupPluralByForm` itself. I little concern is with that the so-called "default lang" is configurable, and if it is configured to something with less than 100% completion, it will cause fallback bugs, as well as a lot of logging of this as an error. But this is why changing "default lang" is a bad idea in the first place, and broken fallbacks should be greater concern than junk in the logs. Co-authored-by: 0ko <0ko@noreply.codeberg.org> Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12185 Reviewed-by: Beowulf <beowulf@beocode.eu> Co-authored-by: forgejo-backport-action <forgejo-backport-action@noreply.codeberg.org> Co-committed-by: forgejo-backport-action <forgejo-backport-action@noreply.codeberg.org>
336 lines
8.9 KiB
Go
336 lines
8.9 KiB
Go
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package i18n
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"slices"
|
|
|
|
"forgejo.org/modules/log"
|
|
"forgejo.org/modules/setting"
|
|
"forgejo.org/modules/translation/localeiter"
|
|
"forgejo.org/modules/util"
|
|
)
|
|
|
|
// This file implements the static LocaleStore that will not watch for changes
|
|
|
|
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
|
|
usedPluralForms []PluralFormIndex
|
|
}
|
|
|
|
var _ Locale = (*locale)(nil)
|
|
|
|
type localeStore struct {
|
|
// After initializing has finished, these fields are read-only.
|
|
langNames []string
|
|
langDescs []string
|
|
|
|
localeMap map[string]*locale
|
|
trKeyToIdxMap map[string]int
|
|
|
|
defaultLang string
|
|
}
|
|
|
|
// NewLocaleStore creates a static locale store
|
|
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, pluralRule PluralFormRule, usedPluralForms []PluralFormIndex, source, moreSource []byte) error {
|
|
if _, ok := store.localeMap[langName]; ok {
|
|
return ErrLocaleAlreadyExist
|
|
}
|
|
|
|
store.langNames = append(store.langNames, langName)
|
|
store.langDescs = append(store.langDescs, langDesc)
|
|
|
|
l := &locale{store: store, langName: langName, idxToMsgMap: make(map[int]string), pluralRule: pluralRule, usedPluralForms: usedPluralForms, newStyleMessages: make(map[string]string)}
|
|
store.localeMap[l.langName] = l
|
|
|
|
iniFile, err := setting.NewConfigProviderForLocale(source, moreSource)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to load ini: %w", err)
|
|
}
|
|
|
|
for _, section := range iniFile.Sections() {
|
|
for _, key := range section.Keys() {
|
|
var trKey string
|
|
// see https://codeberg.org/forgejo/discussions/issues/104
|
|
// https://github.com/WeblateOrg/weblate/issues/10831
|
|
// for an explanation of why "common" is an alternative
|
|
if section.Name() == "" || section.Name() == "DEFAULT" || section.Name() == "common" {
|
|
trKey = key.Name()
|
|
} else {
|
|
trKey = section.Name() + "." + key.Name()
|
|
}
|
|
idx, ok := store.trKeyToIdxMap[trKey]
|
|
if !ok {
|
|
idx = len(store.trKeyToIdxMap)
|
|
store.trKeyToIdxMap[trKey] = idx
|
|
}
|
|
l.idxToMsgMap[idx] = key.Value()
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (store *localeStore) AddToLocaleFromJSON(langName string, source []byte) error {
|
|
locale, ok := store.localeMap[langName]
|
|
if !ok {
|
|
return ErrLocaleDoesNotExist
|
|
}
|
|
|
|
return localeiter.IterateMessagesNextContent(source, func(key, pluralForm, value string) error {
|
|
msgKey := key
|
|
if pluralForm != "" {
|
|
msgKey = key + PluralFormSeparator + pluralForm
|
|
}
|
|
locale.newStyleMessages[msgKey] = value
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (l *locale) LookupNewStyleMessage(trKey string) string {
|
|
if msg, ok := l.newStyleMessages[trKey]; ok {
|
|
return msg
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (l *locale) LookupPluralByCount(trKey string, count any, isDefaultLang bool) string {
|
|
n, err := util.ToInt64(count)
|
|
if err != nil {
|
|
log.Error("Invalid plural count '%s'", count)
|
|
return ""
|
|
}
|
|
|
|
pluralForm := l.pluralRule(n)
|
|
return l.LookupPluralByForm(trKey, pluralForm, isDefaultLang)
|
|
}
|
|
|
|
func (l *locale) LookupPluralByForm(trKey string, pluralForm PluralFormIndex, isDefaultLang bool) string {
|
|
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.
|
|
break
|
|
default:
|
|
log.Error("Invalid plural form index %d", pluralForm)
|
|
return ""
|
|
}
|
|
|
|
if result, ok := l.newStyleMessages[trKey+suffix]; ok {
|
|
return result
|
|
}
|
|
|
|
// Severify depends on the lang. A missing string in default lang will affect
|
|
// all translations, while community translations may just be incomplete
|
|
logFunc := log.Debug
|
|
if isDefaultLang {
|
|
logFunc = log.Error
|
|
}
|
|
|
|
logFunc("Missing translation for key `%[1]s`, plural form `%[2]s`", trKey, suffix)
|
|
return ""
|
|
}
|
|
|
|
func (store *localeStore) HasLang(langName string) bool {
|
|
_, ok := store.localeMap[langName]
|
|
return ok
|
|
}
|
|
|
|
func (store *localeStore) ListLangNameDesc() (names, desc []string) {
|
|
return store.langNames, store.langDescs
|
|
}
|
|
|
|
// SetDefaultLang sets default language as a fallback
|
|
func (store *localeStore) SetDefaultLang(lang string) {
|
|
store.defaultLang = lang
|
|
}
|
|
|
|
func (store *localeStore) GetDefaultLang() string {
|
|
return store.defaultLang
|
|
}
|
|
|
|
// Locale returns the locale for the lang or the default language
|
|
func (store *localeStore) Locale(lang string) (Locale, bool) {
|
|
l, found := store.localeMap[lang]
|
|
if !found {
|
|
var ok bool
|
|
l, ok = store.localeMap[store.defaultLang]
|
|
if !ok {
|
|
// no default - return an empty locale
|
|
l = &locale{store: store, idxToMsgMap: make(map[int]string)}
|
|
}
|
|
}
|
|
return l, found
|
|
}
|
|
|
|
func (store *localeStore) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func (l *locale) Language() string {
|
|
return l.langName
|
|
}
|
|
|
|
func (l *locale) TrString(trKey string, trArgs ...any) string {
|
|
format := trKey
|
|
|
|
if msg := l.LookupNewStyleMessage(trKey); msg != "" {
|
|
format = msg
|
|
} else {
|
|
// First fallback: old-style translation
|
|
idx, foundIndex := l.store.trKeyToIdxMap[trKey]
|
|
found := false
|
|
if foundIndex {
|
|
if msg, ok := l.idxToMsgMap[idx]; ok {
|
|
format = msg // use the found translation
|
|
found = true
|
|
}
|
|
}
|
|
|
|
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
|
|
found = true
|
|
} else if foundIndex {
|
|
// 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...)
|
|
if err != nil {
|
|
log.Error("Error whilst formatting %q in %s: %v", trKey, l.langName, err)
|
|
}
|
|
return msg
|
|
}
|
|
|
|
func PrepareArgsForHTML(trArgs ...any) []any {
|
|
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
|
|
break
|
|
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 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.LookupPluralByCount(trKey, count, false)
|
|
|
|
if message == "" {
|
|
if defaultLang, ok := l.store.localeMap[l.store.defaultLang]; ok {
|
|
message = defaultLang.LookupPluralByCount(trKey, count, true)
|
|
}
|
|
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)
|
|
}
|
|
|
|
func (l *locale) TrPluralStringAllForms(trKey string) ([]string, []string) {
|
|
defaultLang, hasDefaultLang := l.store.localeMap[l.store.defaultLang]
|
|
|
|
var fallback []string
|
|
fallback = nil
|
|
|
|
result := make([]string, len(l.usedPluralForms))
|
|
allPresent := true
|
|
|
|
for i, form := range l.usedPluralForms {
|
|
result[i] = l.LookupPluralByForm(trKey, form, false)
|
|
if result[i] == "" {
|
|
allPresent = false
|
|
}
|
|
}
|
|
|
|
if !allPresent {
|
|
if hasDefaultLang {
|
|
fallback = make([]string, len(defaultLang.usedPluralForms))
|
|
for i, form := range defaultLang.usedPluralForms {
|
|
fallback[i] = defaultLang.LookupPluralByForm(trKey, form, true)
|
|
}
|
|
} else {
|
|
log.Error("Plural set for '%s' is incomplete and no fallback language is set.", trKey)
|
|
}
|
|
}
|
|
|
|
return result, fallback
|
|
}
|
|
|
|
// HasKey returns whether a key is present in this locale or not
|
|
func (l *locale) HasKey(trKey string) bool {
|
|
_, ok := l.newStyleMessages[trKey]
|
|
if ok {
|
|
return true
|
|
}
|
|
idx, ok := l.store.trKeyToIdxMap[trKey]
|
|
if !ok {
|
|
return false
|
|
}
|
|
_, ok = l.idxToMsgMap[idx]
|
|
return ok
|
|
}
|