/** * Testable functions extracted from main.js for Vitest. * No obsidian dependency — safe to import in Node test environment. */ const fs = require('fs'); const path = require('path'); const { execFile, spawn } = require('node:child_process'); // ── Runtime helpers ── 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 ── 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 ── 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", }, { 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", 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", 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 }); }); }); } module.exports = { resolvePythonExecutable, getPluginVersion, checkRuntimeVersion, classifyError, buildRuntimeInstallCommand, parseRuntimeStatus, ACTIONS, buildCommandArgs, runSubprocess, };