lllin000_PaperForge/update.py

362 lines
12 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
PaperForge Lite 更新器
一键更新推荐:
python update.py
高级用法:
python update.py --check # 仅检查,不安装
python update.py --dry-run # 预览更新内容
python update.py --force # 强制更新,不提示确认
安全保证
- 只更新代码文件绝不触碰用户数据
- 更新前自动备份
- 失败自动回滚
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.request import urlopen, Request
from urllib.error import URLError
# =============================================================================
# 配置
# =============================================================================
GITHUB_REPO = "LLLin000/PaperForge"
GITHUB_API = f"https://api.github.com/repos/{GITHUB_REPO}"
GITHUB_ZIP = f"https://github.com/{GITHUB_REPO}/archive/refs/heads/master.zip"
# Windows 编码修复
if sys.platform == "win32":
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8")
# 可更新路径(代码文件)
UPDATEABLE_PATHS = ["skills", "pipeline", "command", "scripts"]
# =============================================================================
# 工具函数
# =============================================================================
def color(text: str, c: str = "") -> str:
colors = {
"r": "\033[91m", "g": "\033[92m", "y": "\033[93m",
"b": "\033[94m", "c": "\033[96m", "x": "\033[0m",
}
if sys.platform == "win32" and not os.environ.get("FORCE_COLOR"):
return text
return f"{colors.get(c, '')}{text}{colors['x']}"
def log(msg: str, c: str = "") -> None:
print(color(msg, c))
def load_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
2026-04-22 17:44:13 +00:00
def load_vault_config(vault: Path) -> dict:
defaults = {
"system_dir": "99_System",
"resources_dir": "03_Resources",
"literature_dir": "Literature",
"control_dir": "LiteratureControl",
"base_dir": "05_Bases",
}
data = load_json(vault / "paperforge.json")
nested = data.get("vault_config", {}) if isinstance(data.get("vault_config"), dict) else {}
return {**defaults, **nested, **{k: v for k, v in data.items() if k in defaults and v}}
def protected_paths(vault: Path) -> set[str]:
cfg = load_vault_config(vault)
pf = f"{cfg['system_dir']}/PaperForge"
return {
cfg["resources_dir"],
cfg["base_dir"],
f"{pf}/ocr",
f"{pf}/exports",
f"{pf}/indexes",
f"{pf}/candidates",
".env",
"AGENTS.md",
}
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest() if path.exists() else ""
def is_git(path: Path) -> bool:
return (path / ".git").is_dir()
def git(cmd: list[str], cwd: Path) -> subprocess.CompletedProcess:
return subprocess.run(["git"] + cmd, cwd=cwd, capture_output=True, text=True, encoding="utf-8")
# =============================================================================
# 版本检测
# =============================================================================
def local_version(vault: Path) -> str:
return load_json(vault / "paperforge.json").get("version", "unknown")
def remote_version() -> str | None:
"""从 GitHub 获取远程版本"""
try:
req = Request(
f"{GITHUB_API}/contents/paperforge.json",
headers={"Accept": "application/vnd.github.v3+json", "User-Agent": "PaperForge"},
)
with urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
req2 = Request(data["download_url"], headers={"User-Agent": "PaperForge"})
with urlopen(req2, timeout=10) as resp2:
return json.loads(resp2.read()).get("version")
except Exception:
return None
def parse_v(v: str) -> tuple:
return tuple(int(x) for x in v.split(".") if x.isdigit())
def newer(a: str, b: str) -> bool:
try:
return parse_v(a) > parse_v(b)
except ValueError:
return a != b
# =============================================================================
# 核心更新逻辑
# =============================================================================
def scan_updates(vault: Path, source: Path) -> list[tuple[Path, Path, str]]:
"""扫描需要更新的文件,返回 (src, dst, action) 列表"""
updates = []
2026-04-22 17:44:13 +00:00
protected = protected_paths(vault)
for name in UPDATEABLE_PATHS:
src_dir = source / name
if not src_dir.exists():
continue
for src in src_dir.rglob("*"):
if not src.is_file():
continue
rel = src.relative_to(source)
dst = vault / rel
rel_str = str(rel).replace("\\", "/")
2026-04-22 17:44:13 +00:00
if any(rel_str.startswith(p) for p in protected):
continue
if dst.exists():
if sha256(src) != sha256(dst):
updates.append((src, dst, "UPDATE"))
else:
updates.append((src, dst, "NEW"))
return updates
def do_backup(vault: Path, updates: list) -> Path | None:
"""备份将被覆盖的文件"""
backup_dir = vault / f".backup_{datetime.now():%Y%m%d_%H%M%S}"
backup_dir.mkdir(exist_ok=True)
count = 0
for src, dst, action in updates:
if action == "UPDATE" and dst.exists():
bp = backup_dir / dst.relative_to(vault)
bp.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(dst, bp)
count += 1
if count:
log(f"[INFO] 已备份 {count} 个文件到 {backup_dir.name}", "c")
return backup_dir if count else None
def do_update(vault: Path, updates: list) -> bool:
"""执行文件更新"""
try:
for src, dst, action in updates:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
return True
except Exception as e:
log(f"[ERR] 更新失败: {e}", "r")
return False
def do_rollback(vault: Path, backup_dir: Path) -> None:
"""从备份恢复"""
log("[INFO] 正在回滚...", "b")
for bp in backup_dir.rglob("*"):
if bp.is_file():
orig = vault / bp.relative_to(backup_dir)
orig.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(bp, orig)
log("[OK] 回滚完成", "g")
# =============================================================================
# 更新模式Git Pull
# =============================================================================
def update_git(vault: Path, dry: bool = False) -> bool:
if not is_git(vault):
log("[ERR] 不是 git 仓库", "r")
return False
status = git(["status", "--short"], vault)
if status.stdout.strip():
log("[WARN] 有未提交的更改,请先提交或储藏", "y")
return False
if dry:
log("[WOULD] git pull origin master", "y")
return True
log("[INFO] 执行 git pull...", "b")
r = git(["pull", "origin", "master"], vault)
if r.returncode != 0:
log(f"[ERR] git pull 失败: {r.stderr}", "r")
return False
log("[OK] git pull 成功", "g")
if r.stdout.strip():
print(r.stdout)
return True
# =============================================================================
# 更新模式Zip 下载
# =============================================================================
def update_zip(vault: Path, dry: bool = False) -> bool:
log("[INFO] 下载更新包...", "b")
tmp = Path(tempfile.mkdtemp(prefix="pf_update_"))
zip_path = tmp / "update.zip"
try:
if not dry:
req = Request(GITHUB_ZIP, headers={"User-Agent": "PaperForge"})
with urlopen(req, timeout=60) as resp:
zip_path.write_bytes(resp.read())
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(tmp / "extracted")
dirs = [d for d in (tmp / "extracted").iterdir() if d.is_dir()]
source = dirs[0] if dirs else None
else:
source = tmp / "PaperForge-master"
if not source or not source.exists():
log("[ERR] 解压失败", "r")
return False
return apply_updates(vault, source, dry)
except Exception as e:
log(f"[ERR] 下载失败: {e}", "r")
return False
finally:
if not dry:
shutil.rmtree(tmp, ignore_errors=True)
# =============================================================================
# 通用更新应用
# =============================================================================
def apply_updates(vault: Path, source: Path, dry: bool = False) -> bool:
updates = scan_updates(vault, source)
if not updates:
log("[OK] 所有文件已是最新", "g")
return True
log(f"\n[INFO] 发现 {len(updates)} 个文件需要更新:", "b")
for src, dst, action in updates:
log(f" [{action}] {dst.relative_to(vault)}", "g" if action == "NEW" else "y")
if dry:
log("\n[INFO] 预览完成,未实际写入", "c")
return True
backup = do_backup(vault, updates)
if do_update(vault, updates):
log(f"\n[OK] 更新完成!共 {len(updates)} 个文件", "g")
return True
if backup:
do_rollback(vault, backup)
return False
# =============================================================================
# 主流程
# =============================================================================
def main() -> int:
parser = argparse.ArgumentParser(description="PaperForge Lite 更新器")
parser.add_argument("--check", action="store_true", help="仅检查版本,不更新")
parser.add_argument("--dry-run", action="store_true", help="预览更新内容")
parser.add_argument("--force", action="store_true", help="强制更新,不提示")
parser.add_argument("--vault", default=".", help="Vault 路径")
args = parser.parse_args()
vault = Path(args.vault).resolve()
if not (vault / "paperforge.json").exists():
log(f"[ERR] 未找到 paperforge.json: {vault}", "r")
return 1
local = local_version(vault)
remote = remote_version()
if args.check:
log(f"本地版本: {local}", "c")
log(f"远程版本: {remote or 'unknown'}", "c")
if remote and newer(remote, local):
log(f"[INFO] 有新版本可用: {remote}", "y")
else:
log("[OK] 已是最新", "g")
return 0
# 默认行为:检查 + 更新
log("=" * 50, "b")
log("PaperForge Lite 更新", "b")
log("=" * 50, "b")
log(f"本地版本: {local}", "c")
log(f"远程版本: {remote or 'unknown'}", "c")
if not remote or not newer(remote, local):
log("[OK] 当前已是最新版本", "g")
return 0
log(f"\n[INFO] 发现新版本: {local} -> {remote}", "y")
if not args.force and not args.dry_run:
log("[WARN] 更新前建议备份 Vault", "y")
ans = input(color("确认更新? [y/N]: ", "y")).strip().lower()
if ans not in ("y", "yes"):
log("[INFO] 已取消", "c")
return 0
# 选择更新方式
if is_git(vault):
success = update_git(vault, args.dry_run)
else:
success = update_zip(vault, args.dry_run)
if success and not args.dry_run:
log("\n[OK] 更新完成!请重启 Obsidian", "g")
return 0 if success else 1
if __name__ == "__main__":
sys.exit(main())