Язык выбирается в шапке панели, а до первого выбора берётся из настроек браузера. Переведены интерфейс, подписи и описания всех параметров конфигов, ошибки API и служебные сообщения менеджера в консоли. Подписи параметров вынесены из app.js в hints-<язык>.json: их больше четырёхсот на язык, и грузить чужой язык незачем. Сообщения менеджера переводятся на выходе из API по словарю, где ключом служит русская фраза из кода, — вывод самого игрового сервера через него проходит без изменений. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
153 lines
5.3 KiB
Go
153 lines
5.3 KiB
Go
// Package i18n переводит сообщения менеджера на язык веб-панели.
|
|
//
|
|
// Исходный язык кода — русский, он же служит ключом перевода: словарь связывает
|
|
// русскую фразу с английской, а незнакомая фраза остаётся как есть. Благодаря
|
|
// этому через перевод можно безопасно пропускать и вывод игрового сервера — он
|
|
// английский и в словаре не встретится.
|
|
package i18n
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Lang — язык сообщений панели.
|
|
type Lang string
|
|
|
|
const (
|
|
RU Lang = "ru"
|
|
EN Lang = "en"
|
|
// Default — язык, на котором сообщения написаны в коде.
|
|
Default = RU
|
|
)
|
|
|
|
// CookieName — кука, которой панель сообщает выбранный человеком язык.
|
|
const CookieName = "pzm_lang"
|
|
|
|
// Parse разбирает код языка; для незнакомого возвращает язык по умолчанию.
|
|
func Parse(value string) (Lang, bool) {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "ru":
|
|
return RU, true
|
|
case "en":
|
|
return EN, true
|
|
}
|
|
return Default, false
|
|
}
|
|
|
|
// FromRequest определяет язык ответа. Выбор человека (кука, которую ставит
|
|
// панель) важнее списка языков браузера: на сервер могут заходить с чужой
|
|
// машины, а язык панели человек выбирает сам.
|
|
func FromRequest(r *http.Request) Lang {
|
|
if r == nil {
|
|
return Default
|
|
}
|
|
if cookie, err := r.Cookie(CookieName); err == nil {
|
|
if lang, ok := Parse(cookie.Value); ok {
|
|
return lang
|
|
}
|
|
}
|
|
return fromAcceptLanguage(r.Header.Get("Accept-Language"))
|
|
}
|
|
|
|
// fromAcceptLanguage берёт первый понятный язык из заголовка. Веса (q=) не
|
|
// разбираем: браузеры и так перечисляют языки по убыванию предпочтения.
|
|
func fromAcceptLanguage(header string) Lang {
|
|
for _, part := range strings.Split(header, ",") {
|
|
tag := strings.TrimSpace(part)
|
|
if i := strings.IndexByte(tag, ';'); i >= 0 {
|
|
tag = tag[:i]
|
|
}
|
|
if i := strings.IndexByte(tag, '-'); i >= 0 {
|
|
tag = tag[:i]
|
|
}
|
|
if lang, ok := Parse(tag); ok {
|
|
return lang
|
|
}
|
|
}
|
|
return Default
|
|
}
|
|
|
|
// T переводит сообщение на указанный язык. Неизвестное сообщение возвращается
|
|
// без изменений: потерять текст хуже, чем показать его на чужом языке.
|
|
func T(lang Lang, message string) string {
|
|
if lang == Default || lang == "" || message == "" {
|
|
return message
|
|
}
|
|
return translate(message, 0)
|
|
}
|
|
|
|
// maxDepth ограничивает разбор вложенных ошибок: «прочитать X: создать Y: ...».
|
|
const maxDepth = 4
|
|
|
|
func translate(message string, depth int) string {
|
|
if text, ok := messages[message]; ok {
|
|
return text
|
|
}
|
|
if depth >= maxDepth {
|
|
return message
|
|
}
|
|
for _, p := range patterns {
|
|
match := p.re.FindStringSubmatch(message)
|
|
if match == nil {
|
|
continue
|
|
}
|
|
// Подставленные значения переводим тоже: в них попадает вложенная
|
|
// ошибка целиком, и она может быть знакома словарю.
|
|
args := make([]any, 0, len(match)-1)
|
|
for _, group := range match[1:] {
|
|
args = append(args, translate(group, depth+1))
|
|
}
|
|
return fmt.Sprintf(p.en, args...)
|
|
}
|
|
return message
|
|
}
|
|
|
|
// pattern — пара форматов «русский → английский». Значения, подставленные в
|
|
// сообщение при его сборке, вылавливаются группами регулярного выражения.
|
|
type pattern struct {
|
|
re *regexp.Regexp
|
|
en string
|
|
// weight — длина неизменяемой части. Чем она больше, тем конкретнее
|
|
// шаблон, и тем раньше его стоит примерить.
|
|
weight int
|
|
}
|
|
|
|
var patterns []pattern
|
|
|
|
func init() {
|
|
patterns = make([]pattern, 0, len(formats))
|
|
for _, f := range formats {
|
|
patterns = append(patterns, compile(f[0], f[1]))
|
|
}
|
|
sort.SliceStable(patterns, func(i, j int) bool {
|
|
return patterns[i].weight > patterns[j].weight
|
|
})
|
|
}
|
|
|
|
func compile(ru, en string) pattern {
|
|
parts := strings.Split(ru, "%s")
|
|
weight := 0
|
|
for _, part := range parts {
|
|
weight += len(part)
|
|
}
|
|
|
|
var body strings.Builder
|
|
body.WriteString(regexp.QuoteMeta(parts[0]))
|
|
for i := 1; i < len(parts); i++ {
|
|
// Последняя группа жадная: в неё попадает вложенная ошибка целиком,
|
|
// вместе со своими двоеточиями.
|
|
group := "(.+?)"
|
|
if i == len(parts)-1 && parts[i] == "" {
|
|
group = "(.+)"
|
|
}
|
|
body.WriteString(group)
|
|
body.WriteString(regexp.QuoteMeta(parts[i]))
|
|
}
|
|
// (?s) — сообщения бывают многострочными.
|
|
return pattern{re: regexp.MustCompile("(?s)^" + body.String() + "$"), en: en, weight: weight}
|
|
}
|