from __future__ import annotations import argparse import io import sys import zipfile from datetime import datetime import hashlib import re from pathlib import Path from typing import Any from flask import Flask, Response, redirect, render_template, request, url_for from pz_config.parsers import parse_sandboxvars_lua, parse_server_ini from pz_config.writers import write_sandboxvars_lua, write_server_ini def _parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Project Zomboid 配置 WebUI 编辑器") parser.add_argument("--ini", default="server.ini", help="server.ini 路径(默认:./server.ini)") parser.add_argument( "--lua", default="server_SandboxVars.lua", help="server_SandboxVars.lua 路径(默认:./server_SandboxVars.lua)", ) parser.add_argument( "--translations", default=str(Path("i18n") / "sandboxvars_zh.json"), help="SandboxVars 中文说明 JSON(默认:./i18n/sandboxvars_zh.json)", ) parser.add_argument("--host", default="127.0.0.1", help="监听地址(默认:127.0.0.1)") parser.add_argument("--port", type=int, default=5050, help="监听端口(默认:5050)") parser.add_argument("--debug", action="store_true", help="Flask debug 模式") return parser.parse_args(argv) def _coerce_value(value_type: str, raw: str) -> bool | int | float | str: if value_type == "bool": return raw.lower() == "true" if value_type == "int": return int(raw) if value_type == "float": return float(raw) return raw def create_app(ini_path: str, lua_path: str, translations_path: str) -> Flask: app = Flask(__name__) ini_path = str(Path(ini_path)) lua_path = str(Path(lua_path)) translations_path = str(Path(translations_path)) def _stable_group_id(name: str) -> str: ascii_slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") or "group" digest = hashlib.md5(name.encode("utf-8")).hexdigest()[:8] return f"{ascii_slug}-{digest}" long_text_keys = { "ServerWelcomeMessage", "PublicDescription", "Mods", "Map", "SpawnItems", "ClientCommandFilter", "ClientActionLogs", "BadWordReplacement", "BadWordListFile", "GoodWordListFile", "LootItemRemovalList", "WorldItemRemovalList", "Map.MapAllKnown", } @app.get("/") def index() -> str: translations = translations_path if Path(translations_path).exists() else None ini_cfg = parse_server_ini(ini_path) lua_cfg = parse_sandboxvars_lua(lua_path, translations_json=translations) # Group Lua settings by their top-level table. lua_groups: dict[str, list[Any]] = {} for s in lua_cfg.settings: lua_groups.setdefault(s.group, []).append(s) for group_settings in lua_groups.values(): group_settings.sort(key=lambda s: s.line_index) lua_groups_payload: list[dict[str, Any]] = [] for group_name, items in lua_groups.items(): first_line = min(s.line_index for s in items) if items else 0 lua_groups_payload.append( { "name": group_name, "id": _stable_group_id(group_name), "settings": items, "count": len(items), "order": first_line, } ) lua_groups_payload.sort(key=lambda g: g["order"]) lua_setting_count = sum(len(items) for items in lua_groups.values()) return render_template( "index.html", ini_settings=ini_cfg.settings, lua_groups=lua_groups_payload, lua_setting_count=lua_setting_count, ini_path=ini_path, lua_path=lua_path, has_translations=Path(translations_path).exists(), translations_path=translations_path, long_text_keys=long_text_keys, saved=request.args.get("saved") == "1", ) def _read_updates() -> tuple[dict[str, object], dict[str, object], list[str]]: translations = translations_path if Path(translations_path).exists() else None ini_cfg = parse_server_ini(ini_path) lua_cfg = parse_sandboxvars_lua(lua_path, translations_json=translations) ini_updates: dict[str, object] = {} lua_updates: dict[str, object] = {} errors: list[str] = [] for s in ini_cfg.settings: field = f"ini__{s.path}" if s.value_type == "bool": ini_updates[s.path] = field in request.form continue raw = request.form.get(field, "") try: ini_updates[s.path] = _coerce_value(s.value_type, raw) except Exception: errors.append(f"server.ini: {s.path} 值非法:{raw!r}") for s in lua_cfg.settings: field = f"lua__{s.path}" if s.value_type == "bool": lua_updates[s.path] = field in request.form continue raw = request.form.get(field, "") try: lua_updates[s.path] = _coerce_value(s.value_type, raw) except Exception: errors.append(f"server_SandboxVars.lua: {s.path} 值非法:{raw!r}") return ini_updates, lua_updates, errors @app.post("/download.zip") def download_zip() -> Response: translations = translations_path if Path(translations_path).exists() else None ini_cfg = parse_server_ini(ini_path) lua_cfg = parse_sandboxvars_lua(lua_path, translations_json=translations) ini_updates, lua_updates, errors = _read_updates() if errors: return Response("\n".join(errors), status=400, mimetype="text/plain; charset=utf-8") ini_out = write_server_ini(ini_cfg, ini_updates) lua_out = write_sandboxvars_lua(lua_cfg, lua_updates) buf = io.BytesIO() with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: zf.writestr(Path(ini_path).name, ini_out) zf.writestr(Path(lua_path).name, lua_out) buf.seek(0) filename = f"pz-config-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip" return Response( buf.getvalue(), mimetype="application/zip", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @app.post("/save") def save_to_disk() -> Response: translations = translations_path if Path(translations_path).exists() else None ini_cfg = parse_server_ini(ini_path) lua_cfg = parse_sandboxvars_lua(lua_path, translations_json=translations) ini_updates, lua_updates, errors = _read_updates() if errors: return Response("\n".join(errors), status=400, mimetype="text/plain; charset=utf-8") ini_out = write_server_ini(ini_cfg, ini_updates) lua_out = write_sandboxvars_lua(lua_cfg, lua_updates) Path(ini_path).write_text(ini_out, encoding="utf-8", newline="") Path(lua_path).write_text(lua_out, encoding="utf-8", newline="") return redirect(url_for("index", saved="1")) return app def main(argv: list[str]) -> int: args = _parse_args(argv) app = create_app(args.ini, args.lua, args.translations) app.run(host=args.host, port=args.port, debug=args.debug) return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))