refactor: inline src/ modules back into main.js — single file, no runtime require

This commit is contained in:
Research Assistant 2026-05-09 23:48:08 +08:00
parent 56ee621bd7
commit caab9ff9ee
5 changed files with 217 additions and 409 deletions

View file

@ -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';

View file

@ -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 <cmd>` 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,
};

View file

@ -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,
};

View file

@ -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,
};

View file

@ -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",
]