Русский и английский языки интерфейса
CI / test (push) Successful in 22s
Release / release (push) Successful in 29s

Язык выбирается в шапке панели, а до первого выбора берётся из настроек
браузера. Переведены интерфейс, подписи и описания всех параметров конфигов,
ошибки API и служебные сообщения менеджера в консоли.

Подписи параметров вынесены из app.js в hints-<язык>.json: их больше четырёхсот
на язык, и грузить чужой язык незачем. Сообщения менеджера переводятся на
выходе из API по словарю, где ключом служит русская фраза из кода, — вывод
самого игрового сервера через него проходит без изменений.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 21:14:21 +03:00
co-authored by Claude Opus 5
parent d5a308ac8d
commit 9a224bc070
19 changed files with 5417 additions and 857 deletions
+190
View File
@@ -0,0 +1,190 @@
package i18n
// Словарь сообщений менеджера. Ключ — русская фраза ровно в том виде, в каком
// она собирается в коде; значение — английский перевод.
//
// В formats оба текста записаны как форматные строки: %s отмечает место, куда
// при сборке сообщения подставилось значение. Порядок значений в переводе
// должен совпадать с оригиналом.
var messages = map[string]string{
// --- Вход и учётные записи ---
"панель уже настроена": "the panel is already set up",
"неверный код первичной настройки": "wrong setup code",
"неверный логин или пароль": "wrong login or password",
"требуется вход": "sign-in required",
"логин не может быть пустым": "the login cannot be empty",
"пароль короче 8 символов": "the password is shorter than 8 characters",
"пароли не совпадают": "the passwords do not match",
"пользователь не найден": "user not found",
"пользователь уже существует": "the user already exists",
"нельзя удалить учётную запись, под которой вы вошли": "you cannot delete the account you are signed in with",
// --- Сервер и задачи ---
"сервер не запущен": "the server is not running",
"сервер уже запущен": "the server is already running",
"сначала остановите игровой сервер": "stop the game server first",
"серверные файлы не найдены — сначала выполните установку": "server files not found — install the server first",
"нет активной задачи": "no active task",
"уже выполняется другая задача": "another task is already running",
"пустая команда": "empty command",
"потоковая отдача не поддерживается": "streaming is not supported",
"штатное завершение": "clean shutdown",
// --- Профили ---
"профиль не найден": "profile not found",
"нельзя удалить активный профиль — сначала переключитесь на другой": "the active profile cannot be deleted — switch to another one first",
"нельзя удалить единственный профиль": "the only profile cannot be deleted",
"путь к конфигу неизвестен": "the config path is unknown",
// --- Бэкапы и обновление ---
"архив не найден": "archive not found",
"обновление из панели недоступно: нет pzmanager-admin или правила sudo": "updating from the panel is unavailable: pzmanager-admin or the sudo rule is missing",
"длительность должна быть строкой вида 90s или числом секунд": "the duration must be a string like 90s or a number of seconds",
// --- Моды ---
"слишком много идентификаторов за один запрос": "too many identifiers in one request",
"не похоже на идентификатор или ссылку на коллекцию": "that does not look like a collection ID or link",
// --- Подсказки и пометки в ответах API ---
"Файл создаётся при первом запуске сервера": "The file is created on the first server start",
"Файл конфигурации появится после первого запуска сервера": "The configuration file appears after the first server start",
"Адрес прослушивания применится после перезапуска pzmanager": "The listen address applies after a pzmanager restart",
// --- Имена задач ---
"Установка/обновление сервера через SteamCMD": "Installing or updating the server via SteamCMD",
"Обновление панели": "Panel update",
"Перезапуск панели": "Panel restart",
// --- Сообщения в консоли панели ---
"Настройки менеджера сохранены": "Manager settings saved",
"Настройки песочницы сохранены. Часть параметров применяется только к новому миру.": "Sandbox settings saved. Some of them only affect a freshly created world.",
"Список модов обновлён. Сервер докачает моды из мастерской при следующем запуске.": "Mod list updated. The server will download the missing mods on its next start.",
"Профиль сохранён": "Profile saved",
"Профиль сохранён. Память и аргументы запуска применятся после перезапуска сервера.": "Profile saved. Memory and launch arguments apply after a server restart.",
"Останавливаю сервер перед бэкапом": "Stopping the server before the backup",
"Делаю страховочный бэкап текущего мира": "Taking a safety backup of the current world",
"Поднимаю сервер обратно": "Bringing the server back up",
"Плановый бэкап пропущен: выполняется другая задача": "Scheduled backup skipped: another task is running",
"Мир ещё не создан — архивирую только конфиги": "The world does not exist yet — archiving the configs only",
"Останавливаю сервер командой quit...": "Stopping the server with the quit command...",
"Сервер не ответил на quit — отправляю SIGTERM": "The server did not answer quit — sending SIGTERM",
"Сервер завис — отправляю SIGKILL": "The server hung — sending SIGKILL",
"Автоперезапуск отменён": "Automatic restart cancelled",
"Посмотрите ошибку выше и запустите сервер вручную.": "Check the error above and start the server manually.",
"Запускаю обновление — оно идёт отдельной службой и переживёт перезапуск панели": "Starting the update — it runs as a separate service and survives the panel restart",
"Подробности: journalctl -u pzmanager-update -f": "Details: journalctl -u pzmanager-update -f",
"Перезапускаю панель": "Restarting the panel",
"обновлением": "the update",
"перезапуском": "the restart",
}
var formats = [][2]string{
// --- Ошибки API ---
{"запрос без заголовка %s", "request without the %s header"},
{"некорректный JSON: %s", "malformed JSON: %s"},
{"конфиг сервера недоступен: %s", "the server config is unavailable: %s"},
{"настройки песочницы недоступны: %s", "the sandbox settings are unavailable: %s"},
// --- Профили ---
{"профиль %s не найден", "profile %s not found"},
{"профиль %s уже существует", "profile %s already exists"},
{"профиль-источник %s не найден", "source profile %s not found"},
{"идентификатор %s недопустим: разрешены латинские буквы, цифры, дефис и подчёркивание (до 32 символов)",
"identifier %s is not allowed: use Latin letters, digits, hyphen and underscore (up to 32 characters)"},
{"у профиля %s ещё нет файлов настроек — сначала запустите его хотя бы раз",
"profile %s has no config files yet — start it at least once first"},
{"скопировать настройки: %s", "copy the settings: %s"},
// --- Память JVM ---
{"объём памяти должен выглядеть как 4g или 8192m, получено %s",
"the memory size must look like 4g or 8192m, got %s"},
{"не разобран объём памяти %s", "could not parse the memory size %s"},
{"начальный размер памяти (%s) больше максимального (%s)",
"the initial memory size (%s) is larger than the maximum (%s)"},
{"начальный размер кучи (%s) больше максимального (%s)",
"the initial heap size (%s) is larger than the maximum (%s)"},
// --- Бэкапы ---
{"недопустимое имя архива: %s", "invalid archive name: %s"},
{"небезопасный путь в архиве: %s", "unsafe path inside the archive: %s"},
{"абсолютный путь в архиве: %s", "absolute path inside the archive: %s"},
{"архив повреждён: %s", "the archive is damaged: %s"},
{"страховочный бэкап не создан, восстановление отменено: %s",
"the safety backup failed, the restore was cancelled: %s"},
// --- Steam и моды ---
{"Steam недоступен: %s", "Steam is unavailable: %s"},
{"Steam ответил %s", "Steam answered %s"},
{"ответ Steam не разобран: %s", "the Steam answer could not be parsed: %s"},
{"коллекция %s не найдена", "collection %s not found"},
{"коллекция %s недоступна — проверьте ссылку и что она открыта всем",
"collection %s is unavailable — check the link and that it is public"},
{"в коллекции %s нет модов", "collection %s has no mods"},
// --- Конфиги ---
{"параметр %s не найден в SandboxVars", "setting %s not found in SandboxVars"},
{"недопустимое значение для %s", "invalid value for %s"},
{"не удалось разобрать строку параметра %s", "could not parse the line of setting %s"},
{"прочитать конфиг %s: %s", "read the config %s: %s"},
{"создать конфиг %s: %s", "create the config %s: %s"},
{"обновить конфиг %s: %s", "update the config %s: %s"},
{"разобрать конфиг %s: %s", "parse the config %s: %s"},
{"прочитать %s: %s", "read %s: %s"},
{"записать %s: %s", "write %s: %s"},
{"создать %s: %s", "create %s: %s"},
{"разобрать %s: %s", "parse %s: %s"},
{"запустить %s: %s", "start %s: %s"},
{"запись в консоль: %s", "write to the console: %s"},
{"не разобрана длительность %s: %s", "could not parse the duration %s: %s"},
{"скачать SteamCMD: %s", "download SteamCMD: %s"},
// --- Имена задач ---
{"Резервное копирование мира: %s", "World backup: %s"},
{"Плановое резервное копирование: %s", "Scheduled world backup: %s"},
{"Восстановление из архива %s", "Restore from the archive %s"},
// --- Сообщения в консоли панели ---
{"Конфиг сервера сохранён (изменений: %s). Изменения вступят в силу после перезапуска.",
"Server config saved (changes: %s). They take effect after a restart."},
{"Создан профиль %s", "Profile %s created"},
{"Настройки профиля %s скопированы в %s", "The settings of profile %s were copied to %s"},
{"Активный профиль: %s", "Active profile: %s"},
{"Профиль %s удалён из панели. Мир и конфиги остались на диске: %s",
"Profile %s removed from the panel. Its world and configs stayed on disk: %s"},
{"Запуск профиля %s: %s %s (pid %s)", "Starting profile %s: %s %s (pid %s)"},
{"Запускаю: %s", "Running: %s"},
{"Сервер остановлен: %s", "Server stopped: %s"},
{"Автоперезапуск через %s (можно отменить кнопкой «Остановить»)",
"Automatic restart in %s (the Stop button cancels it)"},
{"Автоперезапуск не удался: %s", "Automatic restart failed: %s"},
{"Сервер падает сразу после запуска %s раза подряд — автоперезапуск остановлен. ",
"The server has crashed right after start %s times in a row — automatic restart is off. "},
{"Не удалось выставить границы памяти JVM: %s", "Could not set the JVM memory limits: %s"},
{"Не удалось выставить объём памяти JVM: %s", "Could not set the JVM memory size: %s"},
{"Память JVM: от %s до %s", "JVM memory: from %s to %s"},
{"Лимит памяти JVM выставлен: %s", "JVM memory limit set: %s"},
{"Не удалось отправить quit: %s", "Could not send quit: %s"},
{"SIGTERM не доставлен: %s", "SIGTERM was not delivered: %s"},
{"Остановка не удалась: %s", "Stopping failed: %s"},
{"Остановить сервер не удалось: %s", "The server could not be stopped: %s"},
{"Перезапуск не удался: %s", "Restart failed: %s"},
{"Не удалось запустить сервер: %s", "Could not start the server: %s"},
{"Останавливаю игровой сервер перед %s", "Stopping the game server before %s"},
{"Плановый бэкап не запущен: %s", "The scheduled backup did not start: %s"},
// --- Бэкапы: ход работы ---
{"Создаю архив %s (профиль %s)", "Creating the archive %s (profile %s)"},
{"Архив готов: %s (%s, файлов: %s)", "Archive ready: %s (%s, files: %s)"},
{"Восстанавливаю %s в %s", "Restoring %s into %s"},
{"Восстановлено файлов: %s", "Files restored: %s"},
{"Удалён старый архив %s", "Old archive %s deleted"},
{"Ротация не удалась: %s", "Rotation failed: %s"},
{"Пропускаю %s: %s", "Skipping %s: %s"},
{"Не прочитан %s: %s", "Not read %s: %s"},
// --- SteamCMD ---
{"SteamCMD найден: %s", "SteamCMD found: %s"},
{"SteamCMD не найден, скачиваю в %s", "SteamCMD not found, downloading into %s"},
{"SteamCMD установлен: %s", "SteamCMD installed: %s"},
}
+152
View File
@@ -0,0 +1,152 @@
// 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}
}
+104
View File
@@ -0,0 +1,104 @@
package i18n
import (
"net/http"
"strings"
"testing"
)
func TestTKnownMessage(t *testing.T) {
got := T(EN, "сервер не запущен")
if got != "the server is not running" {
t.Fatalf("T(EN, ...) = %q", got)
}
if got := T(RU, "сервер не запущен"); got != "сервер не запущен" {
t.Fatalf("русский язык не должен трогать сообщение: %q", got)
}
}
// Вывод игрового сервера проходит через перевод как есть: словарь его не знает.
func TestTKeepsUnknown(t *testing.T) {
line := "LOG : General f:1 Zombies spawned: 1000"
if got := T(EN, line); got != line {
t.Fatalf("незнакомая строка изменена: %q", got)
}
}
func TestTFormat(t *testing.T) {
got := T(EN, `профиль "modded" не найден`)
if got != `profile "modded" not found` {
t.Fatalf("T = %q", got)
}
}
// Вложенная ошибка переводится вместе с обёрткой, а более конкретный шаблон
// («прочитать конфиг») должен выиграть у общего («прочитать»).
func TestTNestedError(t *testing.T) {
msg := "конфиг сервера недоступен: прочитать конфиг /srv/servertest.ini: open: no such file"
want := "the server config is unavailable: read the config /srv/servertest.ini: open: no such file"
if got := T(EN, msg); got != want {
t.Fatalf("T = %q, ожидалось %q", got, want)
}
}
func TestTTaskName(t *testing.T) {
if got := T(EN, "Резервное копирование мира: modded"); got != "World backup: modded" {
t.Fatalf("T = %q", got)
}
}
// В переводе должно быть столько же подстановок, сколько в оригинале, иначе
// Sprintf выведет %!s(MISSING) прямо в панель.
func TestCatalogPlaceholders(t *testing.T) {
for _, f := range formats {
ru := strings.Count(f[0], "%s")
en := strings.Count(f[1], "%s")
if ru == 0 {
t.Errorf("шаблон без подстановки: %q", f[0])
}
if ru != en {
t.Errorf("%q: подстановок %d, в переводе %d", f[0], ru, en)
}
if strings.ContainsAny(strings.ReplaceAll(f[0], "%s", ""), "%") {
t.Errorf("в шаблоне остался другой глагол форматирования: %q", f[0])
}
}
for ru, en := range messages {
if strings.Contains(ru, "%") || strings.Contains(en, "%") {
t.Errorf("сообщение с подстановкой должно лежать в formats: %q", ru)
}
}
}
func TestFromRequest(t *testing.T) {
cases := []struct {
name string
cookie string
header string
want Lang
}{
{"по умолчанию", "", "", Default},
{"выбор человека", "en", "ru-RU,ru;q=0.9", EN},
{"мусор в куке", "klingon", "en-US,en;q=0.9", EN},
{"язык браузера", "", "en-GB,en;q=0.9", EN},
{"неизвестный язык браузера", "", "de-DE,de;q=0.9", Default},
{"первый понятный из списка", "", "de-DE;q=0.9,en;q=0.8", EN},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r, err := http.NewRequest(http.MethodGet, "/api/status", nil)
if err != nil {
t.Fatal(err)
}
if c.cookie != "" {
r.AddCookie(&http.Cookie{Name: CookieName, Value: c.cookie})
}
if c.header != "" {
r.Header.Set("Accept-Language", c.header)
}
if got := FromRequest(r); got != c.want {
t.Fatalf("FromRequest = %q, ожидалось %q", got, c.want)
}
})
}
}