#!/usr/bin/env python3 """Interactive, comment-preserving editor for MAA Online daily.toml.""" from __future__ import annotations import argparse import json import os import re import shutil import subprocess import tempfile import tomllib from datetime import datetime from pathlib import Path from typing import Any ROOT = Path(os.getenv("MAA_ONLINE_ROOT", Path(__file__).resolve().parents[1])) DEFAULT_FILE = Path(os.getenv("MAA_ONLINE_CONFIG_FILE", Path(__file__).with_name("daily.toml"))) MAA_DRY_RUN = [ str(ROOT / "bin/maa-online"), "run", "daily", "--dry-run", "--batch", ] KEY_RE = re.compile(r"^(?P\s*)(?P[A-Za-z0-9_]+)(?P\s*=\s*)(?P.*?)(?P\r?\n)?$") def toml_literal(value: Any) -> str: if isinstance(value, bool): return "true" if value else "false" if isinstance(value, str): return json.dumps(value, ensure_ascii=False) if isinstance(value, int): return str(value) if isinstance(value, float): return repr(value) if isinstance(value, list): return "[" + ", ".join(toml_literal(item) for item in value) + "]" if isinstance(value, dict): pairs = [f"{toml_literal(str(key))} = {toml_literal(item)}" for key, item in value.items()] return "{ " + ", ".join(pairs) + " }" raise TypeError(f"不支持的值类型:{type(value).__name__}") def parse_value(raw: str, current: Any) -> Any: text = raw.strip() if isinstance(current, bool): aliases = { "true": True, "yes": True, "y": True, "1": True, "是": True, "开": True, "false": False, "no": False, "n": False, "0": False, "否": False, "关": False, } value = aliases.get(text.lower()) if value is None: raise ValueError("布尔值请输入 true/false、yes/no、是/否或开/关") return value if isinstance(current, str) and not text.startswith(('"', "'")): return text if isinstance(current, list) and not text.startswith("["): parts = [part.strip() for part in text.split(",") if part.strip()] if not parts: return [] sample = current[0] if current else "" if isinstance(sample, int): return [int(part) for part in parts] return parts try: return tomllib.loads("value = " + text)["value"] except (tomllib.TOMLDecodeError, KeyError) as exc: raise ValueError(f"无法解析 TOML 值:{exc}") from exc def same_kind(current: Any, value: Any) -> bool: if isinstance(current, bool): return isinstance(value, bool) if isinstance(current, int) and not isinstance(current, bool): return isinstance(value, int) and not isinstance(value, bool) if isinstance(current, float): return isinstance(value, (int, float)) and not isinstance(value, bool) return isinstance(value, type(current)) class ConfigEditor: def __init__(self, path: Path, maa_validate: bool = True): self.path = path.resolve() self.maa_validate = maa_validate and self.path == DEFAULT_FILE.resolve() self.original_text = self.path.read_text(encoding="utf-8") self.text = self.original_text def data(self) -> dict[str, Any]: return tomllib.loads(self.text) def sections(self) -> list[tuple[str, int | None, dict[str, Any]]]: data = self.data() global_values = {key: value for key, value in data.items() if key != "tasks"} result: list[tuple[str, int | None, dict[str, Any]]] = [("全局", None, global_values)] for index, task in enumerate(data.get("tasks", [])): label = f"{task.get('name', task.get('type', '任务'))} [{task.get('type', '?')}]" result.append((label, index, task.get("params", {}))) return result def replace(self, task_index: int | None, key: str, value: Any) -> None: lines = self.text.splitlines(keepends=True) current_task = -1 in_params = False for index, line in enumerate(lines): stripped = line.strip() if stripped == "[[tasks]]": current_task += 1 in_params = False continue if stripped == "[tasks.params]": in_params = True continue if stripped.startswith("[") and stripped.endswith("]"): in_params = False matches_section = current_task == -1 if task_index is None else current_task == task_index and in_params if not matches_section: continue match = KEY_RE.match(line) if not match or match.group("key") != key: continue newline = match.group("newline") or "" lines[index] = f"{match.group('indent')}{key}{match.group('space')}{toml_literal(value)}{newline}" self.text = "".join(lines) self.data() # 再次解析,确保生成结果仍是合法 TOML。 return raise KeyError(f"找不到配置项:{key}") def save(self) -> Path: self.data() backup_dir = self.path.parent / "backups" backup_dir.mkdir(exist_ok=True) stamp = datetime.now().strftime("%Y%m%d%H%M%S") backup = backup_dir / f"{self.path.name}.{stamp}.bak" suffix = 1 while backup.exists(): backup = backup_dir / f"{self.path.name}.{stamp}.{suffix}.bak" suffix += 1 shutil.copy2(self.path, backup) mode = self.path.stat().st_mode & 0o777 fd, temp_name = tempfile.mkstemp(prefix=f".{self.path.name}.", dir=self.path.parent) try: with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle: handle.write(self.text) handle.flush() os.fsync(handle.fileno()) os.chmod(temp_name, mode) os.replace(temp_name, self.path) except Exception: Path(temp_name).unlink(missing_ok=True) raise if self.maa_validate: result = subprocess.run(MAA_DRY_RUN, text=True, capture_output=True, timeout=120) if result.returncode != 0: shutil.copy2(backup, self.path) detail = (result.stderr or result.stdout).strip() raise RuntimeError(f"MAA dry-run 失败,已自动回滚:\n{detail}") print(result.stdout.strip()) self.original_text = self.text return backup @property def changed(self) -> bool: return self.text != self.original_text def edit_section(editor: ConfigEditor, task_index: int | None, label: str) -> None: while True: section = next(values for section_label, index, values in editor.sections() if section_label == label and index == task_index) keys = list(section) print(f"\n=== {label} ===") for number, key in enumerate(keys, 1): print(f"{number:2}. {key} = {toml_literal(section[key])}") print(" b. 返回") choice = input("选择配置项:").strip().lower() if choice in {"b", "back", "返回", ""}: return try: key = keys[int(choice) - 1] except (ValueError, IndexError): print("无效选择。") continue current = section[key] print(f"当前值:{toml_literal(current)}") raw = input("新值(直接回车取消;列表可用逗号分隔):") if not raw.strip(): continue try: value = parse_value(raw, current) if not same_kind(current, value): raise ValueError(f"类型不匹配,需要 {type(current).__name__}") editor.replace(task_index, key, value) print(f"已暂存:{key} = {toml_literal(value)}") except (KeyError, TypeError, ValueError) as exc: print(f"修改失败:{exc}") def interactive(editor: ConfigEditor) -> int: while True: print("\n=== MAA Online TOML 配置编辑器 ===") for number, (label, _, _) in enumerate(editor.sections(), 1): print(f"{number:2}. {label}") print(" s. 保存并验证" + (" *" if editor.changed else "")) print(" q. 退出") choice = input("请选择:").strip().lower() if choice in {"q", "quit", "退出"}: if editor.changed: confirm = input("存在未保存修改,确认丢弃?[y/N] ").strip().lower() if confirm not in {"y", "yes", "是"}: continue return 0 if choice in {"s", "save", "保存"}: if not editor.changed: print("没有需要保存的修改。") continue try: backup = editor.save() print(f"保存成功;备份:{backup}") except Exception as exc: print(f"保存失败:{exc}") continue try: label, task_index, _ = editor.sections()[int(choice) - 1] except (ValueError, IndexError): print("无效选择。") continue edit_section(editor, task_index, label) def main() -> int: parser = argparse.ArgumentParser(description="交互式编辑 MAA Online TOML 配置") parser.add_argument("--file", type=Path, default=DEFAULT_FILE, help="要编辑的 TOML 文件") parser.add_argument("--no-maa-validate", action="store_true", help="保存时不运行 MAA dry-run") args = parser.parse_args() if not args.file.is_file(): parser.error(f"配置文件不存在:{args.file}") editor = ConfigEditor(args.file, maa_validate=not args.no_maa_validate) try: return interactive(editor) except (EOFError, KeyboardInterrupt): print("\n已取消,未保存的修改不会写入文件。") return 130 if __name__ == "__main__": raise SystemExit(main())