ci / release (push) Successful in 5m5s
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci / checks (push) Successful in 4m41s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 7m14s
The remaining editor strings and undo step names; component, field, enum, setting, diagnostic and Script API texts under schema.*, enum.*, setting.*, diag.* and api.*; the launcher, the in-game Settings View with a Language setting, the CLI and aego schema show --lang. Every documentation page has a .ru.md translation; the site has a full Russian tree with hreflang, a language switch and ru/llms.txt; aego docs check --lang ru. New doc.tutorial and doc.recipe icons. Fix: an animator restored by a rewind prepares its controller before the replayed step.
629 lines
15 KiB
Go
629 lines
15 KiB
Go
// Package i18n translates the editor, the launcher and the CLI; English lives in the code next to each key (D348).
|
|
package i18n
|
|
|
|
import (
|
|
"embed"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"aego/format/aetext"
|
|
)
|
|
|
|
// Languages the editor ships catalogs for; "auto" picks one from the OS.
|
|
const (
|
|
English = "en"
|
|
Russian = "ru"
|
|
Auto = "auto"
|
|
)
|
|
|
|
// Diagnostic codes of the translation layer, reported in dev mode.
|
|
const (
|
|
MissingKey = "I18N001"
|
|
UnusedKey = "I18N002"
|
|
BadPlaceholder = "I18N003"
|
|
)
|
|
|
|
// Form is a CLDR plural category.
|
|
type Form uint8
|
|
|
|
const (
|
|
Other Form = iota
|
|
One
|
|
Few
|
|
Many
|
|
)
|
|
|
|
var formNames = [...]string{"other", "one", "few", "many"}
|
|
|
|
func (f Form) String() string { return formNames[f] }
|
|
|
|
// Message is one translated entry: a plain text, or one text per plural form.
|
|
type Message struct {
|
|
Text string
|
|
Plural map[Form]string
|
|
Context string
|
|
}
|
|
|
|
// Catalog holds the messages of one language.
|
|
type Catalog struct {
|
|
Lang string
|
|
Msgs map[string]Message
|
|
// Order keeps the keys in file order for tools that rewrite catalogs.
|
|
Order []string
|
|
}
|
|
|
|
// Parse reads a catalog: messages version=1 lang=ru { msg "key" { text="…" } msg "n" { one="…" few="…" many="…" } }.
|
|
func Parse(data []byte, name string) (*Catalog, error) {
|
|
doc, err := aetext.Parse(data, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if doc.Head != "messages" {
|
|
return nil, fmt.Errorf("%s: want a messages block, got %q", name, doc.Head)
|
|
}
|
|
lang, _ := doc.Attr("lang")
|
|
c := &Catalog{Lang: lang.Str, Msgs: map[string]Message{}}
|
|
if c.Lang == "" {
|
|
c.Lang = lang.Ident
|
|
}
|
|
if c.Lang == "" {
|
|
return nil, fmt.Errorf("%s: messages has no lang", name)
|
|
}
|
|
for _, b := range doc.Blocks {
|
|
if b.Head != "msg" {
|
|
continue
|
|
}
|
|
if b.Label == "" {
|
|
return nil, fmt.Errorf("%s:%d: msg without a key", name, b.Pos.Line)
|
|
}
|
|
m := Message{}
|
|
for _, f := range b.Fields {
|
|
switch f.Name {
|
|
case "text":
|
|
m.Text = f.Value.Str
|
|
case "context":
|
|
m.Context = f.Value.Str
|
|
case "one", "few", "many", "other":
|
|
if m.Plural == nil {
|
|
m.Plural = map[Form]string{}
|
|
}
|
|
m.Plural[formOf(f.Name)] = f.Value.Str
|
|
default:
|
|
return nil, fmt.Errorf("%s:%d: unknown field %q in msg %q", name, f.Pos.Line, f.Name, b.Label)
|
|
}
|
|
}
|
|
if _, dup := c.Msgs[b.Label]; dup {
|
|
return nil, fmt.Errorf("%s:%d: msg %q declared twice", name, b.Pos.Line, b.Label)
|
|
}
|
|
c.Msgs[b.Label] = m
|
|
c.Order = append(c.Order, b.Label)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func formOf(s string) Form {
|
|
for i, n := range formNames {
|
|
if n == s {
|
|
return Form(i)
|
|
}
|
|
}
|
|
return Other
|
|
}
|
|
|
|
// Write renders a catalog in key order.
|
|
func Write(c *Catalog) []byte {
|
|
root := &aetext.Node{Head: "messages", Attrs: []aetext.Field{{Name: "version", Value: aetext.Int(1)}, {Name: "lang", Value: aetext.Ident(c.Lang)}}}
|
|
keys := c.Order
|
|
if len(keys) != len(c.Msgs) {
|
|
keys = make([]string, 0, len(c.Msgs))
|
|
for k := range c.Msgs {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
}
|
|
for _, k := range keys {
|
|
m := c.Msgs[k]
|
|
n := &aetext.Node{Head: "msg", Label: k}
|
|
if m.Context != "" {
|
|
n.Fields = append(n.Fields, aetext.Field{Name: "context", Value: aetext.String(m.Context)})
|
|
}
|
|
if m.Plural == nil {
|
|
n.Fields = append(n.Fields, aetext.Field{Name: "text", Value: aetext.String(m.Text)})
|
|
}
|
|
for _, f := range []Form{One, Few, Many, Other} {
|
|
if v, ok := m.Plural[f]; ok {
|
|
n.Fields = append(n.Fields, aetext.Field{Name: f.String(), Value: aetext.String(v)})
|
|
}
|
|
}
|
|
root.Blocks = append(root.Blocks, n)
|
|
}
|
|
return aetext.Write(root)
|
|
}
|
|
|
|
// PluralForm is the CLDR category of an integer count in a language.
|
|
func PluralForm(lang string, n int64) Form {
|
|
if n < 0 {
|
|
n = -n
|
|
}
|
|
switch lang {
|
|
case Russian:
|
|
switch m10, m100 := n%10, n%100; {
|
|
case m10 == 1 && m100 != 11:
|
|
return One
|
|
case m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14):
|
|
return Few
|
|
default:
|
|
return Many
|
|
}
|
|
}
|
|
if n == 1 {
|
|
return One
|
|
}
|
|
return Other
|
|
}
|
|
|
|
type argKind uint8
|
|
|
|
const (
|
|
argStr argKind = iota
|
|
argInt
|
|
argFloat
|
|
)
|
|
|
|
// Arg is a typed value for a {name} placeholder.
|
|
type Arg struct {
|
|
name string
|
|
kind argKind
|
|
s string
|
|
i int64
|
|
f float64
|
|
prec int
|
|
}
|
|
|
|
// Args is the list of placeholder values of one message.
|
|
type Args []Arg
|
|
|
|
func Str(name, v string) Arg { return Arg{name: name, kind: argStr, s: v} }
|
|
func Int(name string, v int64) Arg { return Arg{name: name, kind: argInt, i: v} }
|
|
func Float(name string, v float64, prec int) Arg {
|
|
return Arg{name: name, kind: argFloat, f: v, prec: prec}
|
|
}
|
|
|
|
func (a Arg) text() string {
|
|
switch a.kind {
|
|
case argInt:
|
|
return strconv.FormatInt(a.i, 10)
|
|
case argFloat:
|
|
return strconv.FormatFloat(a.f, 'f', a.prec, 64)
|
|
}
|
|
return a.s
|
|
}
|
|
|
|
// Problem is a translation diagnostic seen at run time, in dev mode.
|
|
type Problem struct {
|
|
Code string
|
|
Key string
|
|
Message string
|
|
}
|
|
|
|
// Bundle holds the catalogs of every language and the language in use.
|
|
type Bundle struct {
|
|
mu sync.Mutex
|
|
cats map[string]*Catalog
|
|
lang atomic.Pointer[string]
|
|
cur atomic.Pointer[map[string]Message]
|
|
dev atomic.Bool
|
|
seen map[string]bool
|
|
loaded map[string]bool
|
|
problems []Problem
|
|
used map[string]bool
|
|
}
|
|
|
|
func NewBundle() *Bundle {
|
|
b := &Bundle{cats: map[string]*Catalog{}, seen: map[string]bool{}, used: map[string]bool{}, loaded: map[string]bool{}}
|
|
en := English
|
|
b.lang.Store(&en)
|
|
return b
|
|
}
|
|
|
|
//go:embed messages/*.aetext data/*.aetext
|
|
var messages embed.FS
|
|
|
|
var def = engineBundle()
|
|
|
|
func engineBundle() *Bundle {
|
|
b := NewBundle()
|
|
if err := b.LoadOnce("engine", messages, "messages", ""); err != nil {
|
|
panic("i18n: " + err.Error())
|
|
}
|
|
if err := b.LoadOnce("engine data", messages, "data", ""); err != nil {
|
|
panic("i18n: " + err.Error())
|
|
}
|
|
return b
|
|
}
|
|
|
|
// Default is the bundle the editor, the launcher and the CLI translate through.
|
|
func Default() *Bundle { return def }
|
|
|
|
// Add merges a catalog; a key the bundle already has keeps its first text, a key outside namespace is an error.
|
|
func (b *Bundle) Add(c *Catalog, namespace string) error {
|
|
if namespace != "" {
|
|
for _, k := range c.Order {
|
|
if !strings.HasPrefix(k, namespace+".") {
|
|
return fmt.Errorf("%s catalog: key %q is outside %s.*", c.Lang, k, namespace)
|
|
}
|
|
}
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
dst := b.cats[c.Lang]
|
|
if dst == nil {
|
|
dst = &Catalog{Lang: c.Lang, Msgs: map[string]Message{}}
|
|
b.cats[c.Lang] = dst
|
|
}
|
|
msgs := make(map[string]Message, len(dst.Msgs)+len(c.Msgs))
|
|
for k, m := range dst.Msgs {
|
|
msgs[k] = m
|
|
}
|
|
for _, k := range c.Order {
|
|
if _, dup := msgs[k]; dup {
|
|
continue
|
|
}
|
|
msgs[k] = c.Msgs[k]
|
|
dst.Order = append(dst.Order, k)
|
|
}
|
|
dst.Msgs = msgs
|
|
if *b.lang.Load() == c.Lang {
|
|
b.cur.Store(&msgs)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Load reads every <lang>.aetext of a directory into the bundle.
|
|
func (b *Bundle) Load(fsys fs.FS, dir, namespace string) error {
|
|
files, err := fs.Glob(fsys, path.Join(dir, "*.aetext"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, f := range files {
|
|
data, err := fs.ReadFile(fsys, f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c, err := Parse(data, f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := b.Add(c, namespace); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LoadOnce is Load for a source named owner, skipped when that owner is already loaded.
|
|
func (b *Bundle) LoadOnce(owner string, fsys fs.FS, dir, namespace string) error {
|
|
b.mu.Lock()
|
|
done := b.loaded[owner]
|
|
b.loaded[owner] = true
|
|
b.mu.Unlock()
|
|
if done || fsys == nil {
|
|
return nil
|
|
}
|
|
return b.Load(fsys, dir, namespace)
|
|
}
|
|
|
|
// SetLanguage switches every following Tr; an unknown language uses English.
|
|
func (b *Bundle) SetLanguage(lang string) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.lang.Store(&lang)
|
|
if c := b.cats[lang]; c != nil && lang != English {
|
|
b.cur.Store(&c.Msgs)
|
|
} else {
|
|
b.cur.Store(nil)
|
|
}
|
|
}
|
|
|
|
func (b *Bundle) Language() string { return *b.lang.Load() }
|
|
|
|
// SetDev turns on the run-time diagnostics.
|
|
func (b *Bundle) SetDev(on bool) { b.dev.Store(on) }
|
|
|
|
// Languages lists the languages with a catalog, English first.
|
|
func (b *Bundle) Languages() []string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
out := []string{English}
|
|
for l := range b.cats {
|
|
if l != English {
|
|
out = append(out, l)
|
|
}
|
|
}
|
|
sort.Strings(out[1:])
|
|
return out
|
|
}
|
|
|
|
// Catalog returns the merged catalog of a language.
|
|
func (b *Bundle) Catalog(lang string) *Catalog {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.cats[lang]
|
|
}
|
|
|
|
func (b *Bundle) lookup(key string) (Message, bool) {
|
|
msgs := b.cur.Load()
|
|
if msgs == nil {
|
|
return Message{}, false
|
|
}
|
|
m, ok := (*msgs)[key]
|
|
if b.dev.Load() {
|
|
b.mu.Lock()
|
|
b.used[key] = true
|
|
if !ok && !b.seen[key] {
|
|
b.seen[key] = true
|
|
b.problems = append(b.problems, Problem{Code: MissingKey, Key: key, Message: "no " + *b.lang.Load() + " text for " + key})
|
|
}
|
|
b.mu.Unlock()
|
|
}
|
|
return m, ok
|
|
}
|
|
|
|
// Tr is the text of key in the current language, or en.
|
|
func (b *Bundle) Tr(key, en string, args ...Arg) string {
|
|
text := en
|
|
if m, ok := b.lookup(key); ok && m.Text != "" {
|
|
text = m.Text
|
|
}
|
|
if len(args) == 0 && strings.IndexByte(text, '{') < 0 {
|
|
return text
|
|
}
|
|
return b.fill(key, text, args)
|
|
}
|
|
|
|
// TrN picks the plural form of n; {n} in the text is the count.
|
|
func (b *Bundle) TrN(key string, n int64, one, other string, args ...Arg) string {
|
|
text := other
|
|
if PluralForm(English, n) == One {
|
|
text = one
|
|
}
|
|
lang := b.Language()
|
|
if m, ok := b.lookup(key); ok && m.Plural != nil {
|
|
f := PluralForm(lang, n)
|
|
if t, ok := m.Plural[f]; ok {
|
|
text = t
|
|
} else if t, ok := m.Plural[Other]; ok {
|
|
text = t
|
|
}
|
|
}
|
|
return b.fill(key, text, append(args, Int("n", n)))
|
|
}
|
|
|
|
func (b *Bundle) fill(key, text string, args Args) string {
|
|
var sb strings.Builder
|
|
for {
|
|
i := strings.IndexByte(text, '{')
|
|
if i < 0 {
|
|
sb.WriteString(text)
|
|
return sb.String()
|
|
}
|
|
j := strings.IndexByte(text[i:], '}')
|
|
if j < 0 {
|
|
sb.WriteString(text)
|
|
return sb.String()
|
|
}
|
|
name := text[i+1 : i+j]
|
|
sb.WriteString(text[:i])
|
|
if !isName(name) {
|
|
sb.WriteByte('{')
|
|
text = text[i+1:]
|
|
continue
|
|
}
|
|
found := false
|
|
for _, a := range args {
|
|
if a.name == name {
|
|
sb.WriteString(a.text())
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
sb.WriteString(text[i : i+j+1])
|
|
b.report(BadPlaceholder, key, "placeholder {"+name+"} has no value")
|
|
}
|
|
text = text[i+j+1:]
|
|
}
|
|
}
|
|
|
|
func (b *Bundle) report(code, key, msg string) {
|
|
if !b.dev.Load() {
|
|
return
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.seen[code+key] {
|
|
return
|
|
}
|
|
b.seen[code+key] = true
|
|
b.problems = append(b.problems, Problem{Code: code, Key: key, Message: msg})
|
|
}
|
|
|
|
// Problems are the run-time diagnostics so far.
|
|
func (b *Bundle) Problems() []Problem {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return append([]Problem(nil), b.problems...)
|
|
}
|
|
|
|
// TakeProblems returns the run-time diagnostics and forgets them, so each session reports its own.
|
|
func (b *Bundle) TakeProblems() []Problem {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
out := b.problems
|
|
b.problems = nil
|
|
return out
|
|
}
|
|
|
|
// Unused lists the keys of a language that no Tr asked for while dev mode was on.
|
|
func (b *Bundle) Unused(lang string) []Problem {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
var out []Problem
|
|
if c := b.cats[lang]; c != nil {
|
|
for _, k := range c.Order {
|
|
if !b.used[k] {
|
|
out = append(out, Problem{Code: UnusedKey, Key: k, Message: k + " is never used"})
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Placeholders lists the {names} of a text, for checking a translation against its English source.
|
|
func Placeholders(text string) []string {
|
|
var out []string
|
|
for {
|
|
i := strings.IndexByte(text, '{')
|
|
if i < 0 {
|
|
return out
|
|
}
|
|
j := strings.IndexByte(text[i:], '}')
|
|
if j < 0 {
|
|
return out
|
|
}
|
|
if name := text[i+1 : i+j]; isName(name) {
|
|
out = append(out, name)
|
|
text = text[i+j+1:]
|
|
} else {
|
|
text = text[i+1:]
|
|
}
|
|
}
|
|
}
|
|
|
|
func isName(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for i, r := range s {
|
|
if !(r == '_' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || i > 0 && r >= '0' && r <= '9') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Tr translates through the default bundle.
|
|
func Tr(key, en string, args ...Arg) string { return def.Tr(key, en, args...) }
|
|
|
|
// TrN translates a count through the default bundle.
|
|
func TrN(key string, n int64, one, other string, args ...Arg) string {
|
|
return def.TrN(key, n, one, other, args...)
|
|
}
|
|
|
|
// Resolve is the language for a preference: en or ru as given, or for auto the first OS locale with a catalog.
|
|
func Resolve(pref string, locales, have []string) string {
|
|
if pref != Auto && pref != "" {
|
|
return pref
|
|
}
|
|
for _, l := range locales {
|
|
lang, _, _ := strings.Cut(strings.ToLower(strings.ReplaceAll(l, "_", "-")), "-")
|
|
for _, h := range have {
|
|
if h == lang {
|
|
return h
|
|
}
|
|
}
|
|
}
|
|
return English
|
|
}
|
|
|
|
// Text is a key with its English text, declared where a label is defined and translated where it is drawn.
|
|
type Text struct {
|
|
Key string
|
|
En string
|
|
}
|
|
|
|
// T declares a translatable text; tools/i18n extract reads its literals.
|
|
func T(key, en string) Text { return Text{Key: key, En: en} }
|
|
|
|
// String is the text in the current language; a Text with no key is its English as is.
|
|
func (t Text) String() string {
|
|
if t.Key == "" {
|
|
return t.En
|
|
}
|
|
return def.Tr(t.Key, t.En)
|
|
}
|
|
|
|
// Lookup translates a key built at run time; tools/i18n does not extract it, so its key must come from a literal elsewhere.
|
|
func Lookup(key, en string) string { return def.Tr(key, en) }
|
|
|
|
// Apply sets the default bundle's language for a preference; AEGO_LANG overrides it, and auto asks locales.
|
|
func Apply(pref string, locales func() []string) string {
|
|
if v := os.Getenv("AEGO_LANG"); v != "" {
|
|
pref = v
|
|
}
|
|
var ls []string
|
|
if (pref == Auto || pref == "") && locales != nil {
|
|
ls = locales()
|
|
}
|
|
lang := Resolve(pref, ls, def.Languages())
|
|
def.SetLanguage(lang)
|
|
return lang
|
|
}
|
|
|
|
var notes struct {
|
|
mu sync.Mutex
|
|
en map[string]string
|
|
}
|
|
|
|
// Note is Tr for a message that is also logged: the log keeps English, so EnglishOf maps the shown text back.
|
|
func Note(key, en string, args ...Arg) string {
|
|
t := def.Tr(key, en, args...)
|
|
if def.Language() == English {
|
|
return t
|
|
}
|
|
e := en
|
|
if len(args) > 0 || strings.IndexByte(en, '{') >= 0 {
|
|
e = def.fill(key, en, args)
|
|
}
|
|
notes.mu.Lock()
|
|
if notes.en == nil || len(notes.en) > 256 {
|
|
notes.en = map[string]string{}
|
|
}
|
|
notes.en[t] = e
|
|
notes.mu.Unlock()
|
|
return t
|
|
}
|
|
|
|
// EnglishOf is the English text of a message made by Note, or the text itself.
|
|
func EnglishOf(text string) string {
|
|
notes.mu.Lock()
|
|
defer notes.mu.Unlock()
|
|
if e, ok := notes.en[text]; ok {
|
|
return e
|
|
}
|
|
return text
|
|
}
|
|
|
|
// In is the text of key in lang, or en, without switching the bundle's language.
|
|
func (b *Bundle) In(lang, key, en string) string {
|
|
if lang == English || lang == "" {
|
|
return en
|
|
}
|
|
b.mu.Lock()
|
|
c := b.cats[lang]
|
|
b.mu.Unlock()
|
|
if c != nil {
|
|
if m, ok := c.Msgs[key]; ok && m.Text != "" {
|
|
return m.Text
|
|
}
|
|
}
|
|
return en
|
|
}
|