From caab9ff9ee1447cb60571d140002e3aec30d1fe9 Mon Sep 17 00:00:00 2001 From: Research Assistant Date: Sat, 9 May 2026 23:48:08 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20inline=20src/=20modules=20back=20in?= =?UTF-8?q?to=20main.js=20=E2=80=94=20single=20file,=20no=20runtime=20requ?= =?UTF-8?q?ire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- paperforge/plugin/main.js | 222 +++++++++++++++++++++++++++++- paperforge/plugin/src/commands.js | 147 -------------------- paperforge/plugin/src/errors.js | 97 ------------- paperforge/plugin/src/runtime.js | 157 --------------------- scripts/bump.py | 3 - 5 files changed, 217 insertions(+), 409 deletions(-) delete mode 100644 paperforge/plugin/src/commands.js delete mode 100644 paperforge/plugin/src/errors.js delete mode 100644 paperforge/plugin/src/runtime.js diff --git a/paperforge/plugin/main.js b/paperforge/plugin/main.js index 3d25c0d9..01536efc 100644 --- a/paperforge/plugin/main.js +++ b/paperforge/plugin/main.js @@ -1,12 +1,224 @@ const { Plugin, Notice, ItemView, Modal, Setting, PluginSettingTab, addIcon } = require('obsidian'); -const { exec, execFile } = require('node:child_process'); +const { exec, execFile, spawn } = require('node:child_process'); const fs = require('fs'); const path = require('path'); -// Extracted modules for testability (Plan 53-001) -const { resolvePythonExecutable, getPluginVersion, checkRuntimeVersion } = require('./src/runtime'); -const { classifyError, buildRuntimeInstallCommand, parseRuntimeStatus } = require('./src/errors'); -const { ACTIONS, buildCommandArgs, runSubprocess } = require('./src/commands'); +// ── Runtime helpers (inlined from src/runtime.js) ── + +function resolvePythonExecutable(vaultPath, settings, _fs, _execFileSync) { + const f = _fs || fs; + const execSync = _execFileSync || require("node:child_process").execFileSync; + + if (settings && settings.python_path && settings.python_path.trim()) { + const manualPath = settings.python_path.trim(); + if (f.existsSync(manualPath)) { + return { path: manualPath, source: "manual", extraArgs: [] }; + } + } + + const venvCandidates = [ + path.join(vaultPath, ".paperforge-test-venv", "Scripts", "python.exe"), + path.join(vaultPath, ".venv", "Scripts", "python.exe"), + path.join(vaultPath, "venv", "Scripts", "python.exe"), + ]; + for (const candidate of venvCandidates) { + try { + if (f.existsSync(candidate)) { + return { path: candidate, source: "auto-detected", extraArgs: [] }; + } + } catch {} + } + + const systemCandidates = [ + { path: "py", extraArgs: ["-3"] }, + { path: "python", extraArgs: [] }, + { path: "python3", extraArgs: [] }, + ]; + for (const candidate of systemCandidates) { + try { + const verOut = execSync(candidate.path, [...candidate.extraArgs, "--version"], { + encoding: "utf-8", timeout: 5000, windowsHide: true, + }); + if (verOut && verOut.toLowerCase().includes("python")) { + return { path: candidate.path, source: "auto-detected", extraArgs: candidate.extraArgs }; + } + } catch {} + } + + return { path: "python", source: "auto-detected", extraArgs: [] }; +} + +function getPluginVersion(app) { + try { + const manifest = app && app.plugins && app.plugins.plugins && + app.plugins.plugins["paperforge"] && app.plugins.plugins["paperforge"].manifest; + return (manifest && manifest.version) || null; + } catch { + return null; + } +} + +function checkRuntimeVersion(pythonExe, pluginVersion, cwd, timeout, _execFile) { + if (timeout === undefined) timeout = 10000; + const exe = _execFile || execFile; + + return new Promise((resolve) => { + exe(pythonExe, ["-c", "import paperforge; print(paperforge.__version__)"], + { cwd, timeout }, + (err, stdout) => { + if (err) { + resolve({ status: "not-installed", pyVersion: null, pluginVersion, error: err.message }); + return; + } + const pyVer = (stdout && stdout.trim()) || null; + if (pyVer === pluginVersion) { + resolve({ status: "match", pyVersion: pyVer, pluginVersion, error: null }); + } else { + resolve({ status: "mismatch", pyVersion: pyVer, pluginVersion, error: null }); + } + }); + }); +} + +// ── Error helpers (inlined from src/errors.js) ── + +function classifyError(errorCode) { + const code = String(errorCode); + const patterns = { + ENOENT: { type: "python_missing", message: "Python executable not found", recoverable: true }, + "python-missing": { type: "python_missing", message: "Python executable not found", recoverable: true }, + MODULE_NOT_FOUND: { type: "import_failed", message: "PaperForge package not installed", recoverable: true }, + "import-failed": { type: "import_failed", message: "PaperForge package not installed", recoverable: true }, + "version-mismatch": { type: "version_mismatch", message: "Plugin and package versions differ", recoverable: true, action: "sync-runtime" }, + "pip-failed": { type: "pip_install_failure", message: "pip install command failed", recoverable: true }, + ETIMEDOUT: { type: "timeout", message: "Subprocess timed out", recoverable: true, action: "retry" }, + timeout: { type: "timeout", message: "Subprocess timed out", recoverable: true, action: "retry" }, + }; + const match = patterns[code]; + if (match) return { ...match }; + return { type: "unknown", message: String(errorCode), recoverable: false }; +} + +function buildRuntimeInstallCommand(pythonExe, version, extraArgs) { + if (extraArgs === undefined) extraArgs = []; + const url = `git+https://github.com/LLLin000/PaperForge.git@${version}`; + const args = [...extraArgs, "-m", "pip", "install", "--upgrade", url]; + return { cmd: pythonExe, args, url, timeout: 120000 }; +} + +function parseRuntimeStatus(err, stdout, stderr) { + if (!err && stdout) { + return { status: "ok", version: stdout.trim() }; + } + if (err && err.code === "ENOENT") { + const classified = classifyError("ENOENT"); + return { status: "error", version: null, ...classified }; + } + if (stderr && stderr.includes("No module named paperforge")) { + const classified = classifyError("import-failed"); + return { status: "error", version: null, ...classified }; + } + if (err && err.killed) { + const classified = classifyError("timeout"); + return { status: "error", version: null, ...classified }; + } + if (stderr && stderr.includes("ModuleNotFoundError")) { + const classified = classifyError("import-failed"); + return { status: "error", version: null, ...classified }; + } + return { status: "error", version: null, type: "unknown", + message: err ? err.message : String(stderr), recoverable: false }; +} + +// ── Action definitions and subprocess dispatch (inlined from src/commands.js) ── + +const ACTIONS = [ + { + id: "paperforge-sync", + title: "Sync Library", + desc: "Pull new references from Zotero and generate literature notes", + icon: "\u21BB", + cmd: "sync", + okMsg: "Sync complete", + }, + { + id: "paperforge-ocr", + title: "Run OCR", + desc: "Extract full text and figures from PDFs via PaddleOCR", + icon: "\u229E", + cmd: "ocr", + okMsg: "OCR started", + }, + { + id: "paperforge-doctor", + title: "Run Doctor", + desc: "Verify PaperForge setup \u2014 check configs, Zotero, paths, and index health", + icon: "\u2695", + cmd: "doctor", + okMsg: "Doctor complete", + }, + { + id: "paperforge-repair", + title: "Repair Issues", + desc: "Fix three-way state divergence, path errors, and rebuild index", + icon: "\u21BA", + cmd: "repair", + args: ["--fix", "--fix-paths"], + okMsg: "Repair complete", + disabled: true, + disabledMsg: "Repair Issues will be available in a future update.", + }, + { + id: "paperforge-copy-context", + title: "Copy Context", + desc: "Copy this paper\u2019s canonical index entry JSON to clipboard for AI use", + icon: "\u2139", + cmd: "context", + needsKey: true, + okMsg: "Context copied", + }, + { + id: "paperforge-copy-collection-context", + title: "Copy Collection Context", + desc: "Copy canonical index entries for all visible papers to clipboard", + icon: "\u2261", + cmd: "context", + needsFilter: true, + okMsg: "Collection context copied", + }, +]; + +function buildCommandArgs(action, key, filter) { + const args = Array.isArray(action.args) ? [...action.args] : []; + if (action.needsKey && key) args.push(key); + if (action.needsFilter || filter) args.push("--all"); + return args; +} + +function runSubprocess(pythonExe, args, cwd, timeout, _spawn) { + const sp = _spawn || spawn; + + return new Promise((resolve) => { + const startTime = Date.now(); + const child = sp(pythonExe, args, { cwd, timeout, windowsHide: true }); + const stdoutChunks = []; + const stderrChunks = []; + + child.stdout.on("data", (data) => { stdoutChunks.push(data.toString("utf-8")); }); + child.stderr.on("data", (data) => { stderrChunks.push(data.toString("utf-8")); }); + + child.on("close", (code) => { + resolve({ stdout: stdoutChunks.join(""), stderr: stderrChunks.join(""), + exitCode: code, elapsed: Date.now() - startTime }); + }); + + child.on("error", (err) => { + resolve({ stdout: stdoutChunks.join(""), + stderr: stderrChunks.join("") + "\n" + err.message, + exitCode: -1, elapsed: Date.now() - startTime }); + }); + }); +} const VIEW_TYPE_PAPERFORGE = 'paperforge-status'; const PF_ICON_ID = 'paperforge'; diff --git a/paperforge/plugin/src/commands.js b/paperforge/plugin/src/commands.js deleted file mode 100644 index 466b6f26..00000000 --- a/paperforge/plugin/src/commands.js +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Action definitions and subprocess dispatch for PaperForge plugin commands. - * - * Extracted from main.js for testability. Provides: - * - ACTIONS array (same as main.js lines 189-243) - * - buildCommandArgs — resolves action arguments (key, filter flags) - * - runSubprocess — Promisified spawn with stdout/stderr capture - * - * `runSubprocess` accepts an optional `_spawn` parameter for testing. - */ -"use strict"; - -const { spawn } = require("node:child_process"); - -/** - * Action definitions for the PaperForge dashboard quick-action buttons. - * - * Each action maps to a `paperforge ` CLI invocation. - */ -const ACTIONS = [ - { - id: "paperforge-sync", - title: "Sync Library", - desc: "Pull new references from Zotero and generate literature notes", - icon: "\u21BB", - cmd: "sync", - okMsg: "Sync complete", - }, - { - id: "paperforge-ocr", - title: "Run OCR", - desc: "Extract full text and figures from PDFs via PaddleOCR", - icon: "\u229E", - cmd: "ocr", - okMsg: "OCR started", - }, - { - id: "paperforge-doctor", - title: "Run Doctor", - desc: "Verify PaperForge setup \u2014 check configs, Zotero, paths, and index health", - icon: "\u2695", - cmd: "doctor", - okMsg: "Doctor complete", - }, - { - id: "paperforge-repair", - title: "Repair Issues", - desc: "Fix three-way state divergence, path errors, and rebuild index", - icon: "\u21BA", - cmd: "repair", - args: ["--fix", "--fix-paths"], - okMsg: "Repair complete", - disabled: true, - disabledMsg: "Repair Issues will be available in a future update.", - }, - { - id: "paperforge-copy-context", - title: "Copy Context", - desc: "Copy this paper\u2019s canonical index entry JSON to clipboard for AI use", - icon: "\u2139", - cmd: "context", - needsKey: true, - okMsg: "Context copied", - }, - { - id: "paperforge-copy-collection-context", - title: "Copy Collection Context", - desc: "Copy canonical index entries for all visible papers to clipboard", - icon: "\u2261", - cmd: "context", - needsFilter: true, - okMsg: "Collection context copied", - }, -]; - -/** - * Resolve extra CLI arguments for an action based on needsKey and needsFilter flags. - * - * @param {object} action — action object from ACTIONS array - * @param {string|null} key — zotero_key for per-paper actions - * @param {boolean} [filter] — whether to add --all filter - * @returns {string[]} - */ -function buildCommandArgs(action, key, filter) { - const args = Array.isArray(action.args) ? [...action.args] : []; - if (action.needsKey && key) { - args.push(key); - } - if (action.needsFilter || filter) { - args.push("--all"); - } - return args; -} - -/** - * Run a subprocess with full stdout/stderr capture, wrapped in a Promise. - * - * @param {string} pythonExe — resolved Python executable path - * @param {string[]} args — CLI arguments - * @param {string} cwd — working directory (vault root) - * @param {number} [timeout] — timeout in ms - * @param {Function} [_spawn] — optional injected spawn (for testing) - * @returns {Promise<{ stdout: string, stderr: string, exitCode: number|null, elapsed: number }>} - */ -function runSubprocess(pythonExe, args, cwd, timeout, _spawn) { - const sp = _spawn || spawn; - - return new Promise((resolve) => { - const startTime = Date.now(); - const child = sp(pythonExe, args, { cwd, timeout, windowsHide: true }); - const stdoutChunks = []; - const stderrChunks = []; - - child.stdout.on("data", (data) => { - stdoutChunks.push(data.toString("utf-8")); - }); - child.stderr.on("data", (data) => { - stderrChunks.push(data.toString("utf-8")); - }); - - child.on("close", (code) => { - const elapsed = Date.now() - startTime; - resolve({ - stdout: stdoutChunks.join(""), - stderr: stderrChunks.join(""), - exitCode: code, - elapsed, - }); - }); - - child.on("error", (err) => { - const elapsed = Date.now() - startTime; - resolve({ - stdout: stdoutChunks.join(""), - stderr: stderrChunks.join("") + "\n" + err.message, - exitCode: -1, - elapsed, - }); - }); - }); -} - -module.exports = { - ACTIONS, - buildCommandArgs, - runSubprocess, -}; diff --git a/paperforge/plugin/src/errors.js b/paperforge/plugin/src/errors.js deleted file mode 100644 index d0570bd1..00000000 --- a/paperforge/plugin/src/errors.js +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Error classification and runtime install command builder for PaperForge plugin. - * - * Extracted from main.js for testability. Covers 5 error patterns: - * 1. Python missing — ENOENT or python-missing - * 2. Import failed — MODULE_NOT_FOUND or import-failed - * 3. Version mismatch — version-mismatch - * 4. Pip install failure — pip-failed - * 5. Timeout — ETIMEDOUT or timeout - */ -"use strict"; - -/** - * Classify a subprocess error code/string into a structured error object. - * - * @param {string|number} errorCode — error.code from subprocess, or a string label - * @returns {{ type: string, message: string, recoverable: boolean, action?: string }} - */ -function classifyError(errorCode) { - const code = String(errorCode); - - const patterns = { - ENOENT: { type: "python_missing", message: "Python executable not found", recoverable: true }, - "python-missing": { type: "python_missing", message: "Python executable not found", recoverable: true }, - MODULE_NOT_FOUND: { type: "import_failed", message: "PaperForge package not installed", recoverable: true }, - "import-failed": { type: "import_failed", message: "PaperForge package not installed", recoverable: true }, - "version-mismatch": { type: "version_mismatch", message: "Plugin and package versions differ", recoverable: true, action: "sync-runtime" }, - "pip-failed": { type: "pip_install_failure", message: "pip install command failed", recoverable: true }, - ETIMEDOUT: { type: "timeout", message: "Subprocess timed out", recoverable: true, action: "retry" }, - timeout: { type: "timeout", message: "Subprocess timed out", recoverable: true, action: "retry" }, - }; - - const match = patterns[code]; - if (match) { - return { ...match }; - } - - return { type: "unknown", message: String(errorCode), recoverable: false }; -} - -/** - * Build the command and arguments for installing a specific version of paperforge via pip. - * - * @param {string} pythonExe — resolved Python executable - * @param {string} version — version tag (e.g., "1.4.17rc3") - * @param {string[]} [extraArgs=[]] — extra Python CLI args (e.g., venv activation flags) - * @returns {{ cmd: string, args: string[], url: string, timeout: number }} - */ -function buildRuntimeInstallCommand(pythonExe, version, extraArgs) { - if (extraArgs === undefined) extraArgs = []; - const url = `git+https://github.com/LLLin000/PaperForge.git@${version}`; - const args = [...extraArgs, "-m", "pip", "install", "--upgrade", url]; - return { cmd: pythonExe, args, url, timeout: 120000 }; -} - -/** - * Parse the result of a subprocess version check into a structured status object. - * - * @param {Error|null} err — subprocess error object (or null on success) - * @param {string|null} stdout — subprocess stdout - * @param {string|null} stderr — subprocess stderr - * @returns {{ status: string, version: string|null, type?: string, message?: string, recoverable?: boolean, action?: string }} - */ -function parseRuntimeStatus(err, stdout, stderr) { - if (!err && stdout) { - const version = stdout.trim(); - return { status: "ok", version }; - } - - if (err && err.code === "ENOENT") { - const classified = classifyError("ENOENT"); - return { status: "error", version: null, ...classified }; - } - - if (stderr && stderr.includes("No module named paperforge")) { - const classified = classifyError("import-failed"); - return { status: "error", version: null, ...classified }; - } - - if (err && err.killed) { - const classified = classifyError("timeout"); - return { status: "error", version: null, ...classified }; - } - - if (stderr && stderr.includes("ModuleNotFoundError")) { - const classified = classifyError("import-failed"); - return { status: "error", version: null, ...classified }; - } - - return { status: "error", version: null, type: "unknown", message: err ? err.message : String(stderr), recoverable: false }; -} - -module.exports = { - classifyError, - buildRuntimeInstallCommand, - parseRuntimeStatus, -}; diff --git a/paperforge/plugin/src/runtime.js b/paperforge/plugin/src/runtime.js deleted file mode 100644 index 8e717d25..00000000 --- a/paperforge/plugin/src/runtime.js +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Runtime environment helpers — Python executable resolution and version checking. - * - * Extracted from main.js for testability. These functions handle: - * - Finding a usable Python interpreter (manual, venv, system) - * - Reading the plugin version from the Obsidian manifest - * - Checking the installed paperforge package version against the plugin - * - * Dependencies are accepted as optional parameters for testability. - * When omitted, they are eagerly required at the top level. - */ -"use strict"; - -const fs = require("fs"); -const path = require("path"); -const { execFile } = require("node:child_process"); - -/** - * Resolve a usable Python executable by trying candidates in priority order. - * - * 1. Manual override (settings.python_path) - * 2. Venv candidates (common venv locations under vaultPath) - * 3. System candidates (py -3, python, python3) - * 4. Last-resort fallback - * - * @param {string} vaultPath — root path of the Obsidian vault - * @param {object} settings — plugin settings (may contain python_path) - * @param {object} [_fs] — optional injected fs module (for testing) - * @param {Function} [_execFileSync] — optional injected execFileSync (for testing) - * @returns {{ path: string, source: string, extraArgs: string[] }} - */ -function resolvePythonExecutable(vaultPath, settings, _fs, _execFileSync) { - const f = _fs || fs; - const execSync = _execFileSync || require("node:child_process").execFileSync; - - // 1. Manual override — absolute source of truth - if (settings && settings.python_path && settings.python_path.trim()) { - const manualPath = settings.python_path.trim(); - if (f.existsSync(manualPath)) { - return { path: manualPath, source: "manual", extraArgs: [] }; - } - } - - // 2. Venv candidates - const venvCandidates = [ - path.join(vaultPath, ".paperforge-test-venv", "Scripts", "python.exe"), - path.join(vaultPath, ".venv", "Scripts", "python.exe"), - path.join(vaultPath, "venv", "Scripts", "python.exe"), - ]; - for (const candidate of venvCandidates) { - try { - if (f.existsSync(candidate)) { - return { path: candidate, source: "auto-detected", extraArgs: [] }; - } - } catch {} - } - - // 3. System candidates — test each with --version, pick first that succeeds - const systemCandidates = [ - { path: "py", extraArgs: ["-3"] }, - { path: "python", extraArgs: [] }, - { path: "python3", extraArgs: [] }, - ]; - for (const candidate of systemCandidates) { - try { - const verOut = execSync(candidate.path, [...candidate.extraArgs, "--version"], { - encoding: "utf-8", - timeout: 5000, - windowsHide: true, - }); - if (verOut && verOut.toLowerCase().includes("python")) { - return { path: candidate.path, source: "auto-detected", extraArgs: candidate.extraArgs }; - } - } catch {} - } - - // 4. Last-resort fallback - return { path: "python", source: "auto-detected", extraArgs: [] }; -} - -/** - * Read the plugin version from the Obsidian app manifest. - * - * @param {object} app — Obsidian App instance (from plugin) - * @returns {string|null} - */ -function getPluginVersion(app) { - try { - const manifest = - app && - app.plugins && - app.plugins.plugins && - app.plugins.plugins["paperforge"] && - app.plugins.plugins["paperforge"].manifest; - return (manifest && manifest.version) || null; - } catch { - return null; - } -} - -/** - * Check whether the installed paperforge Python package matches the plugin version. - * - * Spawns: pythonExe -c "import paperforge; print(paperforge.__version__)" - * - * @param {string} pythonExe — resolved Python executable path - * @param {string} pluginVersion — expected version from manifest - * @param {string} cwd — working directory (vault root) - * @param {number} [timeout=10000] — timeout in ms - * @param {Function} [_execFile] — optional injected execFile (for testing) - * @returns {Promise<{ status: string, pyVersion: string|null, pluginVersion: string, error: string|null }>} - */ -function checkRuntimeVersion(pythonExe, pluginVersion, cwd, timeout, _execFile) { - if (timeout === undefined) timeout = 10000; - const exe = _execFile || execFile; - - return new Promise((resolve) => { - exe( - pythonExe, - ["-c", "import paperforge; print(paperforge.__version__)"], - { cwd, timeout }, - (err, stdout) => { - if (err) { - resolve({ - status: "not-installed", - pyVersion: null, - pluginVersion, - error: err.message, - }); - return; - } - const pyVer = (stdout && stdout.trim()) || null; - if (pyVer === pluginVersion) { - resolve({ - status: "match", - pyVersion: pyVer, - pluginVersion, - error: null, - }); - } else { - resolve({ - status: "mismatch", - pyVersion: pyVer, - pluginVersion, - error: null, - }); - } - }, - ); - }); -} - -module.exports = { - resolvePythonExecutable, - getPluginVersion, - checkRuntimeVersion, -}; diff --git a/scripts/bump.py b/scripts/bump.py index f36efce1..e5b12e5d 100644 --- a/scripts/bump.py +++ b/scripts/bump.py @@ -31,9 +31,6 @@ PLUGIN_FILES = [ ROOT / "paperforge" / "plugin" / "main.js", ROOT / "paperforge" / "plugin" / "styles.css", ROOT / "paperforge" / "plugin" / "manifest.json", - ROOT / "paperforge" / "plugin" / "src" / "runtime.js", - ROOT / "paperforge" / "plugin" / "src" / "errors.js", - ROOT / "paperforge" / "plugin" / "src" / "commands.js", ]