Files
pzmanager/internal/pzconfig/ini.go
T
mrleo1nidandClaude Opus 5 aec932f68f
Release / release (push) Successful in 30s
CI / test (push) Successful in 23s
Импорт пресетов сборок из ZIP-пакета
Крупные сборки раздают готовый комплект настроек архивом. Панель разбирает
его сама: находит серверный .ini, песочницу и точки появления, показывает,
что нашла (сколько модов, карт, параметров), и только по подтверждению
раскладывает файлы в новый или существующий профиль.

Порты, пароли и название сервера при этом можно оставить свои — они относятся
к машине, а не к сборке; у нового профиля они берутся из активного. Прежние
конфиги остаются рядом как .bak, мир не трогается, а импорт в работающий
профиль отклоняется: PZ перезаписал бы файлы своими при остановке.

Посторонние .ini из архива (настройки модов) за конфиг сервера не
принимаются: он опознаётся по знакомым параметрам.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 17:18:44 +03:00

206 lines
6.2 KiB
Go

// Package pzconfig читает и правит конфиги Project Zomboid: servertest.ini и
// SandboxVars.lua.
package pzconfig
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
)
// iniLine — одна строка файла. Пустой Key означает комментарий или пустую
// строку, которую нужно сохранить как есть.
type iniLine struct {
Key string
Value string
Raw string
// Comment — описание параметра: строки комментария прямо перед ним.
// Project Zomboid пишет их на языке сервера, так что панели есть что
// показать человеку, не выдумывая своих формулировок.
Comment string
}
// INI — конфиг PZ в виде плоских key=value. Разбор сохраняет исходный порядок
// строк и комментарии, чтобы правка одного параметра не переписывала файл.
type INI struct {
lines []iniLine
index map[string]int
// crlf — файл был с виндовыми переводами строк. Сохраняем как было: иначе
// правка одного параметра переписывает весь файл целиком.
crlf bool
}
// ParseINI разбирает содержимое конфига.
func ParseINI(data []byte) *INI {
ini := &INI{index: make(map[string]int)}
raw := string(data)
ini.crlf = strings.Contains(raw, "\r\n")
text := strings.ReplaceAll(raw, "\r\n", "\n")
// Комментарии копим до ближайшего параметра: в конфигах PZ описание стоит
// строкой выше. Пустая строка обрывает связь — иначе описание прилипло бы
// к следующему блоку.
var comment []string
for _, raw := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(raw)
key, value, found := strings.Cut(raw, "=")
if strings.HasPrefix(trimmed, "#") {
comment = append(comment, strings.TrimSpace(strings.TrimPrefix(trimmed, "#")))
ini.lines = append(ini.lines, iniLine{Raw: raw})
continue
}
if !found || trimmed == "" {
comment = nil
ini.lines = append(ini.lines, iniLine{Raw: raw})
continue
}
key = strings.TrimSpace(key)
ini.index[key] = len(ini.lines)
ini.lines = append(ini.lines, iniLine{
Key: key,
Value: strings.TrimSpace(value),
Comment: strings.Join(comment, " "),
})
comment = nil
}
return ini
}
// LoadINI читает конфиг с диска.
func LoadINI(path string) (*INI, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return ParseINI(data), nil
}
// Get возвращает значение параметра.
func (i *INI) Get(key string) string {
idx, ok := i.index[key]
if !ok {
return ""
}
return i.lines[idx].Value
}
// Has сообщает, есть ли такой параметр в файле.
func (i *INI) Has(key string) bool {
_, ok := i.index[key]
return ok
}
// Set меняет значение, добавляя параметр в конец, если его ещё нет.
func (i *INI) Set(key, value string) {
// Перевод строки в значении сломал бы формат файла.
value = strings.NewReplacer("\n", " ", "\r", " ").Replace(value)
if idx, ok := i.index[key]; ok {
i.lines[idx].Value = value
return
}
i.index[key] = len(i.lines)
i.lines = append(i.lines, iniLine{Key: key, Value: value})
}
// Keys возвращает имена параметров в порядке их появления в файле.
func (i *INI) Keys() []string {
out := make([]string, 0, len(i.index))
for _, l := range i.lines {
if l.Key != "" {
out = append(out, l.Key)
}
}
return out
}
// Comments возвращает описания параметров: ключ -> текст комментария.
func (i *INI) Comments() map[string]string {
out := make(map[string]string, len(i.index))
for _, l := range i.lines {
if l.Key != "" && l.Comment != "" {
out[l.Key] = l.Comment
}
}
return out
}
// Map отдаёт все параметры в виде словаря.
func (i *INI) Map() map[string]string {
out := make(map[string]string, len(i.index))
for _, l := range i.lines {
if l.Key != "" {
out[l.Key] = l.Value
}
}
return out
}
// Render собирает файл обратно в текст.
func (i *INI) Render() []byte {
newline := "\n"
if i.crlf {
newline = "\r\n"
}
var buf bytes.Buffer
for n, l := range i.lines {
if l.Key == "" {
buf.WriteString(l.Raw)
} else {
buf.WriteString(l.Key)
buf.WriteByte('=')
buf.WriteString(l.Value)
}
if n < len(i.lines)-1 {
buf.WriteString(newline)
}
}
return buf.Bytes()
}
// SaveINI записывает конфиг атомарно, предварительно сняв копию прежней
// версии рядом (.bak): испорченный конфиг остановит сервер.
func SaveINI(path string, ini *INI) error {
if err := backupFile(path); err != nil {
return err
}
return writeAtomic(path, ini.Render(), 0o644)
}
// SaveRaw кладёт на место файл, который менеджер не разбирает (настройки
// песочницы и точки появления из пакета сборки), с той же страховкой: копия
// прежней версии рядом и атомарная замена.
func SaveRaw(path string, data []byte) error {
if err := backupFile(path); err != nil {
return err
}
return writeAtomic(path, data, 0o644)
}
func backupFile(path string) error {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
return os.WriteFile(path+".bak", data, 0o644)
}
func writeAtomic(path string, data []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, mode); err != nil {
return fmt.Errorf("записать %s: %w", tmp, err)
}
return os.Rename(tmp, path)
}