Мод-менеджер с галочками и коллекции мастерской

Вместо двух отдельных списков (Workshop ID и Mod ID) — один список модов:
галочка включает мод, перетаскивание задаёт порядок загрузки, кнопка
убирает пакет вместе со всеми его модами.

Чтобы галочкам было за что зацепиться, панель читает mod.info уже
скачанных модов: серверу в Mods нужен именно Mod ID, а он лежит внутри
пакета мастерской и вручную его приходилось искать в описании мода. Пока
мод не скачан, строка помечена и галочка недоступна — Mod ID неизвестен.

Один пакет мастерской может содержать несколько модов; теперь они видны
отдельными строками и включаются по отдельности.

Коллекции: кнопка разворачивает коллекцию Steam в список входящих в неё
модов, сохраняя порядок со страницы коллекции. И мод, и коллекция
принимаются как ссылкой, так и голым идентификатором.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 00:36:06 +03:00
co-authored by Claude Opus 5
parent 277ab7af4a
commit fe517cfb71
9 changed files with 675 additions and 71 deletions
+143
View File
@@ -0,0 +1,143 @@
package pzconfig
import (
"bufio"
"os"
"path/filepath"
"sort"
"strings"
)
// pzAppID — идентификатор Project Zomboid в Steam. По нему сервер
// раскладывает скачанные моды мастерской.
const pzAppID = "108600"
// InstalledMod — мод, найденный на диске. Именно ModID сервер ждёт в списке
// Mods, а Workshop ID — это пакет мастерской, внутри которого мод лежит.
type InstalledMod struct {
ModID string `json:"mod_id"`
WorkshopID string `json:"workshop_id"`
Name string `json:"name"`
Description string `json:"description"`
Dir string `json:"dir"`
}
// ScanInstalledMods обходит места, куда попадают моды, и читает их mod.info.
// Панель по этому каталогу показывает настоящие названия и, главное, знает
// Mod ID: без него мод нельзя включить, а вручную его приходится искать в
// описании на странице мастерской.
//
// roots — директории, внутри которых ищем steamapps/workshop (директория
// сервера и ~/Zomboid), localMods — папка с модами, положенными руками.
func ScanInstalledMods(roots []string, localMods string) []InstalledMod {
seen := make(map[string]bool)
var out []InstalledMod
add := func(mod InstalledMod) {
// Один и тот же мод может лежать и в мастерской, и в локальной папке.
if mod.ModID == "" || seen[mod.ModID] {
return
}
seen[mod.ModID] = true
out = append(out, mod)
}
for _, root := range roots {
if root == "" {
continue
}
content := filepath.Join(root, "steamapps", "workshop", "content", pzAppID)
entries, err := os.ReadDir(content)
if err != nil {
continue
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
workshopID := entry.Name()
// Внутри пакета моды лежат в mods/<имя>/mod.info, но встречается
// и раскладка без промежуточной папки mods.
for _, modsDir := range []string{
filepath.Join(content, workshopID, "mods"),
filepath.Join(content, workshopID),
} {
for _, mod := range readModsDir(modsDir, workshopID) {
add(mod)
}
}
}
}
for _, mod := range readModsDir(localMods, "") {
add(mod)
}
sort.Slice(out, func(i, j int) bool {
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
})
return out
}
// readModsDir читает mod.info во всех поддиректориях dir.
func readModsDir(dir, workshopID string) []InstalledMod {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var out []InstalledMod
for _, entry := range entries {
if !entry.IsDir() {
continue
}
modDir := filepath.Join(dir, entry.Name())
mod, ok := readModInfo(filepath.Join(modDir, "mod.info"))
if !ok {
continue
}
mod.WorkshopID = workshopID
mod.Dir = modDir
if mod.Name == "" {
mod.Name = mod.ModID
}
out = append(out, mod)
}
return out
}
// readModInfo разбирает mod.info — файл вида "ключ=значение" с необязательными
// комментариями. Нас интересуют id, name и description.
func readModInfo(path string) (InstalledMod, bool) {
f, err := os.Open(path)
if err != nil {
return InstalledMod{}, false
}
defer f.Close()
var mod InstalledMod
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 8*1024), 256*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, found := strings.Cut(line, "=")
if !found {
continue
}
value = strings.TrimSpace(value)
switch strings.ToLower(strings.TrimSpace(key)) {
case "id":
mod.ModID = value
case "name":
mod.Name = value
case "description":
if len(value) > 300 {
value = value[:300] + "…"
}
mod.Description = value
}
}
return mod, mod.ModID != ""
}