From 0505fd2fdaba14914da7b0c41b2a69f7645c8876 Mon Sep 17 00:00:00 2001 From: Research Assistant Date: Mon, 27 Apr 2026 21:34:03 +0800 Subject: [PATCH] feat: add paperforge setup CLI command, one-click install script, sync __version__ to 1.4.0, detect local version from pip metadata --- paperforge/__init__.py | 2 +- paperforge/cli.py | 8 + paperforge/setup_wizard.py | 1521 ++++++++++++++++++++++++++++++++ paperforge/worker/update.py | 14 +- scripts/install-paperforge.ps1 | 66 ++ setup_wizard.py | 4 +- 6 files changed, 1607 insertions(+), 8 deletions(-) create mode 100644 paperforge/setup_wizard.py create mode 100644 scripts/install-paperforge.ps1 diff --git a/paperforge/__init__.py b/paperforge/__init__.py index e9497654..932acb56 100644 --- a/paperforge/__init__.py +++ b/paperforge/__init__.py @@ -1,3 +1,3 @@ """paperforge — PaperForge Lite package.""" -__version__ = "1.0.0" +__version__ = "1.4.0" diff --git a/paperforge/cli.py b/paperforge/cli.py index ebe85312..df7e5fbf 100644 --- a/paperforge/cli.py +++ b/paperforge/cli.py @@ -219,6 +219,9 @@ def build_parser() -> argparse.ArgumentParser: # update sub.add_parser("update", help="Update PaperForge Lite to the latest version") + # setup wizard + sub.add_parser("setup", help="Run the setup wizard (Textual-based)") + return parser @@ -358,6 +361,11 @@ def main(argv: list[str] | None = None) -> int: return run_update(vault) + if args.command == "setup": + from paperforge.setup_wizard import main as run_setup + + return run_setup(sys.argv[2:]) + print(f"Error: unknown command {args.command}", file=sys.stderr) return 1 diff --git a/paperforge/setup_wizard.py b/paperforge/setup_wizard.py new file mode 100644 index 00000000..f7f804a1 --- /dev/null +++ b/paperforge/setup_wizard.py @@ -0,0 +1,1521 @@ +#!/usr/bin/env python3 +""" +PaperForge Lite Setup Wizard (Textual Step-by-Step) +==================================================== +基于 Textual ContentSwitcher + Tree + ProgressBar 的步骤向导。 + +Usage: + python setup_wizard.py --vault /path/to/vault +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import subprocess +import sys +import webbrowser +from pathlib import Path + +if sys.platform == "win32": + import winreg +else: + winreg = None + +from textual.app import App, ComposeResult +from textual.containers import Container, Horizontal, Vertical +from textual.message import Message +from textual.reactive import reactive +from textual.widgets import ( + Button, + ContentSwitcher, + Footer, + Header, + Markdown, + ProgressBar, + Static, + Tree, +) + +# ============================================================================= +# Agent Platform Configurations +# ============================================================================= + +AGENT_CONFIGS = { + "opencode": { + "name": "OpenCode", + "skill_dir": ".opencode/skills", + "command_dir": ".opencode/command", + "config_file": None, + }, + "cursor": {"name": "Cursor", "skill_dir": ".cursor/skills", "config_file": ".cursor/settings.json"}, + "claude": {"name": "Claude Code", "skill_dir": ".claude/skills", "config_file": ".claude/skills.json"}, + "windsurf": {"name": "Windsurf", "skill_dir": ".windsurf/skills", "config_file": None}, + "github_copilot": { + "name": "GitHub Copilot", + "skill_dir": ".github/skills", + "config_file": ".github/copilot-instructions.md", + }, + "cline": {"name": "Cline", "skill_dir": ".clinerules/skills", "config_file": ".clinerules"}, + "augment": {"name": "Augment", "skill_dir": ".augment/skills", "config_file": None}, + "trae": {"name": "Trae", "skill_dir": ".trae/skills", "config_file": None}, +} + + +# ============================================================================= +# Detection Logic (unchanged from previous version) +# ============================================================================= + + +class CheckResult: + def __init__(self, name: str): + self.name = name + self.passed = False + self.detail = "" + self.action_required = False + + +class EnvChecker: + """环境检测器""" + + def __init__(self, vault: Path): + self.vault = vault + self.manual_zotero_path: Path | None = None + self.system_dir: str = "99_System" # 可由用户自定义 + self.results: dict[str, CheckResult] = { + "python": CheckResult("Python 版本"), + "vault": CheckResult("Vault 结构"), + "zotero": CheckResult("Zotero 安装"), + "bbt": CheckResult("Better BibTeX"), + "json": CheckResult("JSON 导出"), + } + + def get_exports_dir(self) -> Path: + """Get exports directory based on user config.""" + return self.vault / self.system_dir / "PaperForge" / "exports" + + def check_python(self) -> CheckResult: + r = self.results["python"] + v = sys.version_info + if v >= (3, 8): + r.passed = True + r.detail = f"Python {v.major}.{v.minor}.{v.micro}" + else: + r.passed = False + r.detail = f"Python {v.major}.{v.minor}.{v.micro} (需要 >= 3.8)" + r.action_required = True + return r + + def check_dependencies(self) -> CheckResult: + r = CheckResult("Python 依赖") + required = {"requests": "requests", "pymupdf": "fitz", "PIL": "PIL"} + missing = [] + for pkg, import_name in required.items(): + try: + __import__(import_name) + except ImportError: + missing.append(pkg) + if not missing: + r.passed = True + r.detail = "所有依赖已安装 (requests, pymupdf, pillow)" + else: + r.passed = False + r.detail = f"缺少依赖: {', '.join(missing)}" + r.action_required = True + return r + + def install_dependencies(self) -> bool: + deps = ["requests", "pymupdf", "pillow"] + try: + subprocess.run([sys.executable, "-m", "pip", "install"] + deps, check=True, capture_output=True) + return True + except subprocess.CalledProcessError: + return False + + def check_vault(self) -> CheckResult: + r = self.results["vault"] + required = [ + f"{self.system_dir}/PaperForge/exports", + f"{self.system_dir}/PaperForge/ocr", + ] + missing = [rel for rel in required if not (self.vault / rel).exists()] + if not missing: + r.passed = True + r.detail = "所有必要目录已就绪" + else: + r.passed = False + r.detail = f"缺少: {', '.join(missing)}" + r.action_required = True + return r + + def _find_zotero(self, manual_path: Path | None = None) -> Path | None: + # 如果提供了手动路径,优先使用 + if manual_path and manual_path.exists(): + return manual_path + + system = platform.system() + if system == "Windows": + # ...existing detection code... + # 1. 注册表检测 (HKEY_LOCAL_MACHINE) + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Zotero") as key: + install_dir, _ = winreg.QueryValueEx(key, "InstallDir") + path = Path(install_dir) / "zotero.exe" + if path.exists(): + return path + except (FileNotFoundError, OSError): + pass + # 2. 注册表检测 (HKEY_CURRENT_USER - 用户级安装) + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Zotero") as key: + install_dir, _ = winreg.QueryValueEx(key, "InstallDir") + path = Path(install_dir) / "zotero.exe" + if path.exists(): + return path + except (FileNotFoundError, OSError): + pass + # 3. 常见安装路径检测 + search_paths = [ + Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "Zotero" / "zotero.exe", + Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)")) / "Zotero" / "zotero.exe", + Path(os.environ.get("LOCALAPPDATA", r"C:\Users\%USERNAME%\AppData\Local")) / "Zotero" / "zotero.exe", + Path.home() / "AppData" / "Local" / "Zotero" / "zotero.exe", + Path.home() / "scoop" / "apps" / "zotero" / "current" / "zotero.exe", # Scoop安装 + ] + for p in search_paths: + if p.exists(): + return p + # 4. 通过 where 命令检测 + try: + result = subprocess.run(["where", "zotero"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + for line in result.stdout.strip().split("\n"): + p = Path(line.strip()) + if p.exists(): + return p + except Exception: + pass + elif system == "Darwin": + search_paths = [ + Path("/Applications/Zotero.app/Contents/MacOS/zotero"), + Path.home() / "Applications" / "Zotero.app" / "Contents" / "MacOS" / "zotero", + ] + for p in search_paths: + if p.exists(): + return p + # 通过 which 检测 + try: + result = subprocess.run(["which", "zotero"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + return Path(result.stdout.strip()) + except Exception: + pass + else: + # Linux + search_paths = [ + Path.home() / ".local" / "share" / "zotero" / "zotero", + Path("/usr/bin/zotero"), + Path("/usr/local/bin/zotero"), + Path("/snap/bin/zotero"), + ] + for p in search_paths: + if p.exists(): + return p + try: + result = subprocess.run(["which", "zotero"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + return Path(result.stdout.strip()) + except Exception: + pass + return None + + def check_zotero(self) -> CheckResult: + r = self.results["zotero"] + path = self._find_zotero() + if path: + r.passed = True + r.detail = str(path) + else: + r.passed = False + r.detail = "未找到 Zotero" + r.action_required = True + return r + + def check_bbt(self) -> CheckResult: + r = self.results["bbt"] + system = platform.system() + bbt_found = False + bbt_path = None + + if system == "Windows": + appdata = os.environ.get("APPDATA", "") + if appdata: + profiles = Path(appdata) / "Zotero" / "Zotero" / "Profiles" + if profiles.exists(): + for profile in profiles.iterdir(): + if profile.is_dir(): + ext_dir = profile / "extensions" + if ext_dir.exists(): + for ext in ext_dir.iterdir(): + if "better-bibtex" in ext.name.lower() or "betterbibtex" in ext.name.lower(): + bbt_found = True + bbt_path = ext + break + elif system == "Darwin": + profiles = Path.home() / "Library" / "Application Support" / "Zotero" / "Profiles" + if profiles.exists(): + for profile in profiles.iterdir(): + ext_dir = profile / "extensions" + if ext_dir.exists(): + for ext in ext_dir.iterdir(): + if "better-bibtex" in ext.name.lower(): + bbt_found = True + bbt_path = ext + break + else: + profiles = Path.home() / ".zotero" / "zotero" / "Profiles" + if profiles.exists(): + for profile in profiles.iterdir(): + ext_dir = profile / "extensions" + if ext_dir.exists(): + for ext in ext_dir.iterdir(): + if "better-bibtex" in ext.name.lower(): + bbt_found = True + bbt_path = ext + break + + if bbt_found: + r.passed = True + r.detail = bbt_path.name if bbt_path else "Better BibTeX" + else: + r.passed = False + r.detail = "未找到 Better BibTeX 插件" + r.action_required = True + return r + + def check_json(self) -> CheckResult: + r = self.results["json"] + exports_dir = self.get_exports_dir() + if not exports_dir.exists(): + r.passed = False + r.detail = f"导出目录不存在: {exports_dir}" + r.action_required = True + return r + + json_files = list(exports_dir.glob("*.json")) + if not json_files: + r.passed = False + r.detail = "未找到 JSON 导出文件" + r.action_required = True + return r + + valid = [] + for jf in json_files: + try: + data = json.loads(jf.read_text(encoding="utf-8")) + # Better BibTeX JSON 是 dict 格式(含 items),也兼容 list 格式 + if isinstance(data, dict) and data.get("items") or isinstance(data, list) and len(data) > 0: + valid.append(jf.name) + except Exception: + pass + + if valid: + r.passed = True + r.detail = f"找到 {len(valid)} 个有效 JSON" + else: + r.passed = False + r.detail = "JSON 文件格式无效" + r.action_required = True + return r + + +# ============================================================================= +# Step Screens +# ============================================================================= + +STEP_TITLES = [ + "欢迎使用 PaperForge", + "选择 AI Agent 平台", + "检查 Python 与依赖", + "检查 Vault 结构", + "安装 Zotero 与链接", + "安装 Better BibTeX", + "配置 JSON 导出", + "部署工作流脚本", + "安装完成", +] + +STEP_IDS = [f"step-{i}" for i in range(len(STEP_TITLES))] + + +class StepScreen(Static): + """单个步骤页面基类""" + + def __init__(self, step_id: str, checker: EnvChecker, **kwargs): + kwargs.setdefault("id", step_id) + super().__init__(**kwargs) + self.step_id = step_id + self.checker = checker + self.step_idx = int(step_id.split("-")[1]) + + def compose(self) -> ComposeResult: + yield Static(f"## {STEP_TITLES[self.step_idx]}", classes="step-title") + yield Static("", id=f"{self.step_id}-status", classes="status-bar") + + def set_status(self, text: str, success: bool | None = None) -> None: + status = self.query_one(f"#{self.step_id}-status", Static) + if success is True: + status.update(f"[green]✓ {text}[/]") + elif success is False: + status.update(f"[red]✗ {text}[/]") + else: + status.update(text) + + +class WelcomeStep(StepScreen): + """Step 0: 欢迎页""" + + def compose(self) -> ComposeResult: + yield from super().compose() + yield Static( + r""" + ______ ___ ______ _________________ ___________ _____ _____ + | ___ \/ _ \ | ___ \ ___| ___ \ ___| _ | ___ \ __ \| ___| + | |_/ / /_\ \| |_/ / |__ | |_/ / |_ | | | | |_/ / | \/| |__ + | __/| _ || __/| __|| /| _| | | | | /| | __ | __| + | | | | | || | | |___| |\ \| | \ \_/ / |\ \| |_\ \| |___ + \_| \_| |_/\_| \____/\_| \_\_| \___/\_| \_|\____/\____/ + + [+] Forge Your Knowledge Into Power [+] + """, + classes="logo", + ) + yield Markdown(""" +**PaperForge Lite** 是一个连接 Zotero 与 Obsidian 的文献工作流工具。 + +安装向导将引导你完成以下配置: + +1. 确认 Python 版本 (>= 3.8) +2. 配置 Vault 目录结构 +3. 创建 Zotero 数据链接 +4. 安装 Better BibTeX 插件 +5. 配置 JSON 自动导出 +6. 部署工作流文件 + +点击 **开始安装** 继续。 + """) + yield Button("▶ 开始安装", id="btn-start", variant="primary") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-start": + self.app.post_message(StepPassed(self.step_idx)) + + +class AgentPlatformStep(StepScreen): + """Step 1: 选择 AI Agent 平台""" + + def compose(self) -> ComposeResult: + yield from super().compose() + yield Markdown(""" +PaperForge 需要知道你使用哪个 **AI Agent** 来执行精读命令。 + +这将决定 Skill 文件安装到哪里: +- **OpenCode** -> `.opencode/skills/` +- **Cursor** -> `.cursor/skills/` +- **Claude Code** -> `.claude/skills/` +- 其他... + +选择你的 Agent 平台: + """) + for i, (key, cfg) in enumerate(AGENT_CONFIGS.items()): + yield Button( + f"{i+1}. {cfg['name']}", + id=f"btn-agent-{key}", + variant="default", + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + btn_id = event.button.id + if btn_id and btn_id.startswith("btn-agent-"): + agent_key = btn_id.replace("btn-agent-", "") + cfg = AGENT_CONFIGS.get(agent_key) + if cfg: + # 保存选择到 app + self.app.agent_config = cfg + self.app.agent_key = agent_key + self.set_status(f"已选择: {cfg['name']}", True) + self.app.post_message(StepPassed(self.step_idx)) + + +class PythonStep(StepScreen): + """Step 2: Python 版本与依赖""" + + def compose(self) -> ComposeResult: + yield from super().compose() + yield Markdown(""" +PaperForge 需要 **Python 3.8+** 以及以下 Python 包: +- `requests` — HTTP 请求 +- `pymupdf` — PDF 处理 +- `pillow` — 图像处理 + +点击 **一键检测** 检查环境。 + """) + yield Horizontal( + Button("🔍 一键检测", id="btn-check-python", variant="primary"), + Button("📦 安装依赖", id="btn-install-deps", variant="default"), + Button("⬇ 下载 Python", id="btn-dl-python", variant="default"), + id="btn-row", + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-check-python": + py_result = self.checker.check_python() + self.set_status(f"Python: {py_result.detail}", py_result.passed) + if py_result.passed: + dep_result = self.checker.check_dependencies() + self.set_status(f"依赖: {dep_result.detail}", dep_result.passed) + if dep_result.passed: + self.app.post_message(StepPassed(self.step_idx)) + elif event.button.id == "btn-install-deps": + if self.checker.install_dependencies(): + self.set_status("依赖安装成功", True) + # 重新检测 + dep_result = self.checker.check_dependencies() + if dep_result.passed: + self.app.post_message(StepPassed(self.step_idx)) + else: + self.set_status("依赖安装失败,请手动运行: pip install requests pymupdf pillow", False) + elif event.button.id == "btn-dl-python": + webbrowser.open("https://www.python.org/downloads/") + + +class VaultStep(StepScreen): + """Step 3: Vault 目录结构配置""" + + def __init__(self, step_id: str, checker: EnvChecker, vault: str = "", **kwargs): + kwargs.setdefault("id", step_id) + super().__init__(step_id=step_id, checker=checker, **kwargs) + self.step_id = step_id + self.checker = checker + self.step_idx = int(step_id.split("-")[1]) + self._vault = vault + + def compose(self) -> ComposeResult: + yield from super().compose() + yield Markdown(""" +PaperForge 需要知道你的 **Obsidian Vault 位置**,以及你想要的目录结构。 + +你可以保留默认名称,也可以自定义。 + """) + from textual.widgets import Input + + yield Static("Obsidian Vault 路径 (绝对路径):", classes="step-title") + yield Input(value=self._vault, placeholder="D:\\Documents\\MyVault", id="input-vault-path") + yield Static("", id="vault-error", classes="status-bar") + yield Markdown(""" +--- + +**默认目录结构:** +``` +Obsidian Vault/ +├── 资源文件夹/ ← 文献笔记和资源 +│ ├── 文献子文件夹/ ← 存放正式文献卡片 +│ └── 文献索引/ ← 状态跟踪 +└── 系统文件夹/ ← 系统文件 + └── PaperForge/ ← 导出和 OCR +``` + +修改下方名称(留空使用默认值): + """) + yield Static("系统文件夹名称:", classes="step-title") + yield Input(value="99_System", id="input-system-dir") + yield Static("资源文件夹名称:", classes="step-title") + yield Input(value="03_Resources", id="input-resources-dir") + yield Static("文献子文件夹名称:", classes="step-title") + yield Input(value="Literature", id="input-literature-dir") + yield Static("文献索引文件夹名称:", classes="step-title") + yield Input(value="LiteratureControl", id="input-control-dir") + yield Static("Obsidian Base 文件夹名称:", classes="step-title") + yield Input(value="05_Bases", id="input-base-dir") + yield Horizontal( + Button("✓ 确认并创建目录", id="btn-setup-vault", variant="primary"), + id="btn-row", + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-setup-vault": + from textual.widgets import Input + + # 获取 Vault 路径 + vault_input = self.query_one("#input-vault-path", Input).value.strip() + if not vault_input: + self.query_one("#vault-error", Static).update("[red]请填写 Obsidian Vault 的绝对路径[/]") + return + + vault_path = Path(vault_input) + if not vault_path.exists(): + self.query_one("#vault-error", Static).update(f"[red]该目录不存在: {vault_path}[/]") + return + + # 更新 app 的 vault 路径 + self.app.vault = vault_path + self.checker.vault = vault_path + + # 获取目录名称 + system_dir = self.query_one("#input-system-dir", Input).value.strip() or "99_System" + resources_dir = self.query_one("#input-resources-dir", Input).value.strip() or "03_Resources" + literature_dir = self.query_one("#input-literature-dir", Input).value.strip() or "Literature" + control_dir = self.query_one("#input-control-dir", Input).value.strip() or "LiteratureControl" + base_dir = self.query_one("#input-base-dir", Input).value.strip() or "05_Bases" + + # 解析路径(支持名称、相对路径、绝对路径) + def resolve_path(base: Path, path_str: str) -> Path: + p = Path(path_str) + if p.is_absolute(): + return p + else: + return base / p + + system_path = resolve_path(vault_path, system_dir) + resources_path = resolve_path(vault_path, resources_dir) + base_path = resolve_path(vault_path, base_dir) + + # 保存配置 + self.app.vault_config = { + "vault_path": str(vault_path), + "system_dir": system_dir, + "resources_dir": resources_dir, + "literature_dir": literature_dir, + "control_dir": control_dir, + "base_dir": base_dir, + "paperforge_path": str(system_path / "PaperForge"), + "literature_path": str(resources_path / literature_dir), + "base_path": str(base_path), + } + + # 创建目录 + dirs_to_create = [ + resources_path / control_dir / "library-records", + base_path, + system_path / "PaperForge" / "exports", + system_path / "PaperForge" / "ocr", + ] + + created = [] + for d in dirs_to_create: + d.mkdir(parents=True, exist_ok=True) + created.append(str(d.relative_to(vault_path))) + + # 同步更新 checker + self.checker.system_dir = system_dir + + self.query_one("#vault-error", Static).update("") + self.set_status(f"已创建 {len(created)} 个目录", True) + self.app.post_message(StepPassed(self.step_idx)) + + +class ZoteroStep(StepScreen): + """Step 4: Zotero 数据目录链接""" + + def compose(self) -> ComposeResult: + yield from super().compose() + # 自动检测 Zotero 数据目录 + detected = self._detect_zotero_data() + default_value = str(detected) if detected else "" + + yield Markdown(""" +**Zotero 数据目录**存放了你的文献数据库和 PDF 附件。 + +向导将创建目录链接,让 PaperForge 能读取你的 PDF: +``` +你的 Vault/ + └── [系统目录]/ + └── Zotero/ ← 链接(自动创建) + ↓ junction + 你的 Zotero 数据目录/ ← 你填这里 + ├── zotero.sqlite + └── storage/ +``` + +**请填写你的 Zotero 数据目录路径:** +- 这是 Zotero 存放数据库的地方,不是 Vault 里的路径 +- Windows 通常是 `C:/Users/你的用户名/Zotero` +- macOS 通常是 `~/Zotero` + +> ⚠️ **不要**填 Vault 里面的路径,也不要在这里创建新文件夹 + """) + from textual.widgets import Input + + username = os.environ.get("USERNAME", os.environ.get("USER", "YourName")) + yield Static("Zotero 数据目录:", classes="step-title") + yield Input( + value=default_value, + placeholder=f"C:/Users/{username}/Zotero", + id="input-zotero-data", + ) + yield Horizontal( + Button("🔗 创建目录链接", id="btn-link-zotero", variant="primary"), + Button("⬇ 下载 Zotero", id="btn-dl-zotero", variant="default"), + id="btn-row", + ) + + def _detect_zotero_data(self) -> Path | None: + """Build default Zotero data path from current username.""" + home = Path.home() + # Default: C:/Users//Zotero (Windows) or ~/Zotero (Unix) + default = home / "Zotero" + if default.exists() and (default / "zotero.sqlite").exists(): + return default + return default # Return anyway as default value + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-link-zotero": + from textual.widgets import Input + + path_str = self.query_one("#input-zotero-data", Input).value.strip() + if not path_str: + self.set_status("请填写 Zotero 数据目录路径", False) + return + + zotero_data = Path(path_str) + if not zotero_data.exists(): + self.set_status("路径不存在,请检查路径是否正确", False) + return + if not (zotero_data / "zotero.sqlite").exists(): + self.set_status("未找到 zotero.sqlite,请确认这是 Zotero 数据目录", False) + return + if not (zotero_data / "storage").exists(): + self.set_status("未找到 storage 文件夹,请确认这是 Zotero 数据目录", False) + return + + # 检查是否在 Vault 内部 + vault = self.checker.vault + is_inside_vault = False + try: + zotero_data.resolve().relative_to(vault.resolve()) + is_inside_vault = True + except ValueError: + pass + + if is_inside_vault: + # 在 Vault 内部,直接通过 + self.app.zotero_data_dir = str(zotero_data) + self.set_status("Zotero 数据目录已确认", True) + self.app.post_message(StepPassed(self.step_idx)) + return + + # 在 Vault 外部,创建 Junction + system_dir = getattr(self.app, "vault_config", {}).get("system_dir", "99_System") + junction_path = vault / system_dir / "Zotero" + + # Remove existing + if junction_path.exists() or junction_path.is_symlink(): + try: + if sys.platform == "win32": + subprocess.run(["cmd", "/c", "rmdir", str(junction_path)], check=True, capture_output=True) + else: + junction_path.unlink() + except Exception: + pass + + try: + junction_path.parent.mkdir(parents=True, exist_ok=True) + if sys.platform == "win32": + subprocess.run( + ["cmd", "/c", "mklink", "/J", str(junction_path), str(zotero_data)], + check=True, + capture_output=True, + shell=False, + ) + else: + junction_path.symlink_to(zotero_data, target_is_directory=True) + self.app.zotero_data_dir = str(zotero_data) + self.app.zotero_link = str(junction_path) + self.set_status("链接已创建", True) + self.app.post_message(StepPassed(self.step_idx)) + except Exception as e: + self.set_status(f"创建链接失败: {e}", False) + + elif event.button.id == "btn-dl-zotero": + webbrowser.open("https://www.zotero.org/download/") + + +class BBTStep(StepScreen): + """Step 5: Better BibTeX""" + + def compose(self) -> ComposeResult: + yield from super().compose() + yield Markdown(""" +**Better BibTeX (BBT)** 是 Zotero 的插件,用于生成 citation key 和自动导出 JSON。 + +**安装步骤:** +1. 下载 BBT 插件 +2. Zotero → 工具 → 插件 +3. 齿轮图标 → Install Plugin From File... +4. 选择下载的 `.xpi` 文件 → 重启 Zotero + +安装完成后点击 **检测**。 + """) + yield Horizontal( + Button("🔍 自动检测", id="btn-check-bbt", variant="primary"), + Button("⬇ 下载 BBT", id="btn-dl-bbt", variant="default"), + Button("📷 查看安装截图", id="btn-img-bbt", variant="default"), + id="btn-row", + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-check-bbt": + result = self.checker.check_bbt() + self.set_status(result.detail, result.passed) + if result.passed: + self.app.post_message(StepPassed(self.step_idx)) + elif event.button.id == "btn-dl-bbt": + webbrowser.open("https://retorque.re/zotero-better-bibtex/") + elif event.button.id == "btn-img-bbt": + self.app.open_screenshot("bbt-install.png") + + +class JsonStep(StepScreen): + """Step 5: JSON 导出配置""" + + def compose(self) -> ComposeResult: + yield from super().compose() + system_dir = getattr(self.app, "vault_config", {}).get("system_dir", "99_System") + yield Markdown(f""" +**Better BibTeX 自动导出**是 PaperForge 的数据来源。 + +**配置步骤:** +1. Zotero → 文件 → 导出库... +2. 格式选择 **Better BibTeX** +3. 保存到:`{system_dir}/PaperForge/exports/` +4. **[必须] 勾选 "保持更新"** — 这是自动同步的关键! + +> ⚠️ **重要**:如果不勾选"保持更新",Zotero 新增文献后 PaperForge 不会自动发现,需要每次手动重新导出。 + +📁 **子分类与 Base 管理** + +你可以根据 Zotero 收藏夹结构,灵活决定如何导出: + +**方案 A:分开管理(推荐)** +为每个子分类分别创建 JSON: +``` +Zotero 收藏夹结构 +├── 骨科 +│ ├── 关节外科 → 导出为 orthopedic-joint.json +│ └── 脊柱外科 → 导出为 orthopedic-spine.json +└── 运动医学 + └── 膝盖损伤 → 导出为 sports-knee.json +``` +每个 JSON 对应一个独立的 Obsidian Base 视图,可分别配置 OCR 和精读队列。 + +**方案 B:统一管理** +直接导出整个父级收藏夹: +``` +骨科(含所有子分类) → 导出为 orthopedic.json +``` +适合分类较少、希望统一查看的场景。 + +> 💡 建议:先试用方案 A,分类多了再调整。 + """) + yield Horizontal( + Button("🔍 自动检测", id="btn-check-json", variant="primary"), + id="btn-row", + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-check-json": + result = self.checker.check_json() + self.set_status(result.detail, result.passed) + if result.passed: + self.app.post_message(StepPassed(self.step_idx)) + + +class DeployStep(StepScreen): + """Step 7: 部署脚本和配置""" + + def compose(self) -> ComposeResult: + yield from super().compose() + yield Markdown(""" +**最后一步:部署工作流脚本和配置文件。** + +向导将自动完成以下操作: +1. **复制脚本** — 将 pipeline worker 脚本部署到你的 Vault +2. **创建 .env** — 配置文件存放 API Key(PaddleOCR 等) +3. **创建 paperforge.json** — 版本和路径配置 +4. **安装命令** — 为 Agent 添加快捷命令 + +这些操作不会覆盖你的数据文件。 + """) + from textual.widgets import Input + + yield Static("PaddleOCR API Key:", classes="step-title") + yield Input(placeholder="粘贴你的 PaddleOCR API Key", id="input-api-key") + yield Static("PaddleOCR API URL:", classes="step-title") + yield Input( + value="https://paddleocr.aistudio-app.com/api/v2/ocr/jobs", + placeholder="https://paddleocr.aistudio-app.com/api/v2/ocr/jobs", + id="input-api-url", + ) + yield Horizontal( + Button("🚀 一键部署", id="btn-deploy", variant="primary"), + id="btn-row", + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-deploy": + # 安全检查:前面步骤必须全部通过 + step_states = self.app.step_states + required_steps = [ + (1, "选择 Agent 平台"), + (2, "Python 环境检查"), + (3, "Vault 目录配置"), + (4, "Zotero 数据目录链接"), + (5, "Better BibTeX 插件"), + (6, "JSON 导出配置"), + ] + incomplete = [] + for idx, name in required_steps: + if not step_states[idx]: + incomplete.append(f"步骤 {idx}: {name}") + + if incomplete: + self.set_status( + "[无法部署] 以下步骤未完成:\n" + "\n".join(incomplete) + "\n请先返回并完成上述步骤", False + ) + return + + self.set_status("正在部署...", None) + success = self._deploy() + if success: + self.set_status("部署完成!", True) + self.app.post_message(StepPassed(self.step_idx)) + else: + self.set_status("部署过程中出现错误,请检查上方日志", False) + + def _deploy(self) -> bool: + """Deploy scripts and create config files.""" + vault = self.checker.vault + vault_config = getattr(self.app, "vault_config", {}) + system_dir = vault_config.get("system_dir", "99_System") + resources_dir = vault_config.get("resources_dir", "03_Resources") + literature_dir = vault_config.get("literature_dir", "Literature") + control_dir = vault_config.get("control_dir", "LiteratureControl") + base_dir = vault_config.get("base_dir", "05_Bases") + + def apply_user_paths(text: str, skill_dir_value: str = "") -> str: + agent_config_dir = str(Path(skill_dir_value or ".opencode/skills").parent).replace("\\", "/") + replacements = { + "": system_dir, + "": resources_dir, + "": literature_dir, + "": control_dir, + "": base_dir, + "": skill_dir_value, + "": agent_config_dir, + "99_System/PaperForge": f"{system_dir}/PaperForge", + "99_System\\PaperForge": f"{system_dir}\\PaperForge", + "99_System/Zotero": f"{system_dir}/Zotero", + "99_System\\Zotero": f"{system_dir}\\Zotero", + "03_Resources/LiteratureControl": f"{resources_dir}/{control_dir}", + "03_Resources\\LiteratureControl": f"{resources_dir}\\{control_dir}", + "03_Resources/Literature": f"{resources_dir}/{literature_dir}", + "03_Resources\\Literature": f"{resources_dir}\\{literature_dir}", + ".opencode/skills": skill_dir_value or ".opencode/skills", + ".opencode\\skills": (skill_dir_value or ".opencode/skills").replace("/", "\\"), + } + for old, new in replacements.items(): + text = text.replace(old, new) + return text + + # 1. 获取 agent 配置 + agent_config = getattr(self.app, "agent_config", None) + if not agent_config: + self.set_status("错误:未选择 Agent 平台", False) + return False + + skill_dir = agent_config.get("skill_dir", ".opencode/skills") + + # 2. 确定安装包根目录(wizard 所在目录的父目录) + wizard_dir = Path(__file__).parent.resolve() + # 如果 wizard 在 github-release/ 下,repo_root 就是 github-release/ + # 如果 wizard 在 scripts/ 下,repo_root 是父目录 + if (wizard_dir / "paperforge").exists(): + repo_root = wizard_dir + elif (wizard_dir.parent / "paperforge").exists(): + repo_root = wizard_dir.parent + elif (wizard_dir / "pipeline").exists(): + repo_root = wizard_dir + elif (wizard_dir.parent / "pipeline").exists(): + repo_root = wizard_dir.parent + else: + self.set_status(f"错误:找不到安装包文件。请在 PaperForge 解压目录下运行此向导。当前: {wizard_dir}", False) + return False + + # 3. 创建目录(使用用户自定义路径) + pf_path = vault / system_dir / "PaperForge" + dirs = [ + pf_path / "exports", + pf_path / "ocr", + pf_path / "config", + pf_path / "worker/scripts", + vault / resources_dir / literature_dir, + vault / resources_dir / control_dir / "library-records", + vault / base_dir, + vault / skill_dir / "literature-qa/scripts", + vault / skill_dir / "literature-qa/chart-reading", + ] + for d in dirs: + d.mkdir(parents=True, exist_ok=True) + + # 4. 复制脚本(从安装包到 Vault) + import shutil + + # Copy worker modules to PaperForge/worker/scripts/ + worker_src = repo_root / "paperforge/worker/sync.py" + worker_dst = pf_path / "worker/scripts/sync.py" + if worker_src.exists(): + import shutil + + shutil.copy2(worker_src, worker_dst) + # Also copy other worker modules + for mod in [ + "ocr.py", + "repair.py", + "status.py", + "deep_reading.py", + "update.py", + "base_views.py", + "__init__.py", + ]: + mod_src = repo_root / "paperforge/worker" / mod + if mod_src.exists(): + shutil.copy2(mod_src, pf_path / "worker/scripts" / mod) + else: + # Fallback to old pipeline location for backward compatibility + worker_src = repo_root / "pipeline/worker/scripts/literature_pipeline.py" + worker_dst = pf_path / "worker/scripts/literature_pipeline.py" + if worker_src.exists(): + shutil.copy2(worker_src, worker_dst) + else: + self.set_status(f"错误:找不到 worker 脚本: {worker_src}", False) + return False + + # Copy ld_deep.py (prefer paperforge/skills, fallback to skills/) + ld_src = repo_root / "paperforge/skills/literature-qa/scripts/ld_deep.py" + if not ld_src.exists(): + ld_src = repo_root / "skills/literature-qa/scripts/ld_deep.py" + ld_dst = vault / skill_dir / "literature-qa/scripts/ld_deep.py" + if ld_src.exists(): + shutil.copy2(ld_src, ld_dst) + else: + self.set_status(f"错误:找不到 ld_deep.py: {ld_src}", False) + return False + + # Copy subagent prompt (prefer paperforge/skills, fallback to skills/) + prompt_src = repo_root / "paperforge/skills/literature-qa/prompt_deep_subagent.md" + if not prompt_src.exists(): + prompt_src = repo_root / "skills/literature-qa/prompt_deep_subagent.md" + prompt_dst = vault / skill_dir / "literature-qa/prompt_deep_subagent.md" + if prompt_src.exists(): + shutil.copy2(prompt_src, prompt_dst) + else: + self.set_status(f"错误:找不到 prompt_deep_subagent.md: {prompt_src}", False) + return False + + # Copy chart-reading guides (prefer paperforge/skills, fallback to skills/) + chart_src = repo_root / "paperforge/skills/literature-qa/chart-reading" + if not chart_src.exists(): + chart_src = repo_root / "skills/literature-qa/chart-reading" + chart_dst = vault / skill_dir / "literature-qa/chart-reading" + if chart_src.exists() and chart_src.is_dir(): + for f in chart_src.glob("*.md"): + shutil.copy2(f, chart_dst / f.name) + + # Copy OpenCode command files when the target platform supports them. + if getattr(self.app, "agent_key", "") == "opencode": + command_src = repo_root / "command" + command_dst = vault / agent_config.get("command_dir", ".opencode/command") + if command_src.exists() and command_src.is_dir(): + command_dst.mkdir(parents=True, exist_ok=True) + for f in command_src.glob("*.md"): + text = apply_user_paths(f.read_text(encoding="utf-8"), skill_dir) + (command_dst / f.name).write_text(text, encoding="utf-8") + + # Copy user-facing docs. AGENTS.md is regenerated below with the chosen paths. + docs_src = repo_root / "docs" + docs_dst = vault / "docs" + if docs_src.exists() and docs_src.is_dir(): + shutil.copytree(docs_src, docs_dst, dirs_exist_ok=True) + for doc in docs_dst.rglob("*.md"): + doc.write_text(apply_user_paths(doc.read_text(encoding="utf-8"), skill_dir), encoding="utf-8") + + # 5. 创建 .env(放到 PaperForge 目录下) + from textual.widgets import Input + + api_key = self.query_one("#input-api-key", Input).value.strip() + api_url = ( + self.query_one("#input-api-url", Input).value.strip() + or "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs" + ) + + if not api_key: + self.set_status("请填写 PaddleOCR API Key", False) + return False + + # 把 .env 放在 PaperForge 目录下 + env_path = pf_path / ".env" + env_content = f"""# PaperForge 配置文件 +# PaddleOCR API Token(从 https://paddleocr.baidu.com 获取) +PADDLEOCR_API_TOKEN={api_key} + +# PaddleOCR API 地址 +PADDLEOCR_JOB_URL={api_url} + +# PaddleOCR 模型(通常不需要修改) +PADDLEOCR_MODEL=PaddleOCR-VL-1.5 + +# Zotero data directory selected during setup +ZOTERO_DATA_DIR={getattr(self.app, 'zotero_data_dir', '')} +""" + env_path.write_text(env_content, encoding="utf-8") + + # Create a minimal domain mapping. The worker will keep this usable even + # before JSON exports exist, and will infer domains from export filenames. + domain_config = pf_path / "config" / "domain-collections.json" + if not domain_config.exists(): + export_domains = [ + {"domain": f.stem, "export_file": f.name, "allowed_collections": []} + for f in sorted((pf_path / "exports").glob("*.json")) + ] + domain_config.write_text( + json.dumps({"domains": export_domains}, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + # 6. 创建 paperforge.json(包含用户自定义路径) + pf_json = vault / "paperforge.json" + existing_config = {} + if pf_json.exists(): + try: + existing_config = json.loads(pf_json.read_text(encoding="utf-8")) + except Exception: + existing_config = {} + existing_config.update( + { + "version": existing_config.get("version", "1.2.0"), + "agent_platform": agent_config.get("name", "OpenCode"), + "agent_key": getattr(self.app, "agent_key", "opencode"), + "skill_dir": skill_dir, + "command_dir": agent_config.get("command_dir", ""), + "system_dir": system_dir, + "resources_dir": resources_dir, + "literature_dir": literature_dir, + "control_dir": control_dir, + "base_dir": base_dir, + "paperforge_path": f"{system_dir}/PaperForge", + "zotero_data_dir": getattr(self.app, "zotero_data_dir", ""), + "zotero_link": getattr(self.app, "zotero_link", f"{system_dir}/Zotero"), + "vault_config": { + "system_dir": system_dir, + "resources_dir": resources_dir, + "literature_dir": literature_dir, + "control_dir": control_dir, + "base_dir": base_dir, + }, + } + ) + pf_json.write_text(json.dumps(existing_config, indent=2, ensure_ascii=False), encoding="utf-8") + + agents_src = repo_root / "AGENTS.md" + agents_dst = vault / "AGENTS.md" + if agents_src.exists(): + agents_text = apply_user_paths(agents_src.read_text(encoding="utf-8"), skill_dir) + agents_dst.write_text(agents_text, encoding="utf-8") + + # 7. 验证文件完整性 + self.set_status("验证文件完整性...", None) + checks = { + "Worker 脚本": worker_dst.exists(), + "精读脚本": ld_dst.exists(), + "精读提示词": prompt_dst.exists(), + "目录结构": (vault / resources_dir / control_dir / "library-records").exists(), + "Base 目录": (vault / base_dir).exists(), + "分类配置": domain_config.exists(), + "导出目录": (pf_path / "exports").exists(), + "OCR 目录": (pf_path / "ocr").exists(), + } + + missing = [k for k, v in checks.items() if not v] + if missing: + self.set_status(f"验证失败: {', '.join(missing)}", False) + return False + + self.set_status("文件验证通过,初始化系统...", True) + + # 8. 运行初始化命令(模拟测试) + try: + # 测试 selection-sync(会报错因为没 JSON,但测试脚本能否运行) + result = subprocess.run( + [sys.executable, str(worker_dst), "--vault", str(vault), "status"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + self.set_status("工作流脚本运行正常", True) + else: + self.set_status(f"脚本测试警告: {result.stderr[:100]}", None) + except Exception as e: + self.set_status(f"脚本测试跳过: {e}", None) + + # 9. 安装 PaperForge 工具包(让 paperforge 命令全局可用) + self.set_status("安装 PaperForge 工具包...", None) + try: + result = subprocess.run( + [sys.executable, "-m", "pip", "install", "-e", str(repo_root)], + capture_output=True, + text=True, + timeout=60, + ) + if result.returncode == 0: + self.set_status("PaperForge 工具包安装完成(paperforge 命令已全局注册)", True) + else: + stderr = result.stderr[:200] if result.stderr else "" + self.set_status(f"pip install 警告(paperforge 命令可能需要手动注册): {stderr}", None) + except Exception as e: + self.set_status(f"pip install 跳过: {e}", None) + + return True + + +class DoneStep(StepScreen): + """Step 8: 完成页""" + + def compose(self) -> ComposeResult: + yield from super().compose() + vault_config = getattr(self.app, "vault_config", {}) + system_dir = vault_config.get("system_dir", "99_System") + worker_cmd = "python -m paperforge sync --vault ." + yield Markdown(f""" +## 安装完成! + +PaperForge Lite 已完成安装和初始化。以下是立即开始使用的步骤: + +### 首次使用步骤: + +**1. 同步 Zotero 文献并生成正式笔记** +```bash +paperforge sync +``` + +如需分阶段执行: +```bash +paperforge sync --selection # 仅同步 Zotero 到 library-records +paperforge sync --index # 仅根据现有 library-records 生成正式笔记 +``` + +**2. 标记精读文献** +在 Obsidian 中打开 library-records 文件,设置: +- `do_ocr: true` +- `analyze: true` + +**3. 运行 OCR** +```bash +paperforge ocr +``` + +如需诊断模式(不实际运行): +```bash +paperforge ocr --diagnose +``` + +**4. 执行精读** +在 OpenCode Agent 中输入: +``` +/pf-deep +``` + +### 已安装的 Agent 命令 + +安装向导已自动将以下命令安装到你的 Agent 中: + +**精读命令:** +| 命令 | 作用 | +|------|------| +| `/pf-deep ` | 完整三阶段精读 | +| `/pf-paper ` | 快速摘要 | + +**Worker 快捷命令:** +| 命令 | 作用 | +|------|------| +| `/pf-sync` | 同步 Zotero 文献并生成正式笔记 | +| `/pf-ocr` | 运行 PDF OCR | +| `/pf-status` | 查看工作流状态 | + +**paperforge 命令(推荐):** +```bash +paperforge status # 查看状态 +paperforge sync # 同步文献并生成笔记 +paperforge ocr # 运行 OCR +paperforge deep-reading # 查看精读队列 +``` + +> **命令迁移说明(v1.1 → v1.2)**:旧命令 `paperforge selection-sync`、`paperforge index-refresh`、`paperforge ocr run` 以及 `/LD-deep`、`/LD-paper`、 `/lp-*` 在 v1.2 中仍兼容,但文档已统一使用新命令名。 + +**Python 脚本命令(备用):** +```bash +{worker_cmd} +``` +| 命令 | 作用 | +|------|------| +| `selection-sync` | 检测新文献 | +| `index-refresh` | 生成正式笔记 | +| `ocr` | PDF OCR | +| `deep-reading` | 查看精读队列 | +| `update` | 检查更新 | + +### 详细文档 + +- 安装后指南:`AGENTS.md` +- GitHub: https://github.com/LLLin000/PaperForge + """) + yield Horizontal( + Button("📖 打开详细指南", id="btn-open-guide", variant="primary"), + Button("🔄 重新检测", id="btn-restart", variant="default"), + id="btn-row", + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-open-guide": + guide_path = self.checker.vault / "docs" / "setup-guide.md" + if guide_path.exists(): + if sys.platform == "win32": + os.startfile(str(guide_path)) + elif sys.platform == "darwin": + subprocess.run(["open", str(guide_path)]) + else: + subprocess.run(["xdg-open", str(guide_path)]) + else: + webbrowser.open("https://github.com/LLLin000/PaperForge/blob/master/docs/setup-guide.md") + elif event.button.id == "btn-restart": + self.app.post_message(RestartWizard()) + + +# ============================================================================= +# Custom Messages +# ============================================================================= + + +class StepPassed(Message): + """步骤通过消息""" + + def __init__(self, step_idx: int): + super().__init__() + self.step_idx = step_idx + + +class RestartWizard(Message): + """重新开始消息""" + + def __init__(self): + super().__init__() + + +# ============================================================================= +# Main App +# ============================================================================= + + +class SetupWizardApp(App): + """PaperForge 安装向导主应用""" + + CSS = """ + Screen { align: center middle; } + + .wizard-container { width: 95%; height: 95%; border: solid green; } + + .sidebar { width: 25%; height: 100%; border: solid gray; padding: 1; } + .sidebar-title { text-align: center; text-style: bold; color: cyan; padding: 1; } + .step-tree { height: 1fr; } + + .main-area { width: 75%; height: 100%; padding: 1; } + .progress-area { height: auto; padding: 0 1; } + .content-area { height: 1fr; border: solid blue; padding: 1; overflow-y: auto; } + + .logo { text-align: center; color: ansi_bright_cyan; text-style: bold; height: auto; } + .step-title { text-style: bold; color: yellow; } + .status-bar { height: auto; padding: 1; } + + .step-content { padding: 1; } + .step-content Markdown { padding: 0 1; } + + #btn-row { height: auto; padding: 1; } + #btn-row Button { margin: 0 1; } + + .done { color: green; } + .current { color: yellow; text-style: bold; } + .pending { color: gray; } + """ + + BINDINGS = [ + ("q", "quit", "退出"), + ("n", "next_step", "下一步"), + ("p", "prev_step", "上一步"), + ] + + current_step = reactive(0) + step_states = reactive([False] * len(STEP_TITLES)) + + def __init__(self, vault: Path): + super().__init__() + self.vault = vault + self.checker = EnvChecker(vault) + self.step_screens: dict[str, StepScreen] = {} + + def compose(self) -> ComposeResult: + yield Header(show_clock=False) + + with Container(classes="wizard-container"): + with Horizontal(): + # 左侧:步骤导航树 + with Vertical(classes="sidebar"): + yield Static("安装步骤", classes="sidebar-title") + tree = Tree("PaperForge Lite", id="step-tree", classes="step-tree") + for i, title in enumerate(STEP_TITLES): + tree.root.add_leaf(f"{i}. {title}") + yield tree + + # 右侧:主内容区 + with Vertical(classes="main-area"): + # 进度条 + with Container(classes="progress-area"): + yield ProgressBar(total=len(STEP_TITLES), show_eta=False, id="progress") + yield Static("Step 0 / 6", id="progress-text", classes="progress-text") + + # 内容切换器 + with ContentSwitcher(id="content-switcher", classes="content-area"): + screens = [ + WelcomeStep("step-0", self.checker), + AgentPlatformStep("step-1", self.checker), + PythonStep("step-2", self.checker), + VaultStep("step-3", self.checker, vault=str(self.vault)), + ZoteroStep("step-4", self.checker), + BBTStep("step-5", self.checker), + JsonStep("step-6", self.checker), + DeployStep("step-7", self.checker), + DoneStep("step-8", self.checker), + ] + for screen in screens: + self.step_screens[screen.step_id] = screen + yield screen + + yield Footer() + + def on_mount(self) -> None: + self._update_step_display() + + def watch_current_step(self, step: int) -> None: + self._update_step_display() + + def watch_step_states(self, states: list[bool]) -> None: + self._update_step_display() + + def _update_step_display(self) -> None: + # 更新 ContentSwitcher + switcher = self.query_one("#content-switcher", ContentSwitcher) + switcher.current = f"step-{self.current_step}" + + # 更新进度条 + progress = self.query_one("#progress", ProgressBar) + progress.advance(self.current_step - progress.progress) + + progress_text = self.query_one("#progress-text", Static) + progress_text.update(f"Step {self.current_step} / {len(STEP_TITLES) - 1}: {STEP_TITLES[self.current_step]}") + + # 更新 Tree 高亮 - 区分已完成、跳过、当前、待处理 + tree = self.query_one("#step-tree", Tree) + for i, node in enumerate(tree.root.children): + if self.step_states[i]: + # 已完成:绿色勾 + node.label = f"[green]✓ {i}. {STEP_TITLES[i]}[/]" + elif i == self.current_step: + # 当前:黄色箭头 + node.label = f"[yellow]▶ {i}. {STEP_TITLES[i]}[/]" + elif i < self.current_step and not self.step_states[i]: + # 跳过(已访问但未完成):灰色跳过标记 + node.label = f"[gray]↷ {i}. {STEP_TITLES[i]}[/]" + else: + # 待处理:灰色圆圈 + node.label = f"[gray]○ {i}. {STEP_TITLES[i]}[/]" + + def action_next_step(self) -> None: + if self.current_step < len(STEP_TITLES) - 1: + self.current_step += 1 + + def action_prev_step(self) -> None: + if self.current_step > 0: + self.current_step -= 1 + + def on_step_passed(self, message: StepPassed) -> None: + """步骤通过,标记完成并自动前进""" + self.step_states[message.step_idx] = True + if message.step_idx < len(STEP_TITLES) - 1: + self.current_step = message.step_idx + 1 + + def on_restart_wizard(self) -> None: + """重新开始""" + self.current_step = 0 + self.step_states = [False] * len(STEP_TITLES) + for screen in self.step_screens.values(): + if hasattr(screen, "set_status"): + screen.set_status("") + + def open_screenshot(self, filename: str) -> None: + """打开截图""" + img_path = self.vault / "docs" / "images" / filename + if img_path.exists(): + if sys.platform == "win32": + os.startfile(str(img_path)) + elif sys.platform == "darwin": + subprocess.run(["open", str(img_path)]) + else: + subprocess.run(["xdg-open", str(img_path)]) + else: + # 截图不存在,打开在线文档 + webbrowser.open(f"https://github.com/LLLin000/PaperForge/blob/master/docs/images/{filename}") + + +# ============================================================================= +# Entry +# ============================================================================= + + +def _find_vault() -> Path | None: + """Find vault by looking for paperforge.json in current or parent dirs.""" + current = Path(".").resolve() + for path in [current, *current.parents]: + if (path / "paperforge.json").exists(): + return path + return None + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="PaperForge Lite 安装向导") + parser.add_argument("--vault", type=Path, default=None, help="Vault 路径(可选,默认当前目录)") + args = parser.parse_args(argv) + + if args.vault: + vault = args.vault.resolve() + else: + # 默认使用当前目录 + vault = Path(".").resolve() + + app = SetupWizardApp(vault) + app.run() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/paperforge/worker/update.py b/paperforge/worker/update.py index e76201c6..1de95cf2 100644 --- a/paperforge/worker/update.py +++ b/paperforge/worker/update.py @@ -265,11 +265,15 @@ def update_via_zip(vault: Path) -> bool: def run_update(vault: Path) -> int: """运行更新检查与安装""" - local_cfg = vault / "paperforge.json" - if not local_cfg.exists(): - logger.error("未找到 paperforge.json") - return 1 - local = json.loads(local_cfg.read_text(encoding="utf-8")).get("version", "unknown") + try: + from importlib.metadata import version as _pkg_ver + local = _pkg_ver("paperforge") + except Exception: + local_cfg = vault / "paperforge.json" + if local_cfg.exists(): + local = json.loads(local_cfg.read_text(encoding="utf-8")).get("version", "unknown") + else: + local = "unknown" remote = _remote_version() logger.info("%s", "=" * 50) logger.info("PaperForge Lite 更新") diff --git a/scripts/install-paperforge.ps1 b/scripts/install-paperforge.ps1 new file mode 100644 index 00000000..70355fd7 --- /dev/null +++ b/scripts/install-paperforge.ps1 @@ -0,0 +1,66 @@ +param([switch]$Force) + +$ErrorActionPreference = "Stop" +$RepoUrl = "https://github.com/LLLin000/PaperForge.git" + +Write-Host "=== PaperForge Lite 一键安装 ===" -ForegroundColor Cyan +Write-Host "" + +# 检查 Python +try { + $py = (Get-Command python -ErrorAction Stop).Source + Write-Host "[OK] Python: $((python --version))" -ForegroundColor Green +} catch { + Write-Host "[FAIL] 未找到 Python,请先安装 https://www.python.org/downloads/" -ForegroundColor Red + exit 1 +} + +# 检查 pip +try { + python -m pip --version >$null 2>&1 + Write-Host "[OK] pip 可用" -ForegroundColor Green +} catch { + Write-Host "[FAIL] pip 不可用,请检查 Python 安装" -ForegroundColor Red + exit 1 +} + +# 检查是否已安装 +$installed = $false +try { + $ver = python -c "import paperforge; print('ok')" 2>$null + if ($ver -eq "ok") { $installed = $true } +} catch {} + +if ($installed -and -not $Force) { + $current = python -c "import json, urllib.request; r=json.loads(urllib.request.urlopen('https://api.github.com/repos/LLLin000/PaperForge/releases/latest').read()); print(r['tag_name'])" 2>$null + Write-Host "[i] 已安装 ($current). 使用 -Force 参数重新安装" -ForegroundColor Yellow + Write-Host " 更新命令: paperforge update" -ForegroundColor Yellow + exit 0 +} + +Write-Host "正在安装 PaperForge Lite..." -ForegroundColor Yellow +python -m pip install "git+$RepoUrl" --upgrade +if ($LASTEXITCODE -ne 0) { + Write-Host "[FAIL] 安装失败" -ForegroundColor Red + exit 1 +} + +Write-Host "[OK] 安装成功!" -ForegroundColor Green +Write-Host "" + +# 验证 +try { + python -c "import paperforge; print(f'PaperForge Lite {paperforge.__version__}')" 2>$null +} catch { + python -c "import paperforge; print('PaperForge Lite 已安装')" +} + +Write-Host "" +Write-Host "下一步: 运行 setup 向导配置你的 Vault" -ForegroundColor Cyan +Write-Host " paperforge setup" -ForegroundColor White +Write-Host "" +Write-Host "常用命令:" -ForegroundColor Cyan +Write-Host " paperforge sync 同步 Zotero" -ForegroundColor White +Write-Host " paperforge ocr 运行 OCR" -ForegroundColor White +Write-Host " paperforge update 检查更新" -ForegroundColor White +Write-Host "" diff --git a/setup_wizard.py b/setup_wizard.py index 69a38b6d..f7f804a1 100644 --- a/setup_wizard.py +++ b/setup_wizard.py @@ -1501,10 +1501,10 @@ def _find_vault() -> Path | None: return None -def main() -> int: +def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="PaperForge Lite 安装向导") parser.add_argument("--vault", type=Path, default=None, help="Vault 路径(可选,默认当前目录)") - args = parser.parse_args() + args = parser.parse_args(argv) if args.vault: vault = args.vault.resolve()