Files
zeekayandHanzo Dev aa87442fdd Record upstream authorship centrally in NOTICE instead of in every file header
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the
Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in
NOTICE, alongside LICENSE. The header lint accepts any copyright line above the
SPDX line, so it no longer demands an upstream one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:50:35 -07:00

275 lines
6.6 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package translation
import (
"context"
"html/template"
"sort"
"strings"
"sync"
"github.com/hanzoai/git/modules/log"
"github.com/hanzoai/git/modules/options"
"github.com/hanzoai/git/modules/setting"
"github.com/hanzoai/git/modules/translation/i18n"
"github.com/hanzoai/git/modules/util"
"golang.org/x/text/language"
"golang.org/x/text/message"
"golang.org/x/text/number"
)
type contextKey struct{}
var ContextKey any = &contextKey{}
// Locale represents an interface to translation
type Locale interface {
Language() string
TrString(string, ...any) string
Tr(key string, args ...any) template.HTML
TrN(cnt any, key1, keyN string, args ...any) template.HTML
PrettyNumber(v any) string
}
// LangType represents a lang type
type LangType struct {
Lang, Name string // these fields are used directly in templates: {{range .AllLangs}}{{.Lang}}{{.Name}}{{end}}
}
var (
lock *sync.RWMutex
allLangs []*LangType
allLangMap map[string]*LangType
matcher language.Matcher
supportedTags []language.Tag
)
// AllLangs returns all supported languages sorted by name
func AllLangs() []*LangType {
return allLangs
}
// InitLocales loads the locales
func InitLocales(ctx context.Context) {
if lock != nil {
lock.Lock()
defer lock.Unlock()
} else if !setting.IsProd && lock == nil {
lock = &sync.RWMutex{}
}
refreshLocales := func() {
i18n.ResetDefaultLocales()
localeNames, err := options.AssetFS().ListFiles("locale", true)
if err != nil {
log.Fatal("Failed to list locale files: %v", err)
}
localeData := make(map[string][]byte, len(localeNames))
for _, name := range localeNames {
if !strings.HasPrefix(name, "locale_") || !strings.HasSuffix(name, ".json") {
continue
}
localeData[name], err = options.Locale(name)
if err != nil {
log.Fatal("Failed to load %s locale file. %v", name, err)
}
}
supportedTags = make([]language.Tag, len(setting.Langs))
for i, lang := range setting.Langs {
supportedTags[i] = language.Raw.Make(lang)
}
matcher = language.NewMatcher(supportedTags)
for i := range setting.Names {
var localeDataBase []byte
if i == 0 && setting.Langs[0] != "en-US" {
// Only en-US has complete translations. When use other language as default, the en-US should still be used as fallback.
localeDataBase = localeData["locale_en-US.json"]
if localeDataBase == nil {
log.Fatal("Failed to load locale_en-US.json file.")
}
}
key := "locale_" + setting.Langs[i] + ".json"
if err = i18n.DefaultLocales.AddLocaleByJSON(setting.Langs[i], setting.Names[i], localeDataBase, localeData[key]); err != nil {
log.Error("Failed to set messages to %s: %v", setting.Langs[i], err)
}
}
if len(setting.Langs) != 0 {
defaultLangName := setting.Langs[0]
if defaultLangName != "en-US" {
log.Info("Use the first locale (%s) in LANGS setting option as default", defaultLangName)
}
i18n.DefaultLocales.SetDefaultLang(defaultLangName)
}
}
refreshLocales()
langs, descs := i18n.DefaultLocales.ListLangNameDesc()
allLangs = make([]*LangType, 0, len(langs))
allLangMap = map[string]*LangType{}
for i, v := range langs {
l := &LangType{v, descs[i]}
allLangs = append(allLangs, l)
allLangMap[v] = l
}
// Sort languages case-insensitive according to their name - needed for the user settings
sort.Slice(allLangs, func(i, j int) bool {
return strings.ToLower(allLangs[i].Name) < strings.ToLower(allLangs[j].Name)
})
if !setting.IsProd {
go options.AssetFS().WatchLocalChanges(ctx, func() {
lock.Lock()
defer lock.Unlock()
refreshLocales()
})
}
}
// Match matches accept languages
func Match(tags ...language.Tag) language.Tag {
_, i, _ := matcher.Match(tags...)
return supportedTags[i]
}
// locale represents the information of localization.
type locale struct {
i18n.Locale
Lang, LangName string // these fields are used directly in templates: ctx.Locale.Lang
msgPrinter *message.Printer
}
var _ Locale = (*locale)(nil)
// NewLocale return a locale
func NewLocale(lang string) Locale {
if lock != nil {
lock.RLock()
defer lock.RUnlock()
}
langName := "unknown"
if l, ok := allLangMap[lang]; ok {
langName = l.Name
} else if len(setting.Langs) > 0 {
lang = setting.Langs[0]
langName = setting.Names[0]
}
i18nLocale, _ := i18n.GetLocale(lang)
l := &locale{
Locale: i18nLocale,
Lang: lang,
LangName: langName,
}
if langTag, err := language.Parse(lang); err != nil {
log.Error("Failed to parse language tag from name %q: %v", l.Lang, err)
l.msgPrinter = message.NewPrinter(language.English)
} else {
l.msgPrinter = message.NewPrinter(langTag)
}
return l
}
func (l *locale) Language() string {
return l.Lang
}
// Language specific rules for translating plural texts
var trNLangRules = map[string]func(int64) int{
// the default rule is "en-US" if a language isn't listed here
"en-US": func(cnt int64) int {
if cnt == 1 {
return 0
}
return 1
},
"lv-LV": func(cnt int64) int {
if cnt%10 == 1 && cnt%100 != 11 {
return 0
}
return 1
},
"ru-RU": func(cnt int64) int {
if cnt%10 == 1 && cnt%100 != 11 {
return 0
}
return 1
},
"zh-CN": func(cnt int64) int {
return 0
},
"zh-HK": func(cnt int64) int {
return 0
},
"zh-TW": func(cnt int64) int {
return 0
},
"fr-FR": func(cnt int64) int {
if cnt > -2 && cnt < 2 {
return 0
}
return 1
},
}
func (l *locale) Tr(s string, args ...any) template.HTML {
return l.TrHTML(s, args...)
}
// TrN returns translated message for plural text translation
func (l *locale) TrN(cnt any, key1, keyN string, args ...any) template.HTML {
var c int64
if t, ok := cnt.(int); ok {
c = int64(t)
} else if t, ok := cnt.(int16); ok {
c = int64(t)
} else if t, ok := cnt.(int32); ok {
c = int64(t)
} else if t, ok := cnt.(int64); ok {
c = t
} else {
return l.Tr(keyN, args...)
}
ruleFunc, ok := trNLangRules[l.Lang]
if !ok {
ruleFunc = trNLangRules["en-US"]
}
if ruleFunc(c) == 0 {
return l.Tr(key1, args...)
}
return l.Tr(keyN, args...)
}
func (l *locale) PrettyNumber(v any) string {
// TODO: this mechanism is not good enough, the complete solution is to switch the translation system to ICU message format
if s, ok := v.(string); ok {
if num, err := util.ToInt64(s); err == nil {
v = num
} else if num, err := util.ToFloat64(s); err == nil {
v = num
}
}
return l.msgPrinter.Sprintf("%v", number.Decimal(v))
}
func init() {
// prepare a default matcher, especially for tests
supportedTags = []language.Tag{language.English}
matcher = language.NewMatcher(supportedTags)
}