Files

257 lines
9.8 KiB
Python
Executable File

#!/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<indent>\s*)(?P<key>[A-Za-z0-9_]+)(?P<space>\s*=\s*)(?P<value>.*?)(?P<newline>\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())