Русский и английский языки интерфейса
Язык выбирается в шапке панели, а до первого выбора берётся из настроек браузера. Переведены интерфейс, подписи и описания всех параметров конфигов, ошибки API и служебные сообщения менеджера в консоли. Подписи параметров вынесены из app.js в hints-<язык>.json: их больше четырёхсот на язык, и грузить чужой язык незачем. Сообщения менеджера переводятся на выходе из API по словарю, где ключом служит русская фраза из кода, — вывод самого игрового сервера через него проходит без изменений. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,7 +34,7 @@ func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) {
|
||||
// handleSetup создаёт первого администратора. Доступен, только пока учёток нет.
|
||||
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.users.IsEmpty() {
|
||||
writeError(w, http.StatusConflict, "панель уже настроена")
|
||||
writeError(w, r, http.StatusConflict, "панель уже настроена")
|
||||
return
|
||||
}
|
||||
var creds credentials
|
||||
@@ -44,16 +44,16 @@ func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
// Токен печатается в журнал менеджера при старте: без него первым
|
||||
// администратором стал бы любой, кто успел открыть страницу.
|
||||
if s.setupToken == "" || subtle.ConstantTimeCompare([]byte(creds.Token), []byte(s.setupToken)) != 1 {
|
||||
writeError(w, http.StatusForbidden, "неверный код первичной настройки")
|
||||
writeError(w, r, http.StatusForbidden, "неверный код первичной настройки")
|
||||
return
|
||||
}
|
||||
if err := s.users.AddUser(creds.Login, creds.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
id, err := s.users.Login(creds.Login, creds.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Администратор создан — код больше не нужен и не должен лежать на диске.
|
||||
@@ -72,10 +72,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := s.users.Login(creds.Login, creds.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrBadCredentials) {
|
||||
writeError(w, http.StatusUnauthorized, err.Error())
|
||||
writeError(w, r, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, id, sessionTTL)
|
||||
@@ -100,7 +100,7 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.users.AddUser(creds.Login, creds.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"login": creds.Login})
|
||||
@@ -110,11 +110,11 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
login := r.PathValue("login")
|
||||
current, _ := s.users.SessionFromRequest(r)
|
||||
if login == current {
|
||||
writeError(w, http.StatusBadRequest, "нельзя удалить учётную запись, под которой вы вошли")
|
||||
writeError(w, r, http.StatusBadRequest, "нельзя удалить учётную запись, под которой вы вошли")
|
||||
return
|
||||
}
|
||||
if err := s.users.DeleteUser(login); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": login})
|
||||
@@ -128,7 +128,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.users.SetPassword(r.PathValue("login"), body.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mrleo1nid/pzmanager/internal/config"
|
||||
"github.com/mrleo1nid/pzmanager/internal/i18n"
|
||||
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
||||
)
|
||||
|
||||
@@ -19,12 +20,12 @@ func (s *Server) handleGetServerConfig(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"exists": false,
|
||||
"path": path,
|
||||
"hint": "Файл создаётся при первом запуске сервера",
|
||||
"hint": i18n.T(i18n.FromRequest(r), "Файл создаётся при первом запуске сервера"),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
@@ -50,14 +51,14 @@ func (s *Server) handlePutServerConfig(w http.ResponseWriter, r *http.Request) {
|
||||
path := s.cfg.ServerConfigPath()
|
||||
ini, err := pzconfig.LoadINI(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "конфиг сервера недоступен: "+err.Error())
|
||||
writeError(w, r, http.StatusBadRequest, "конфиг сервера недоступен: "+err.Error())
|
||||
return
|
||||
}
|
||||
for key, value := range body.Values {
|
||||
ini.Set(key, value)
|
||||
}
|
||||
if err := pzconfig.SaveINI(path, ini); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.sup.AppendLog("manager", "Конфиг сервера сохранён (изменений: "+strconv.Itoa(len(body.Values))+"). Изменения вступят в силу после перезапуска.")
|
||||
@@ -71,12 +72,12 @@ func (s *Server) handleGetSandbox(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"exists": false,
|
||||
"path": path,
|
||||
"hint": "Файл создаётся при первом запуске сервера",
|
||||
"hint": i18n.T(i18n.FromRequest(r), "Файл создаётся при первом запуске сервера"),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
@@ -96,17 +97,17 @@ func (s *Server) handlePutSandbox(w http.ResponseWriter, r *http.Request) {
|
||||
path := s.cfg.SandboxConfigPath()
|
||||
sb, err := pzconfig.LoadSandbox(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "настройки песочницы недоступны: "+err.Error())
|
||||
writeError(w, r, http.StatusBadRequest, "настройки песочницы недоступны: "+err.Error())
|
||||
return
|
||||
}
|
||||
for key, value := range body.Values {
|
||||
if err := sb.Set(key, value); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := pzconfig.SaveSandbox(path, sb); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.sup.AppendLog("manager", "Настройки песочницы сохранены. Часть параметров применяется только к новому миру.")
|
||||
@@ -177,16 +178,16 @@ func (s *Server) handlePutManagerConfig(w http.ResponseWriter, r *http.Request)
|
||||
c.Backup.Schedule = config.Duration(time.Duration(dto.BackupScheduleMin) * time.Minute)
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := c.EnsureDirs(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.sup.AppendLog("manager", "Настройки менеджера сохранены")
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"saved": true,
|
||||
"note": "Адрес прослушивания применится после перезапуска pzmanager",
|
||||
"note": i18n.T(i18n.FromRequest(r), "Адрес прослушивания применится после перезапуска pzmanager"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/mrleo1nid/pzmanager/internal/i18n"
|
||||
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
||||
)
|
||||
|
||||
@@ -24,12 +25,12 @@ func (s *Server) handleGetMods(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"exists": false,
|
||||
"path": path,
|
||||
"hint": "Файл конфигурации появится после первого запуска сервера",
|
||||
"hint": i18n.T(i18n.FromRequest(r), "Файл конфигурации появится после первого запуска сервера"),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -67,7 +68,7 @@ func (s *Server) handlePutMods(w http.ResponseWriter, r *http.Request) {
|
||||
path := s.cfg.ServerConfigPath()
|
||||
ini, err := pzconfig.LoadINI(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "конфиг сервера недоступен: "+err.Error())
|
||||
writeError(w, r, http.StatusBadRequest, "конфиг сервера недоступен: "+err.Error())
|
||||
return
|
||||
}
|
||||
ini.SetMods(body.Mods, body.WorkshopItems)
|
||||
@@ -75,7 +76,7 @@ func (s *Server) handlePutMods(w http.ResponseWriter, r *http.Request) {
|
||||
ini.Set(pzconfig.KeyMap, pzconfig.JoinList(body.Map))
|
||||
}
|
||||
if err := pzconfig.SaveINI(path, ini); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.sup.AppendLog("manager", "Список модов обновлён. Сервер докачает моды из мастерской при следующем запуске.")
|
||||
@@ -95,7 +96,7 @@ func (s *Server) handleLookupMods(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if len(body.IDs) > 200 {
|
||||
writeError(w, http.StatusBadRequest, "слишком много идентификаторов за один запрос")
|
||||
writeError(w, r, http.StatusBadRequest, "слишком много идентификаторов за один запрос")
|
||||
return
|
||||
}
|
||||
items := s.workshop.Details(r.Context(), body.IDs)
|
||||
@@ -114,13 +115,13 @@ func (s *Server) handleExpandCollection(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
match := idFromInput.FindString(body.ID)
|
||||
if match == "" {
|
||||
writeError(w, http.StatusBadRequest, "не похоже на идентификатор или ссылку на коллекцию")
|
||||
writeError(w, r, http.StatusBadRequest, "не похоже на идентификатор или ссылку на коллекцию")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := s.workshop.Collection(r.Context(), match)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
writeError(w, r, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
// Названия подтягиваем сразу: иначе панель показала бы десяток голых
|
||||
|
||||
@@ -84,7 +84,7 @@ func (s *Server) handleCreateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := validateProfileMemory(dto); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func (s *Server) handleCreateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
AdminPassword: dto.AdminPassword,
|
||||
}
|
||||
if err := s.cfg.AddProfile(profile); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -106,13 +106,13 @@ func (s *Server) handleCreateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
// Профиль уже добавлен в память — откатываем, чтобы не остался
|
||||
// наполовину настроенный.
|
||||
_ = s.cfg.DeleteProfile(profile.ID)
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.cfg.Save(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.sup.AppendLog("manager", fmt.Sprintf("Создан профиль %q", profile.Name()))
|
||||
@@ -152,7 +152,7 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
current, ok := s.cfg.Profile(id)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "профиль не найден")
|
||||
writeError(w, r, http.StatusNotFound, "профиль не найден")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := validateProfileMemory(dto); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -180,11 +180,11 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.cfg.UpdateProfile(id, updated); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.cfg.Save(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -198,11 +198,11 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := s.cfg.DeleteProfile(id); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.cfg.Save(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.sup.AppendLog("manager", fmt.Sprintf(
|
||||
@@ -215,16 +215,16 @@ func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
|
||||
// сервере нельзя: профили делят игровые порты и файлы запуска.
|
||||
func (s *Server) handleActivateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if s.sup.State() != pzserver.StateStopped {
|
||||
writeError(w, http.StatusConflict, "сначала остановите игровой сервер")
|
||||
writeError(w, r, http.StatusConflict, "сначала остановите игровой сервер")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := s.cfg.SetActive(id); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.cfg.Save(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.sup.AppendLog("manager", fmt.Sprintf("Активный профиль: %s", s.cfg.ActiveProfile().Name()))
|
||||
|
||||
@@ -8,19 +8,26 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mrleo1nid/pzmanager/internal/i18n"
|
||||
"github.com/mrleo1nid/pzmanager/internal/pzserver"
|
||||
)
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"server": s.sup.Status(),
|
||||
"task": s.jobs.Status(),
|
||||
})
|
||||
lang := i18n.FromRequest(r)
|
||||
status := s.sup.Status()
|
||||
status.LastExit = i18n.T(lang, status.LastExit)
|
||||
|
||||
// Имя задачи придумано менеджером и висит в панели, пока задача идёт.
|
||||
task := s.jobs.Status()
|
||||
task.Name = i18n.T(lang, task.Name)
|
||||
task.Error = i18n.T(lang, task.Error)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"server": status, "task": task})
|
||||
}
|
||||
|
||||
func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.sup.Start(); err != nil {
|
||||
writeOpError(w, err)
|
||||
writeOpError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"state": s.sup.State()})
|
||||
@@ -31,7 +38,7 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
// видит прогресс по состоянию и логам.
|
||||
func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
|
||||
if s.sup.State() == pzserver.StateStopped {
|
||||
writeOpError(w, pzserver.ErrNotRunning)
|
||||
writeOpError(w, r, pzserver.ErrNotRunning)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
@@ -44,7 +51,7 @@ func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleRestart(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sup.Installed() {
|
||||
writeOpError(w, pzserver.ErrNotInstalled)
|
||||
writeOpError(w, r, pzserver.ErrNotInstalled)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
@@ -63,7 +70,7 @@ func (s *Server) handleConsole(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.sup.Send(body.Command); err != nil {
|
||||
writeOpError(w, err)
|
||||
writeOpError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"sent": body.Command})
|
||||
@@ -72,7 +79,23 @@ func (s *Server) handleConsole(w http.ResponseWriter, r *http.Request) {
|
||||
// handleLogs отдаёт историю консоли начиная с указанного номера строки.
|
||||
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
|
||||
since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"lines": s.sup.Lines(since)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"lines": translateLines(r, s.sup.Lines(since))})
|
||||
}
|
||||
|
||||
// translateLines переводит строки консоли на язык читающего. Буфер один на
|
||||
// всех, поэтому перевод делается на выходе, у каждого подключения свой. Вывод
|
||||
// самого игрового сервера через словарь проходит без изменений: он английский.
|
||||
func translateLines(r *http.Request, lines []pzserver.LogLine) []pzserver.LogLine {
|
||||
lang := i18n.FromRequest(r)
|
||||
if lang == i18n.Default {
|
||||
return lines
|
||||
}
|
||||
out := make([]pzserver.LogLine, len(lines))
|
||||
for i, line := range lines {
|
||||
line.Text = i18n.T(lang, line.Text)
|
||||
out[i] = line
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleLogStream — живая консоль через Server-Sent Events. SSE выбран вместо
|
||||
@@ -80,7 +103,7 @@ func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeError(w, http.StatusInternalServerError, "потоковая отдача не поддерживается")
|
||||
writeError(w, r, http.StatusInternalServerError, "потоковая отдача не поддерживается")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -94,9 +117,11 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
|
||||
lines, unsubscribe := s.sup.Subscribe()
|
||||
defer unsubscribe()
|
||||
|
||||
lang := i18n.FromRequest(r)
|
||||
|
||||
// Сначала отдаём хвост истории, чтобы вкладка не открывалась пустой.
|
||||
since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
|
||||
for _, line := range s.sup.Lines(since) {
|
||||
for _, line := range translateLines(r, s.sup.Lines(since)) {
|
||||
if !writeSSE(w, line) {
|
||||
return
|
||||
}
|
||||
@@ -115,6 +140,7 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
line.Text = i18n.T(lang, line.Text)
|
||||
if !writeSSE(w, line) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ func (s *Server) handleTaskStatus(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleTaskCancel(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.jobs.Busy() {
|
||||
writeError(w, http.StatusConflict, "нет активной задачи")
|
||||
writeError(w, r, http.StatusConflict, "нет активной задачи")
|
||||
return
|
||||
}
|
||||
s.jobs.Cancel()
|
||||
@@ -31,7 +31,7 @@ func (s *Server) handleTaskCancel(w http.ResponseWriter, r *http.Request) {
|
||||
// JVM, и сервер упадёт непредсказуемо.
|
||||
func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
|
||||
if s.sup.State() != pzserver.StateStopped {
|
||||
writeError(w, http.StatusConflict, "сначала остановите игровой сервер")
|
||||
writeError(w, r, http.StatusConflict, "сначала остановите игровой сервер")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeOpError(w, err)
|
||||
writeOpError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
|
||||
@@ -55,7 +55,7 @@ func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
|
||||
archives, err := s.backups.List()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Фильтр по профилю необязателен: панель показывает архивы всех профилей,
|
||||
@@ -88,7 +88,7 @@ func (s *Server) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
|
||||
body.Profile = s.cfg.Active
|
||||
}
|
||||
if _, ok := s.cfg.Profile(body.Profile); !ok {
|
||||
writeError(w, http.StatusBadRequest, "профиль не найден")
|
||||
writeError(w, r, http.StatusBadRequest, "профиль не найден")
|
||||
return
|
||||
}
|
||||
// Живой сервер держит мир в памяти, и снимок чужого профиля тут ни при
|
||||
@@ -119,7 +119,7 @@ func (s *Server) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
writeOpError(w, err)
|
||||
writeOpError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
|
||||
@@ -127,7 +127,7 @@ func (s *Server) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.backups.Delete(r.PathValue("name")); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": r.PathValue("name")})
|
||||
@@ -137,18 +137,18 @@ func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
path, err := s.backups.Path(name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "архив не найден")
|
||||
writeError(w, r, http.StatusNotFound, "архив не найден")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -162,12 +162,12 @@ func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) {
|
||||
// своим состоянием при следующем сохранении.
|
||||
func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if s.sup.State() != pzserver.StateStopped {
|
||||
writeError(w, http.StatusConflict, "сначала остановите игровой сервер")
|
||||
writeError(w, r, http.StatusConflict, "сначала остановите игровой сервер")
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
if _, err := s.backups.Path(name); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// Страховочную копию снимаем с того профиля, поверх которого ляжет архив;
|
||||
@@ -188,7 +188,7 @@ func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
|
||||
return s.backups.Restore(ctx, log, name)
|
||||
})
|
||||
if err != nil {
|
||||
writeOpError(w, err)
|
||||
writeOpError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"task": taskName})
|
||||
|
||||
@@ -26,7 +26,7 @@ func (s *Server) handleApplyUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if !s.updater.CanApply(r.Context()) {
|
||||
writeError(w, http.StatusPreconditionFailed, updater.ErrNoAdmin.Error())
|
||||
writeError(w, r, http.StatusPreconditionFailed, updater.ErrNoAdmin.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func (s *Server) handleApplyUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return s.updater.Apply(ctx, log, body.Version)
|
||||
})
|
||||
if err != nil {
|
||||
writeOpError(w, err)
|
||||
writeOpError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
|
||||
@@ -49,7 +49,7 @@ func (s *Server) handleApplyUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
// handleRestartManager перезапускает саму панель.
|
||||
func (s *Server) handleRestartManager(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.updater.CanApply(r.Context()) {
|
||||
writeError(w, http.StatusPreconditionFailed, updater.ErrNoAdmin.Error())
|
||||
writeError(w, r, http.StatusPreconditionFailed, updater.ErrNoAdmin.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func (s *Server) handleRestartManager(w http.ResponseWriter, r *http.Request) {
|
||||
return s.updater.Restart(ctx, log)
|
||||
})
|
||||
if err != nil {
|
||||
writeOpError(w, err)
|
||||
writeOpError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
|
||||
|
||||
+11
-9
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/mrleo1nid/pzmanager/internal/auth"
|
||||
"github.com/mrleo1nid/pzmanager/internal/backup"
|
||||
"github.com/mrleo1nid/pzmanager/internal/config"
|
||||
"github.com/mrleo1nid/pzmanager/internal/i18n"
|
||||
"github.com/mrleo1nid/pzmanager/internal/job"
|
||||
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
||||
"github.com/mrleo1nid/pzmanager/internal/pzserver"
|
||||
@@ -175,7 +176,7 @@ func (s *Server) withCommonHeaders(next http.Handler) http.Handler {
|
||||
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead &&
|
||||
strings.HasPrefix(r.URL.Path, "/api/") && r.Header.Get(csrfHeader) == "" {
|
||||
writeError(w, http.StatusForbidden, "запрос без заголовка "+csrfHeader)
|
||||
writeError(w, r, http.StatusForbidden, "запрос без заголовка "+csrfHeader)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
@@ -186,7 +187,7 @@ func (s *Server) withCommonHeaders(next http.Handler) http.Handler {
|
||||
func (s *Server) requireAuth(next http.HandlerFunc) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.users.SessionFromRequest(r); !ok {
|
||||
writeError(w, http.StatusUnauthorized, "требуется вход")
|
||||
writeError(w, r, http.StatusUnauthorized, "требуется вход")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
@@ -228,20 +229,21 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
}
|
||||
|
||||
// writeError отправляет ошибку в том же формате, что и остальные ответы.
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
// Текст переводится на язык панели: в коде сообщения написаны по-русски.
|
||||
func writeError(w http.ResponseWriter, r *http.Request, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": i18n.T(i18n.FromRequest(r), message)})
|
||||
}
|
||||
|
||||
// writeOpError переводит ошибку операции в подходящий HTTP-код.
|
||||
func writeOpError(w http.ResponseWriter, err error) {
|
||||
func writeOpError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, pzserver.ErrAlreadyRunning), errors.Is(err, pzserver.ErrNotRunning),
|
||||
errors.Is(err, job.ErrBusy):
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
writeError(w, r, http.StatusConflict, err.Error())
|
||||
case errors.Is(err, pzserver.ErrNotInstalled):
|
||||
writeError(w, http.StatusPreconditionFailed, err.Error())
|
||||
writeError(w, r, http.StatusPreconditionFailed, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,7 +253,7 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "некорректный JSON: "+err.Error())
|
||||
writeError(w, r, http.StatusBadRequest, "некорректный JSON: "+err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user