const { Plugin, Notice, ItemView, Modal, Setting, PluginSettingTab, MarkdownRenderer, addIcon } = require('obsidian');
const { exec, execFile, spawn, execFileSync } = require('node:child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
// ── Inline memory-state module (Obsidian plugins cannot require() local files) ──
const memoryState = (() => {
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('node:child_process');
function readPathConfig(vaultPath) {
const pfPath = path.join(vaultPath, 'paperforge.json');
const defaults = {
system_dir: 'System',
resources_dir: 'Resources',
literature_dir: 'Literature',
base_dir: 'Bases',
};
try {
if (!fs.existsSync(pfPath)) {
return { ...defaults, _warning: 'paperforge.json not found; using defaults' };
}
const raw = fs.readFileSync(pfPath, 'utf-8');
const data = JSON.parse(raw);
const vc = data.vault_config || {};
return {
system_dir: vc.system_dir || data.system_dir || defaults.system_dir,
resources_dir: vc.resources_dir || data.resources_dir || defaults.resources_dir,
literature_dir: vc.literature_dir || data.literature_dir || defaults.literature_dir,
base_dir: vc.base_dir || data.base_dir || defaults.base_dir,
_warning: null,
};
} catch (e) {
console.warn('PaperForge: Failed to read paperforge.json, using defaults', e);
return { ...defaults, _warning: 'paperforge.json invalid; using defaults' };
}
}
function resolveVaultPaths(vaultPath) {
const cfg = readPathConfig(vaultPath);
const systemDir = path.join(vaultPath, cfg.system_dir, 'PaperForge');
return {
vault: vaultPath,
systemDir,
indexesDir: path.join(systemDir, 'indexes'),
logsDir: path.join(systemDir, 'logs'),
dbPath: path.join(systemDir, 'indexes', 'paperforge.db'),
memoryStatePath: path.join(systemDir, 'indexes', 'memory-runtime-state.json'),
vectorStatePath: path.join(systemDir, 'indexes', 'vector-runtime-state.json'),
healthStatePath: path.join(systemDir, 'indexes', 'runtime-health.json'),
buildStatePath: path.join(systemDir, 'indexes', 'vector-build-state.json'),
orphanStatePath: path.join(systemDir, 'indexes', 'sync-orphan-state.json'),
exportsDir: path.join(systemDir, 'exports'),
ocrDir: path.join(systemDir, 'ocr'),
pluginDataPath: path.join(vaultPath, '.obsidian', 'plugins', 'paperforge', 'data.json'),
pfJsonPath: path.join(vaultPath, 'paperforge.json'),
configWarning: cfg._warning,
};
}
function readJSONFile(filePath) {
try {
if (!fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch (_) { return null; }
}
function getMemoryRuntime(vaultPath) {
var paths = resolveVaultPaths(vaultPath);
return readJSONFile(paths.memoryStatePath);
}
function getVectorRuntime(vaultPath) {
var paths = resolveVaultPaths(vaultPath);
return readJSONFile(paths.vectorStatePath);
}
function getRuntimeHealth(vaultPath) {
var paths = resolveVaultPaths(vaultPath);
return readJSONFile(paths.healthStatePath);
}
function isMemoryReady(vaultPath) {
var s = getMemoryRuntime(vaultPath);
return !!(s && s.paper_count_db > 0 && !s.needs_rebuild);
}
function isVectorReady(vaultPath) {
var s = getVectorRuntime(vaultPath);
if (!s) return false;
if (!s.enabled) return false;
if (!s.deps_installed) return false;
if (!s.db_exists) return false;
if (s.healthy === false) return false;
if (s.chunk_count === 0) return false;
return true;
}
function isHealthOk(vaultPath) {
var s = getRuntimeHealth(vaultPath);
return !!(s && s.summary && s.summary.status === 'ok');
}
function getMemoryStatusText(vaultPath) {
var s = getMemoryRuntime(vaultPath);
if (!s || s.paper_count_db === 0) return 'DB not found. Run paperforge memory build.';
return 'Papers: ' + s.paper_count_db + ' | ' + (s.fresh ? 'fresh' : 'stale');
}
function getVectorStatusText(vaultPath) {
var s = getVectorRuntime(vaultPath);
if (!s) return 'Status unavailable';
if (s.healthy === false) return 'Vector index unreadable - rebuild required';
return 'Chunks: ' + s.chunk_count + ' | ' + s.model + ' | ' + s.mode;
}
var _cachedPython = null;
function getCachedPython(vaultPath, settings) {
if (_cachedPython) return _cachedPython;
if (settings && settings.python_path && settings.python_path.trim()) {
var p = settings.python_path.trim();
if (fs.existsSync(p)) { _cachedPython = { path: p, source: 'manual', extraArgs: [] }; return _cachedPython; }
}
var 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 (var i = 0; i < venvCandidates.length; i++) {
if (fs.existsSync(venvCandidates[i])) {
_cachedPython = { path: venvCandidates[i], source: 'auto-detected', extraArgs: [] };
return _cachedPython;
}
}
var sysCandidates = [{path:'py',extraArgs:['-3']},{path:'python',extraArgs:[]},{path:'python3',extraArgs:[]}];
for (var j = 0; j < sysCandidates.length; j++) {
try {
var c = sysCandidates[j];
var out = execFileSync(c.path, c.extraArgs.concat(['--version']), {encoding:'utf-8',timeout:5000,windowsHide:true});
if (out && out.toLowerCase().indexOf('python') !== -1) {
_cachedPython = { path: c.path, source: 'auto-detected', extraArgs: c.extraArgs };
return _cachedPython;
}
} catch (_) {}
}
_cachedPython = { path: 'python', source: 'auto-detected', extraArgs: [] };
return _cachedPython;
}
function buildSnapshot(vaultPath, _readFn, _resolvePaths) {
var readFn = _readFn || readJSONFile;
var resolvePaths = _resolvePaths || resolveVaultPaths;
var paths = resolvePaths(vaultPath);
var memory = readFn(paths.memoryStatePath);
var vector = readFn(paths.vectorStatePath);
var health = readFn(paths.healthStatePath);
var memoryOk = !!(memory && memory.paper_count_db > 0 && !memory.needs_rebuild);
var vectorOk = !!(vector && vector.enabled && vector.deps_installed && vector.db_exists && vector.chunk_count > 0);
return {
memory: memory, vector: vector, health: health,
updatedAt: (memory && memory.updated_at) || (vector && vector.updated_at) || '',
summary: {
status: memoryOk && vectorOk ? 'ready' : 'degraded',
memoryReady: memoryOk, vectorReady: vectorOk,
healthOk: !!(health && health.summary && health.summary.status === 'ok'),
},
};
}
return {
resolveVaultPaths, readJSONFile,
getMemoryRuntime, getVectorRuntime, getRuntimeHealth,
isMemoryReady, isVectorReady, isHealthOk,
getMemoryStatusText, getVectorStatusText,
getCachedPython, buildSnapshot,
};
})();
/* ── Orphan paper auto-detection (called after sync) ── */
function checkOrphanState(app, plugin, vp) {
console.log('[PF] checkOrphanState called');
try {
const paths = memoryState.resolveVaultPaths(vp);
const orphanPath = paths.orphanStatePath;
const fs = require('fs');
if (!fs.existsSync(orphanPath)) {
console.log('[PF] orphan file NOT FOUND');
return;
}
console.log('[PF] orphan file FOUND');
const raw = fs.readFileSync(orphanPath, 'utf-8');
const data = JSON.parse(raw);
const orphans = data.orphans || [];
console.log('[PF] orphans count:', orphans.length);
if (orphans.length === 0) return;
const py = memoryState.getCachedPython(vp, plugin.settings);
console.log('[PF] py.path:', py ? py.path : 'null');
new PaperForgeOrphanModal(app, orphans, vp, py).open();
fs.unlinkSync(orphanPath);
console.log('[PF] orphan file cleaned');
} catch (e) {
console.log('[PF] checkOrphanState exception:', e.message || e);
}
}
// ── Inlined from src/testable.js ──
function resolvePythonExecutable(vaultPath, settings, _fs, _execFileSync) {
const f = _fs || fs;
const execSync = _execFileSync || 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 });
}
});
});
}
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 pypiPkg = `paperforge==${version}`;
const gitUrl = `git+https://github.com/LLLin000/PaperForge.git@v${version}`;
const pypiArgs = [...extraArgs, "-m", "pip", "install", "--upgrade", pypiPkg];
const gitArgs = [...extraArgs, "-m", "pip", "install", "--upgrade", gitUrl];
return { cmd: pythonExe, url: gitUrl.replace('@v', '@'), args: gitArgs, pypiArgs, gitArgs, 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 };
}
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 complete",
},
{
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",
},
];
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, env) {
const sp = _spawn || spawn;
return new Promise((resolve) => {
const startTime = Date.now();
const opts = { cwd, timeout, windowsHide: true };
if (env) opts.env = env;
const child = sp(pythonExe, args, opts);
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 });
});
});
}
function getDisclosureState(store, key, defaultCollapsed) {
if (!store || typeof store !== 'object') return !!defaultCollapsed;
if (!Object.prototype.hasOwnProperty.call(store, key)) return !!defaultCollapsed;
return !!store[key];
}
function toggleDisclosureState(store, key, defaultCollapsed) {
const next = !getDisclosureState(store, key, defaultCollapsed);
if (store && typeof store === 'object') {
store[key] = next;
}
return next;
}
/* ── Annotation cache (JSON snapshot, no subprocess for reads) ── */
let _jsonCache = null;
let _jsonCachePath = null;
let _annotationCache = null;
let _annotationCacheKey = null;
function _loadAnnotationCache(vaultPath) {
var paths = memoryState.resolveVaultPaths(vaultPath);
var cp = path.join(paths.indexesDir, 'annotation-cache.json');
if (_jsonCachePath === cp && _jsonCache) return _jsonCache;
try {
if (!fs.existsSync(cp)) { console.log('[PF] no annotation cache at', cp); return null; }
var raw = fs.readFileSync(cp, 'utf-8');
_jsonCache = JSON.parse(raw);
_jsonCachePath = cp;
return _jsonCache;
} catch (e) {
console.warn('[PF] annotation cache load failed:', e.message);
return null;
}
}
function _resolvePaperIdFromCache(pdfPath, cache) {
if (!cache || !cache.by_paper) return '';
// Extract storage folder key from path: .../storage/5AWBHQ59/file.pdf
var m = pdfPath.match(/storage[\\/]([A-Z0-9]{8})/i);
if (m) {
var sk = m[1].toUpperCase();
for (var pid in cache.by_paper) {
var anns = cache.by_paper[pid];
for (var i = 0; i < anns.length; i++) {
if (anns[i].zotero_attachment_key === sk) return pid;
}
}
}
// Fallback: try matching by paper_id if the storage folder IS the paper id
var parts = pdfPath.split(/[\\/]/);
for (var pi = 0; pi < parts.length; pi++) {
var part = parts[pi].toUpperCase();
if (/^[A-Z0-9]{8}$/.test(part) && cache.by_paper[part]) return part;
}
return '';
}
function fetchAnnotationsForPaper(vaultPath, pdfPath) {
var cache = _loadAnnotationCache(vaultPath);
if (!cache) return [];
var paperId = _resolvePaperIdFromCache(pdfPath, cache);
if (!paperId) {
console.log('[PF] no paper found for path:', pdfPath);
return [];
}
var key = pdfPath + '|' + paperId;
if (key === _annotationCacheKey && _annotationCache) return _annotationCache;
_annotationCacheKey = key;
_annotationCache = cache.by_paper[paperId] || [];
console.log('[PF] got ' + _annotationCache.length + ' annotations for ' + paperId);
return _annotationCache;
}
function invalidateAnnotationCache() {
_jsonCache = null; _jsonCachePath = null;
_annotationCache = null; _annotationCacheKey = null;
}
function _buildAnnotationWriteArgs(vaultPath, subcmd) {
var py = memoryState.getCachedPython(vaultPath);
var extra = (py && py.extraArgs) || [];
return [...extra, '-m', 'paperforge', '--vault', vaultPath, 'annotation', subcmd, '--json'];
}
function _runAnnotationSubprocess(vaultPath, args) {
var py = memoryState.getCachedPython(vaultPath);
if (!py) return Promise.resolve({ ok: false, error: 'Python not configured' });
return runSubprocess(py.path, args, vaultPath, 30000).then(function (result) {
if (result.exitCode !== 0) return { ok: false, error: result.stderr || 'Subprocess failed' };
try {
var parsed = JSON.parse(result.stdout);
if (parsed && parsed.ok) return { ok: true, data: parsed.data || parsed.result };
return { ok: false, error: (parsed && parsed.error) || 'Unknown response' };
} catch (e) {
return { ok: false, error: 'JSON parse: ' + e.message };
}
});
}
function createLocalAnnotation(vaultPath, pdfPath, payload) {
var args = _buildAnnotationWriteArgs(vaultPath, 'create');
if (pdfPath) args.push('--pdf-path', pdfPath);
if (payload.page_index != null) args.push('--page-index', String(payload.page_index));
if (payload.type) args.push('--type', payload.type);
if (payload.color) args.push('--color', payload.color);
if (payload.selected_text) args.push('--selected-text', payload.selected_text);
if (payload.comment) args.push('--comment', payload.comment);
if (payload.position_json) args.push('--position-json', payload.position_json);
return _runAnnotationSubprocess(vaultPath, args).then(function (r) { invalidateAnnotationCache(); return r; });
}
function deleteLocalAnnotation(vaultPath, annotationId) {
var args = _buildAnnotationWriteArgs(vaultPath, 'delete');
args.push(String(annotationId));
return _runAnnotationSubprocess(vaultPath, args).then(function (r) { invalidateAnnotationCache(); return r; });
}
/* ── PDF annotation overlay patch lifecycle ── */
let _overlayInstalled = false;
let _currentVaultPath = null;
let _currentPdfPath = null;
function detectConflictingPdfPlugins(app) {
try {
const plugins = app && app.plugins && app.plugins.plugins;
if (!plugins) return null;
if (plugins['pdf-plus']) return 'PDF++';
if (plugins['obsidian-annotator']) return 'Obsidian Annotator';
return null;
} catch { return null; }
}
function installAnnotationOverlay(app, vaultPath, plugin) {
if (_overlayInstalled) return;
if (app.isMobile && app.isMobile()) {
console.log('[PF] annotation overlay: mobile not supported');
return;
}
const conflict = detectConflictingPdfPlugins(app);
if (conflict) {
console.warn('[PF] annotation overlay disabled: ' + conflict + ' detected');
return;
}
try {
setupPdfObserver(app, vaultPath, plugin);
_overlayInstalled = true;
console.log('[PF] annotation overlay installed');
} catch (e) {
console.warn('[PF] annotation overlay install failed:', e.message);
}
}
function setupPdfObserver(app, vaultPath, plugin) {
const observer = new MutationObserver(function () {
const pdfLeaves = app.workspace.getLeavesOfType('pdf');
if (!pdfLeaves || pdfLeaves.length === 0) return;
const pdfContainers = [];
for (let i = 0; i < pdfLeaves.length; i++) {
const leaf = pdfLeaves[i];
const view = leaf.view;
if (!view || !view.containerEl) continue;
const viewerEl = view.containerEl.querySelector('.pdf-viewer-container, .pdf-viewer');
if (viewerEl) pdfContainers.push({ leaf, viewerEl, view });
}
if (pdfContainers.length === 0) return;
for (let ci = 0; ci < pdfContainers.length; ci++) {
const pc = pdfContainers[ci];
if (pc.viewerEl.dataset.pfOverlayActive) continue;
pc.viewerEl.dataset.pfOverlayActive = '1';
const pdfPath = resolvePdfPathFromLeaf(pc.leaf);
injectPdfEventHooks(pc.viewerEl, pc.view, vaultPath, pdfPath, plugin);
}
});
observer.observe(document.body, { childList: true, subtree: true });
if (plugin && typeof plugin.register === 'function') {
plugin.register(function () { observer.disconnect(); });
}
}
function resolvePdfPathFromLeaf(leaf) {
try {
const view = leaf && leaf.view;
const file = view && view.file;
if (file && file.path) return file.path;
} catch {}
return null;
}
function getPdfPageElements(containerEl) {
return containerEl.querySelectorAll('.page[data-page-number]');
}
function injectPdfEventHooks(containerEl, view, vaultPath, pdfPath, plugin) {
if (!pdfPath) return;
console.log('[PF] injectPdfEventHooks: ' + pdfPath);
_currentVaultPath = vaultPath;
_currentPdfPath = pdfPath;
var anns = fetchAnnotationsForPaper(vaultPath, pdfPath);
console.log('[PF] fetched ' + (anns ? anns.length : 0) + ' annotations for ' + pdfPath);
if (anns && anns.length > 0) { renderAnnotationsOnExistingPages(containerEl); }
var pageObserver = new MutationObserver(function (mutations) {
var hasNewPage = false;
for (var mi = 0; mi < mutations.length; mi++) {
var added = mutations[mi].addedNodes;
for (var ai = 0; ai < added.length; ai++) {
if (added[ai].nodeType === 1 && added[ai].matches && added[ai].matches('.page[data-page-number]')) {
hasNewPage = true; break;
}
}
if (hasNewPage) break;
}
if (hasNewPage) renderAnnotationsOnExistingPages(containerEl);
});
pageObserver.observe(containerEl, { childList: true, subtree: true });
if (plugin && typeof plugin.register === 'function') {
plugin.register(function () { console.log('[PF] disconnecting page observer'); pageObserver.disconnect(); });
}
setupSelectionCapture(containerEl, vaultPath, pdfPath);
}
function setupSelectionCapture(containerEl, vaultPath, pdfPath) {
var toolbar = null;
containerEl.addEventListener('mouseup', function (e) {
setTimeout(function () {
var sel = window.getSelection();
if (!sel || sel.isCollapsed || !sel.toString().trim()) {
if (toolbar) { toolbar.remove(); toolbar = null; }
return;
}
var pageEl = e.target.closest('.page[data-page-number]');
if (!pageEl) {
if (toolbar) { toolbar.remove(); toolbar = null; }
return;
}
var pageNum = parseInt(pageEl.dataset.pageNumber, 10);
if (isNaN(pageNum)) return;
var range = sel.getRangeAt(0);
var rect = range.getBoundingClientRect();
if (!rect || rect.width < 2 || rect.height < 2) {
if (toolbar) { toolbar.remove(); toolbar = null; }
return;
}
if (toolbar) toolbar.remove();
toolbar = document.createElement('div');
toolbar.className = 'pf-annotation-toolbar';
toolbar.style.cssText = 'position:fixed;z-index:100;background:var(--background-primary);border:1px solid var(--background-modifier-border);border-radius:8px;padding:6px 10px;display:flex;gap:6px;box-shadow:0 2px 12px rgba(0,0,0,0.12);font-size:13px;';
var hlBtn = document.createElement('button');
hlBtn.textContent = '+ Highlight';
hlBtn.style.cssText = 'padding:4px 12px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--interactive-accent);color:var(--text-on-accent);cursor:pointer;';
hlBtn.addEventListener('click', function () {
var selectedText = sel.toString().trim();
var relRect = {
left: (rect.left - containerEl.getBoundingClientRect().left) / containerEl.offsetWidth,
top: (rect.top - containerEl.getBoundingClientRect().top) / containerEl.offsetHeight,
right: (rect.right - containerEl.getBoundingClientRect().left) / containerEl.offsetWidth,
bottom: (rect.bottom - containerEl.getBoundingClientRect().top) / containerEl.offsetHeight,
};
var annPayload = {
pdf_path: pdfPath,
page_index: pageNum - 1,
type: 'highlight',
color: '#ffd400',
selected_text: selectedText,
position_json: JSON.stringify({ pageIndex: pageNum - 1, rects: [[relRect.left, relRect.top, relRect.right, relRect.bottom]] }),
};
createLocalAnnotation(vaultPath, pdfPath, annPayload).then(function (result) {
if (result.ok) {
sel.removeAllRanges();
if (toolbar) { toolbar.remove(); toolbar = null; }
fetchAnnotationsForPaper(vaultPath, pdfPath).then(function () {
renderAnnotationsOnExistingPages(containerEl);
});
} else {
console.warn('[PF] create annotation failed:', result.error);
}
});
});
toolbar.appendChild(hlBtn);
document.body.appendChild(toolbar);
var tbRect = toolbar.getBoundingClientRect();
var left = rect.left + rect.width / 2 - tbRect.width / 2;
var top = rect.bottom + 8;
if (top + tbRect.height > window.innerHeight - 10) {
top = rect.top - tbRect.height - 8;
}
if (left < 10) left = 10;
if (left + tbRect.width > window.innerWidth - 10) left = window.innerWidth - tbRect.width - 10;
toolbar.style.left = Math.max(10, left) + 'px';
toolbar.style.top = Math.max(10, top) + 'px';
}, 100);
});
}
function renderAnnotationsOnExistingPages(containerEl) {
var pages = getPdfPageElements(containerEl);
console.log('[PF] renderAnnotationsOnExistingPages: ' + pages.length + ' pages, cache=' + (_annotationCache ? _annotationCache.length : 'null'));
if (_annotationCache === null || _annotationCache.length === 0) { console.log('[PF] skip: no annotations in cache'); return; }
var grouped = groupAnnotationsByPage(_annotationCache);
for (var i = 0; i < pages.length; i++) {
var pageEl = pages[i];
var pageNum = parseInt(pageEl.dataset.pageNumber, 10);
if (isNaN(pageNum)) continue;
var pageAnns = grouped[pageNum - 1];
if (!pageAnns || pageAnns.length === 0) continue;
console.log('[PF] page ' + pageNum + ' has ' + pageAnns.length + ' annotations');
ensureOverlayLayer(pageEl);
renderOverlaysForPage(pageEl, pageAnns, _currentVaultPath, _currentPdfPath, containerEl);
}
}
function ensureOverlayLayer(pageEl) {
if (pageEl.querySelector('.pf-annotation-overlay')) return;
const layer = document.createElement('div');
layer.className = 'pf-annotation-overlay';
pageEl.appendChild(layer);
}
function renderOverlaysForPage(pageEl, annotations, vaultPath, pdfPath, containerEl) {
const layer = pageEl.querySelector('.pf-annotation-overlay');
if (!layer) return;
let existing = layer.querySelectorAll('.pf-annotation-rect');
for (let e = 0; e < existing.length; e++) existing[e].remove();
for (let i = 0; i < annotations.length; i++) {
const ann = annotations[i];
if (!ann.type || !isAnnotationSupportedType(ann.type)) continue;
renderAnnotationRect(layer, ann, vaultPath, pdfPath, containerEl);
}
}
function isAnnotationSupportedType(type) {
return type === 'highlight' || type === 'underline' || type === 'note';
}
function renderAnnotationRect(layer, ann, vaultPath, pdfPath, containerEl) {
const rects = getAnnotationRects(ann);
if (!rects || rects.length === 0) { console.log('[PF] no rects for ann', ann && ann.id); return; }
const isReadonly = isReadonlyAnnotation(ann);
// Use the canvas inside the page div for accurate viewport dimensions
var pageW = 612, pageH = 792;
try {
var pageEl = layer.parentElement;
if (pageEl) {
var cv = pageEl.querySelector('canvas');
if (cv && cv.clientWidth > 0 && cv.clientHeight > 0) {
pageW = cv.clientWidth; pageH = cv.clientHeight;
} else {
pageW = pageEl.offsetWidth; pageH = pageEl.offsetHeight;
}
}
} catch (_) {}
console.log('[PF] render ' + rects.length + ' rects on ' + pageW + 'x' + pageH + ' canvas for ann ' + ann.id + ' type=' + ann.type);
for (let i = 0; i < rects.length; i++) {
const rect = rects[i];
// PDF rect format: [left, bottom, right, top] — bottom-left origin
// CSS: top-left origin, so mirror Y axis
var cssLeft = rect[0];
var cssTop = pageH - rect[3];
var cssW = rect[2] - rect[0];
var cssH = rect[3] - rect[1];
const el = document.createElement('div');
el.className = 'pf-annotation-rect pf-annotation-rect--' + ann.type;
var pctL = (cssLeft / pageW) * 100;
var pctT = (cssTop / pageH) * 100;
var pctW = (cssW / pageW) * 100;
var pctH = (cssH / pageH) * 100;
if (i === 0) { console.log('[PF] rect0 raw=' + JSON.stringify(rect) + ' css=' + cssLeft.toFixed(1) + ',' + cssTop.toFixed(1) + ' ' + cssW.toFixed(1) + 'x' + cssH.toFixed(1) + ' pct=' + pctL.toFixed(2) + '% ' + pctT.toFixed(2) + '% ' + pctW.toFixed(2) + '% x ' + pctH.toFixed(2) + '% on ' + pageW + 'x' + pageH); }
el.style.left = pctL + '%';
el.style.top = pctT + '%';
el.style.width = pctW + '%';
el.style.height = pctH + '%';
if (ann.color) {
el.style.backgroundColor = ann.color;
if (ann.type === 'highlight') el.style.opacity = '0.25';
else if (ann.type === 'underline') el.style.borderBottomColor = ann.color;
}
if (ann.type === 'underline') el.style.borderBottom = '2px solid ' + (ann.color || '#ffd400');
if (ann.type === 'note') el.style.backgroundColor = (ann.color || '#ffd400') + '40';
el.dataset.annotationId = String(ann.id || i);
if (isReadonly) el.dataset.readonly = '1';
el.addEventListener('click', function (e) {
showAnnotationPopover(e, ann, el, vaultPath, pdfPath, containerEl);
});
el.addEventListener('mouseenter', function () {
el.style.filter = 'brightness(1.4)';
});
el.addEventListener('mouseleave', function () {
el.style.filter = '';
});
layer.appendChild(el);
}
}
function showAnnotationPopover(event, ann, rectEl, vaultPath, pdfPath, containerEl) {
hideAnnotationPopover();
const popover = document.createElement('div');
popover.className = 'pf-annotation-popover';
const meta = document.createElement('div');
meta.className = 'pf-annotation-popover-meta';
const colorDot = document.createElement('span');
colorDot.className = 'pf-annotation-popover-color';
colorDot.style.backgroundColor = ann.color || '#ffd400';
meta.appendChild(colorDot);
meta.appendChild(document.createTextNode(ann.type + ' \u00B7 page ' + (ann.page_index != null ? ann.page_index + 1 : '?')));
popover.appendChild(meta);
if (ann.selected_text) {
const textEl = document.createElement('div');
textEl.className = 'pf-annotation-popover-text';
textEl.textContent = ann.selected_text.slice(0, 200);
popover.appendChild(textEl);
}
if (ann.comment) {
const commentEl = document.createElement('div');
commentEl.className = 'pf-annotation-popover-comment';
commentEl.textContent = ann.comment;
popover.appendChild(commentEl);
}
if (ann.tags && Array.isArray(ann.tags) && ann.tags.length > 0) {
const tagsEl = document.createElement('div');
tagsEl.className = 'pf-annotation-popover-tags';
for (let t = 0; t < ann.tags.length; t++) {
const tag = typeof ann.tags[t] === 'string' ? ann.tags[t] : (ann.tags[t].tag || ann.tags[t].name || '');
if (tag) {
const tagEl = document.createElement('span');
tagEl.className = 'pf-annotation-popover-tag';
tagEl.textContent = tag;
tagsEl.appendChild(tagEl);
}
}
if (tagsEl.children.length > 0) popover.appendChild(tagsEl);
}
if (!isReadonlyAnnotation(ann)) {
const actionsEl = document.createElement('div');
actionsEl.className = 'pf-annotation-popover-actions';
popover.addEventListener('click', function (e) { e.stopPropagation(); });
popover.dataset.editable = '1';
const delBtn = document.createElement('button');
delBtn.className = 'pf-annotation-popover-btn pf-annotation-popover-btn--danger';
delBtn.textContent = 'Delete';
delBtn.addEventListener('click', function () {
if (!confirm('Delete this annotation?')) return;
deleteLocalAnnotation(vaultPath, ann.id).then(function (result) {
hideAnnotationPopover();
if (result.ok) {
fetchAnnotationsForPaper(vaultPath, pdfPath).then(function () {
renderAnnotationsOnExistingPages(containerEl);
});
} else {
console.warn('[PF] delete annotation failed:', result.error);
}
});
});
actionsEl.appendChild(delBtn);
popover.appendChild(actionsEl);
}
document.body.appendChild(popover);
const rect = rectEl.getBoundingClientRect();
let left = rect.left + rect.width / 2 - 160;
let top = rect.bottom + 4;
if (top + popover.offsetHeight > window.innerHeight - 10) {
top = rect.top - popover.offsetHeight - 4;
}
if (left < 10) left = 10;
if (left + 320 > window.innerWidth - 10) left = window.innerWidth - 330;
popover.style.left = Math.max(10, left) + 'px';
popover.style.top = Math.max(10, top) + 'px';
_activePopover = popover;
function dismissOnClickOutside(ev) {
if (!popover.contains(ev.target)) {
hideAnnotationPopover();
document.removeEventListener('click', dismissOnClickOutside);
}
}
setTimeout(function () { document.addEventListener('click', dismissOnClickOutside); }, 0);
}
let _activePopover = null;
function hideAnnotationPopover() {
if (_activePopover) {
_activePopover.remove();
_activePopover = null;
}
}
function getAnnotationRects(ann) {
if (!ann) return null;
// Cache format: "position" is already parsed: {pageIndex:N, rects:[[l,b,r,t],...]}
if (ann.position && Array.isArray(ann.position.rects)) return ann.position.rects;
// Full position_json string from CLI output
if (ann.position_json) {
try {
var pos = JSON.parse(ann.position_json);
if (pos && Array.isArray(pos.rects)) return pos.rects;
} catch { return null; }
}
if (ann.rects_json) {
try { return JSON.parse(ann.rects_json); } catch { return null; }
}
if (ann.position && Array.isArray(ann.position.rects)) {
return ann.position.rects;
}
return null;
}
function groupAnnotationsByPage(annotations) {
if (!annotations || !Array.isArray(annotations)) return {};
const grouped = {};
for (let i = 0; i < annotations.length; i++) {
const a = annotations[i];
const page = (a.page_index != null) ? a.page_index : 0;
if (!grouped[page]) grouped[page] = [];
grouped[page].push(a);
}
return grouped;
}
function isReadonlyAnnotation(annotation) {
return !!(annotation && annotation.sync_state === 'zotero_synced');
}
// ── Cross-platform Python and BBT detection (macOS/Linux) ──
let _gitDir = null;
let _gitDirResolved = false;
function resolveGitDir() {
if (_gitDirResolved) return _gitDir;
_gitDirResolved = true;
try {
let out;
if (process.platform === 'win32') {
const cmdExe = process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe';
out = require('node:child_process').execFileSync(cmdExe, ['/c', 'where', 'git'], { timeout: 5000, windowsHide: true, encoding: 'utf-8' });
} else {
out = require('node:child_process').execFileSync('which', ['git'], { timeout: 5000, encoding: 'utf-8' });
}
if (out) {
const line = out.split('\n')[0].trim();
if (line) _gitDir = path.dirname(line);
}
} catch (_) {}
return _gitDir;
}
function paperforgeEnrichedEnv() {
const env = { ...process.env };
const plat = process.platform;
const home = os.homedir();
const extras = [];
const gitDir = resolveGitDir();
if (gitDir) extras.push(gitDir);
if (plat === 'darwin') {
extras.push('/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', `${home}/.local/bin`);
} else if (plat === 'linux') {
extras.push('/usr/local/bin', '/usr/bin', `${home}/.local/bin`);
}
const cur = env.PATH || '';
env.PATH = [...extras, cur].filter(Boolean).join(path.delimiter);
return env;
}
function shellQuoteForExec(cmd) {
if (!cmd) return "''";
if (/[\s'"\\]/.test(cmd)) return `'${cmd.replace(/'/g, "'\\''")}'`;
return cmd;
}
function isLikelyAppleStubPython(resolvedAbsPath) {
const n = String(resolvedAbsPath).toLowerCase().replace(/\\/g, '/');
return n.includes('commandlinetools') || n.includes('/library/developer/commandlinetools');
}
function collectDarwinPythonCandidates(home) {
return [
'/opt/homebrew/bin/python3',
'/usr/local/bin/python3',
path.join(home, '.local', 'bin', 'python3'),
path.join(home, '.pyenv', 'shims', 'python3'),
'/usr/bin/python3',
];
}
function getPaperforgePythonCmd() {
const plat = process.platform;
const home = os.homedir();
if (plat === 'darwin') {
let stubFallback = null;
for (const p of collectDarwinPythonCandidates(home)) {
try {
if (!p || !fs.existsSync(p)) continue;
let resolved = p;
try { resolved = fs.realpathSync(p); } catch (_) {}
if (isLikelyAppleStubPython(resolved)) {
if (!stubFallback) stubFallback = p;
continue;
}
return p;
} catch (_) {}
}
if (stubFallback) return stubFallback;
return 'python3';
}
const candidates = [];
if (plat === 'linux') {
candidates.push('/usr/bin/python3', '/usr/local/bin/python3', path.join(home, '.local', 'bin', 'python3'), path.join(home, '.pyenv', 'shims', 'python3'));
}
for (const p of candidates) {
try { if (p && fs.existsSync(p)) return p; } catch (_) {}
}
if (plat === 'win32') return 'python';
return 'python3';
}
function paperforgePythonExecArgs(scriptTail) {
const py = shellQuoteForExec(getPaperforgePythonCmd());
return `${py} ${scriptTail}`;
}
function tryExecPythonVersion(callback) {
const plat = process.platform;
const home = os.homedir();
const tried = new Set();
const list = [];
if (plat === 'darwin') {
const nonStub = [], stub = [];
for (const p of collectDarwinPythonCandidates(home)) {
try {
if (!fs.existsSync(p)) continue;
let resolved = p;
try { resolved = fs.realpathSync(p); } catch (_) {}
if (isLikelyAppleStubPython(resolved)) stub.push(p);
else nonStub.push(p);
} catch (_) {}
}
list.push(...nonStub, ...stub);
} else if (plat === 'linux') {
list.push('/usr/bin/python3', '/usr/local/bin/python3', path.join(home, '.local', 'bin', 'python3'), path.join(home, '.pyenv', 'shims', 'python3'));
}
list.push(plat === 'win32' ? 'python' : 'python3', 'python');
const candidates = list.filter((c) => { if (!c || tried.has(c)) return false; tried.add(c); return true; });
let i = 0;
const next = () => {
if (i >= candidates.length) { callback(new Error('Python not found'), '', null); return; }
const py = candidates[i++];
if (py.includes(path.sep) || py.startsWith('/')) {
try { if (!fs.existsSync(py)) { next(); return; } } catch (_) { next(); return; }
}
exec(`${shellQuoteForExec(py)} --version`, { timeout: 8000, env: paperforgeEnrichedEnv() }, (err, stdout) => {
if (!err && stdout) callback(null, stdout.trim(), py);
else next();
});
};
next();
}
function dirLooksLikeBetterBibtexFolder(entryName) {
const compact = String(entryName).toLowerCase().replace(/[^a-z0-9]/g, '');
return compact.includes('betterbibtex');
}
function scanBbtDirectChildren(dir) {
if (!dir) return false;
try {
if (!fs.existsSync(dir)) return false;
for (const entry of fs.readdirSync(dir)) {
if (dirLooksLikeBetterBibtexFolder(entry)) return true;
}
} catch (_) {}
return false;
}
function scanBbtUnderProfiles(profilesDir) {
if (!profilesDir) return false;
try {
if (!fs.existsSync(profilesDir)) return false;
for (const prof of fs.readdirSync(profilesDir)) {
const extDir = path.join(profilesDir, prof, 'extensions');
try {
if (!fs.existsSync(extDir)) continue;
for (const entry of fs.readdirSync(extDir)) {
if (dirLooksLikeBetterBibtexFolder(entry)) return true;
}
} catch (_) {}
}
} catch (_) {}
return false;
}
const VIEW_TYPE_PAPERFORGE = 'paperforge-status';
const PF_ICON_ID = 'paperforge';
const PF_RIBBON_SVG = `
`;
// ── i18n: language pack (auto-detected from Obsidian config) ──
const LANG = {
en: { header_title:'PaperForge',desc:'Obsidian + Zotero literature pipeline.',setup_done:'✓ PaperForge environment configured',setup_pending:'Not installed — complete preparation and open the wizard',section_prep:'Prerequisites',section_prep_desc:'Before first use, complete the following:',section_guide:'Usage',section_config:'Configuration',prep_python:'Python 3.10+',prep_python_desc:'Must be callable from command line. Click below to auto-detect.',prep_zotero:'Zotero Desktop',prep_zotero_desc:'Install Zotero (https://www.zotero.org)',prep_bbt:'Better BibTeX',prep_bbt_desc:'Zotero → Tools → Add-ons → Install Better BibTeX',prep_export:'BBT Auto-export',prep_export_desc:'Right-click collection → Export → BetterBibTeX JSON → Keep updated → to:',prep_key:'PaddleOCR Key',prep_key_desc:'Get free key at https://aistudio.baidu.com/paddleocr',guide_open:'Open Dashboard',guide_open_desc:'Ctrl+P → "PaperForge: Open Dashboard", or sidebar book icon',guide_sync:'Sync Literature',guide_sync_desc:'Dashboard → Sync Library — pull from Zotero, generate notes',guide_ocr:'Run OCR',guide_ocr_desc:'Dashboard → Run OCR — extract PDF text & figures',btn_install:'Open Wizard',btn_reconfig:'Reconfigure',btn_install_desc:'Auto-detect environment, then open setup wizard',btn_reconfig_desc:'Re-run wizard to change directories or keys',wizard_step1:'Overview',wizard_step2:'Dirs',wizard_step3:'Agent',wizard_step4:'Install',wizard_step5:'Done',wizard_title:'PaperForge Setup Wizard',wizard_intro:'This wizard will guide you through the complete setup.',wizard_dir_hint:'The resources directory is the root for all literature data. Sub-directories inside it:',wizard_dir_sub_hint:'Two sub-directories within resources:',wizard_sys_hint:'System directories (at vault root):',wizard_agent_hint:'Select your AI Agent platform. Skill files deploy in the correct format.',wizard_keys_hint:'API key and Zotero:',wizard_preview:'System/agent files at vault root. Literature (notes, index) under resources.',dir_vault:'Vault Path',dir_resources:'Resource Dir',dir_notes:'Notes Dir',dir_index:'Index Dir',dir_system:'System Dir',dir_base:'Base Dir',field_paddleocr:'PaddleOCR API Key',field_zotero_data:'Zotero Data Dir',field_zotero_placeholder:'Optional, for auto PDF detection',label_agent:'Agent Platform',check_python_ok:'Ready',check_python_fail:'Not found',check_zotero_ok:'Found',check_zotero_fail:'Not detected',check_bbt_ok:'Installed',check_bbt_fail:'Not detected',install_btn:'Install',install_btn_running:'Installing...',install_btn_retry:'Retry',install_complete:'✓ Installation complete!',install_failed:'✗ Installation failed: ',complete_title:'✓ Setup Complete',complete_summary:'Configuration',complete_next:'Next Steps',complete_step1:'Open Dashboard',complete_step1_desc:'Ctrl+P → "PaperForge: Open Dashboard" or sidebar book icon',complete_step2:'Sync Literature',complete_step2_desc:'Dashboard → Sync Library — pull from Zotero',complete_step3:'Run OCR',complete_step3_desc:'Dashboard → Run OCR — extract full text & figures',complete_step4:'Configure BBT Auto-export',nav_prev:'← Back',nav_next:'Next →',nav_close:'Close',validate_fail:'Validation failed',validate_vault:'Vault path not set',validate_resources:'Resource dir not set',validate_notes:'Notes dir not set',validate_index:'Index dir not set',validate_base:'Base dir not set',validate_key:'API key not set',validate_system:'System dir not set',notice_python_missing:'Python not detected. Install Python 3.10+ and add to PATH.',notice_check_fail:'Missing: ',panel_actions:'Quick Actions',action_running:'Running ',api_key_set:'Configured ✓',api_key_missing:'Not configured ✗',not_set:'Not set',jump_to_deep_reading:'Open Deep Reading',deep_reading_not_found:'Deep reading file not found', },
zh: { header_title:'PaperForge',desc:'Obsidian + Zotero 文献管理流水线。自动同步文献、生成笔记、OCR 提取全文,一站式文献精读工作流。',setup_done:'✓ PaperForge 环境已配置完成',setup_pending:'尚未安装,完成安装准备后点击安装向导',section_prep:'安装准备',section_prep_desc:'首次使用前,请依次完成以下准备:',section_guide:'操作方式',section_config:'当前配置',prep_python:'Python 3.10+',prep_python_desc:'确保 Python 可命令行调用。点击下方按钮自动检测。',prep_zotero:'Zotero 桌面版',prep_zotero_desc:'安装 Zotero (https://www.zotero.org)',prep_bbt:'Better BibTeX',prep_bbt_desc:'Zotero → 工具 → 插件 → 安装 Better BibTeX',prep_export:'BBT 自动导出',prep_export_desc:'右键文献子分类 → 导出分类 → BetterBibTeX JSON → 勾选保持更新 → 导出到(JSON 文件名即为 Base 名):',prep_key:'PaddleOCR Key',prep_key_desc:'在 https://aistudio.baidu.com/paddleocr 获取 API Key',guide_open:'打开 Dashboard',guide_open_desc:'Ctrl+P → 输入 PaperForge: Open Dashboard,或点左侧书本图标',guide_sync:'同步文献',guide_sync_desc:'Dashboard 中点 Sync Library,从 Zotero 拉取文献生成笔记',guide_ocr:'运行 OCR',guide_ocr_desc:'Dashboard 中点 Run OCR,提取 PDF 全文与图表',btn_install:'打开安装向导',btn_reconfig:'重新配置',btn_install_desc:'自动检测 Python + 前置环境,通过后打开分步安装向导',btn_reconfig_desc:'重新运行安装向导,修改目录或密钥配置',wizard_step1:'概览',wizard_step2:'目录',wizard_step3:'Agent',wizard_step4:'安装',wizard_step5:'完成',wizard_title:'PaperForge 安装向导',wizard_intro:'本向导将引导您完成 PaperForge 环境的完整配置。安装过程会自动创建所有目录结构,无需手动操作。',wizard_dir_hint:'资源目录是文献数据的统一根目录,以下子目录将创建在其内部:',wizard_dir_sub_hint:'资源目录内的两个子目录:',wizard_sys_hint:'独立于资源目录的系统文件:',wizard_agent_hint:'选择你使用的 AI Agent 平台,安装时将按对应格式部署技能文件:',wizard_keys_hint:'以下为 API 密钥与 Zotero 配置:',wizard_preview:'系统文件和 Agent 配置位于 Vault 根目录下。文献数据(正文、索引)统一存放在资源目录内。安装后仍可在设置中修改。',dir_vault:'Vault 路径',dir_resources:'资源目录',dir_notes:'正文目录',dir_index:'索引目录',dir_system:'系统目录',dir_base:'Base 目录',field_paddleocr:'PaddleOCR API 密钥',field_zotero_data:'Zotero 数据目录',field_zotero_placeholder:'可选,用于自动检测 PDF',label_agent:'Agent 平台',check_python_ok:'已就绪',check_python_fail:'未安装',check_zotero_ok:'已安装',check_zotero_fail:'未检测到',check_bbt_ok:'已安装',check_bbt_fail:'未检测到',install_btn:'开始安装',install_btn_running:'正在安装...',install_btn_retry:'重试',install_complete:'✓ 安装完成!',install_failed:'✗ 安装失败:',complete_title:'✓ PaperForge 安装完成',complete_summary:'当前完整配置',complete_next:'下一步操作',complete_step1:'打开 PaperForge Dashboard',complete_step1_desc:'Ctrl+P → 输入 PaperForge: Open Dashboard,或点左侧书本图标',complete_step2:'同步文献',complete_step2_desc:'Dashboard 中点 Sync Library,从 Zotero 拉取文献生成笔记',complete_step3:'运行 OCR',complete_step3_desc:'Dashboard 中点 Run OCR,提取 PDF 全文与图表',complete_step4:'配置 BBT 自动导出',nav_prev:'← 上一步',nav_next:'下一步 →',nav_close:'关闭',validate_fail:'配置验证失败',validate_vault:'Vault 路径未填写',validate_resources:'资源目录未填写',validate_notes:'正文目录未填写',validate_index:'索引目录未填写',validate_base:'Base 目录未填写',validate_key:'PaddleOCR API 密钥未填写',validate_zotero:'Zotero 数据目录为必填项',validate_system:'系统目录未填写',notice_python_missing:'Python 未检测到,请先安装 Python 3.10+ 并加入 PATH',notice_check_fail:'未通过: ',panel_actions:'快捷操作',action_running:'正在执行 ',api_key_set:'已配置 ✓',api_key_missing:'未配置 ✗',not_set:'未设置',jump_to_deep_reading:'跳转到精读',deep_reading_not_found:'精读文件未找到', }
};
let T = LANG.zh;
Object.assign(LANG.en, {
desc: 'Obsidian + Zotero literature pipeline. Sync papers, generate notes, run OCR, and read deeply in one place.',
setup_done: 'PaperForge environment is ready',
setup_pending: 'Not installed yet. Finish the preparation items below, then open the wizard.',
section_prep: 'Preparation',
section_prep_desc: 'Before first use, finish these 4 preparation items. Better BibTeX auto-export is configured after setup:',
section_guide: 'How To Use',
section_config: 'Current Configuration',
prep_python_desc: 'Python must be available from the command line. If you are not sure, click below to auto-detect.',
prep_zotero_desc: 'Install Zotero from https://www.zotero.org',
prep_bbt_desc: 'In Zotero: Tools -> Add-ons -> install Better BibTeX.',
prep_export: 'Better BibTeX Auto-export',
prep_export_desc: 'In Zotero, right-click the collection you want to sync -> Export Collection -> BetterBibTeX JSON -> enable "Keep updated" -> save the JSON file into the exports folder shown below. Obsidian Base views will use the JSON filename as the Base name:',
prep_export_path_label: 'Save the exported JSON file into this folder:',
prep_key_desc: 'Get your API key from https://aistudio.baidu.com/paddleocr',
guide_open: 'Open Main Panel',
guide_open_desc: 'Press Ctrl+P and run "PaperForge: Open Main Panel", or click the PaperForge icon in the left sidebar.',
guide_sync_desc: 'After Better BibTeX JSON export is configured, click Sync Library to import papers from Zotero into Obsidian and generate notes automatically.',
guide_ocr_desc: 'In the main panel, click Run OCR to extract full text and figures from PDFs for later reading and analysis.',
btn_install: 'Open Setup Wizard',
btn_install_desc: 'Check whether the environment is ready, then open the step-by-step setup wizard',
btn_reconfig_desc: 'Open the setup wizard again to change directories, platform, or API keys',
wizard_step2: 'Directory Setup',
wizard_step3: 'Platform & Keys',
wizard_intro: 'This wizard walks you through the full setup. In most cases, the default values are fine to keep.',
wizard_dir_hint: 'PaperForge stores user-facing literature data under the resources directory. These folders will live there:',
wizard_dir_sub_hint: 'Resolved folder preview based on the names below:',
wizard_sys_hint: 'These folders live at the vault root, outside the resources directory:',
wizard_agent_hint: 'Choose the AI agent platform you use most often. PaperForge will place the matching command and skill files in the correct location.',
wizard_keys_hint: 'Enter your PaddleOCR API key below. If you want PaperForge to auto-locate Zotero PDFs, you can also fill in the Zotero data directory.',
wizard_preview: 'After installation, system files stay at the vault root while literature data stays under the resources directory.',
wizard_safety: 'Safety: if the selected folders already contain files, setup preserves existing files and only creates missing PaperForge folders and files.',
field_zotero_placeholder: 'Required. Path to Zotero data directory for PDF attachment resolution.',
install_btn: 'Start Install',
install_validating: 'Validating setup...',
install_bootstrapping: 'PaperForge Python package not found. Installing automatically...',
install_complete: 'Installation complete!',
install_failed: 'Installation failed: ',
complete_title: 'Setup Complete',
complete_summary: 'Saved Configuration',
complete_next: 'Recommended next steps',
complete_step1_desc: 'Press Ctrl+P and run "PaperForge: Open Main Panel", or click the PaperForge icon in the left sidebar.',
complete_step2_desc: 'In the main panel, click Sync Library to bring papers from Zotero into Obsidian and generate notes.',
complete_step3_desc: 'In the Obsidian Base view, mark do_ocr:true on papers, then run OCR in the main panel.',
complete_step4: 'Configure Better BibTeX Auto-export',
complete_step4_desc: 'In Zotero, right-click the library or collection you want to sync -> Export -> Better BibTeX JSON -> enable "Keep updated".',
complete_export_path: 'Save Better BibTeX JSON exports into:',
nav_prev: 'Back',
nav_next: 'Next',
validate_fail: 'Please complete the required fields below',
validate_vault: 'Vault path is required',
validate_resources: 'Resources directory is required',
validate_notes: 'Notes directory is required',
validate_index: 'Index directory is required',
validate_base: 'Base directory is required',
validate_key: 'PaddleOCR API key (optional, needed for OCR)',
validate_zotero: 'Zotero data directory (optional, needed for PDF linking)',
optional_later: '(can be set later in Settings)',
validate_system: 'System directory is required',
notice_python_missing: 'Python was not detected. Install Python 3.10+ and add it to PATH.',
api_key_set: 'Entered',
api_key_missing: 'Missing',
not_set: 'Not entered',
field_python_interp: 'Python Interpreter',
field_python_custom: 'Custom Path',
btn_validate: 'Validate',
/* ── Runtime Health (Task 2) ── */
runtime_health: 'Runtime Health',
runtime_health_desc: 'Check whether the installed paperforge Python package matches the plugin version',
runtime_health_plugin_ver: 'Plugin v{0}',
runtime_health_package_ver: 'Python package v{0}',
runtime_health_match: 'Match',
runtime_health_mismatch: 'Mismatch',
runtime_health_checking: 'Checking...',
runtime_health_sync: 'Sync Runtime',
runtime_health_syncing: 'Syncing...',
runtime_health_sync_done: 'Runtime synced to v{0}',
runtime_health_sync_fail: 'Sync failed: {0}',
dashboard_drift_warning: 'PaperForge CLI (v{0}) differs from plugin (v{1}). Open Settings → Runtime Health to sync.',
/* ── Copy Diagnostic (Task 3) ── */
error_copy_diagnostic: 'Copy diagnostic',
error_copied: 'Copied!',
/* ── DASH-01: OCR Queue (Plan 54-001) ── */
ocr_queue_add: 'Add to OCR Queue',
ocr_queue_remove: 'Remove from OCR Queue',
ocr_queue_added: 'Added to OCR queue',
ocr_queue_removed: 'Removed from OCR queue',
/* ── DASH-02: /pf-deep Handoff (Plan 54-001) ── */
copy_pf_deep_cmd: 'Copy /pf-deep Command',
copied: 'Copied!',
run_in_agent: 'Run in {0}',
/* ── DASH-03: Privacy Warning (Plan 54-003) ── */
ocr_privacy_title: 'OCR Privacy Notice',
ocr_privacy_warning: 'OCR will upload PDFs to the PaddleOCR API. Do not upload sensitive or confidential documents.',
ocr_understand: 'I understand, continue',
/* ── Tabbed Settings ── */
tab_setup: 'Installation',
tab_features: 'Features',
/* ── Features tab descriptions ── */
feat_skills_desc: 'Manage and enable/disable agent skills installed in your vault. Each row corresponds to a SKILL.md file — toggle off to prevent the agent from auto-invoking that skill.',
feat_skills_system: 'System Skills ship with PaperForge and are updated alongside PaperForge.',
feat_skills_user: 'User Skills are custom skills you install from community or create yourself.',
feat_memory_desc: 'The Memory Layer is the core data engine of PaperForge, powered by SQLite. It integrates all literature metadata (papers, assets, aliases, reading events), provides FTS5 full-text search across titles/abstracts/authors/collections, and enables the agent-context and paper-status commands. Always active — no toggle needed.',
feat_vector_desc: 'Vector Database enables semantic search across OCR-extracted fulltext via API embedding. Documents are split into chunks, embedded via OpenAI-compatible API, and stored in ChromaDB.',
feat_vector_config_label: 'Vector Settings',
feat_agent_platform: 'Agent Platform',
feat_agent_platform_desc: 'Select which agent platform to manage skills for.',
feat_vector_enable: 'Enable Vector Retrieval',
feat_vector_enable_desc: 'Semantic search across OCR fulltext. Requires: pip install openai chromadb (~35MB).',
feat_openai_key: 'OpenAI API Key',
feat_openai_key_desc: 'Used for API embedding calls. Model is defined below.',
feat_verify: 'Verify',
feat_checking: 'Checking...',
feat_rebuild_vectors: 'Rebuild Vectors',
feat_rebuild_vectors_desc: 'Rebuild all OCR fulltext vectors. Required after model or mode change.',
feat_rebuild_vectors_changed: 'Model changed — rebuild to update all vectors.',
feat_install_deps: 'Install Dependencies',
feat_install_deps_desc: 'pip install chromadb openai (~35MB).',
feat_api_base_url: 'API Base URL',
feat_api_base_url_desc: 'Custom OpenAI-compatible API endpoint. Leave empty for default.',
feat_api_model: 'API Model',
feat_api_model_desc: 'Embedding model name for this endpoint.',
feat_deps_missing: 'Dependencies not installed. Required: chromadb, openai.',
feat_deps_checking: 'Checking dependencies...',
feat_no_python: 'No Python found. Check Installation tab.',
feat_rebuild_btn: 'Rebuild',
feat_build_btn: 'Build',
feat_vector_corrupted: 'Vector index corrupted — needs force rebuild.',
feat_vector_rebuild_force_btn: 'Force Rebuild',
feat_building: 'Building...',
feat_installing: 'Installing...',
feat_install_btn: 'Install',
feat_retry_btn: 'Retry',
feat_removing: 'Removing...',
feat_not_cached: 'Not cached',
feat_uninstall_btn: 'Uninstall',
feat_verify_btn: 'Verify',
feat_checking_btn: 'Checking...',
feat_valid_key: 'API key valid.',
feat_key_rejected: 'API key rejected.',
feat_enter_key: 'Enter a valid OpenAI API key.',
feat_network_error: 'Network error: ',
feat_build_complete: 'Vector build complete.',
feat_build_failed: 'Build failed. See terminal output.',
feat_output_copied: 'Output copied to clipboard.',
feat_install_done: 'Dependencies installed. Building vectors...',
feat_install_failed: 'Install failed: ',
feat_memory_rebuild_btn: 'Rebuild',
feat_memory_rebuilding: 'Rebuilding...',
feat_memory_rebuild_done: 'Memory DB rebuilt.',
feat_memory_rebuild_failed: 'Rebuild failed.',
feat_installing_pkgs: 'Installing {pkgs}...',
feat_model_changed_warn: 'Model changed ({0} -> {1}). Existing vectors are incompatible — rebuild required.',
feat_cache_removed: 'Model cache removed.',
feat_cache_remove_failed: 'Failed: {0}',
orphan_title: 'Found {count} orphan paper(s)',
orphan_desc: 'These papers are no longer in your Zotero library.',
orphan_select_all: 'Select all',
orphan_deselect_all: 'Deselect all',
orphan_delete_selected: 'Delete {count} selected',
orphan_keep_all: 'Keep all',
orphan_explain: 'Removed from Zotero. Workspace files remain on disk.',
orphan_deleted: 'Deleted {count} orphan workspace(s)',
orphan_none_selected: 'No papers selected for deletion',
orphan_delete_failed: 'Prune failed',
});
/* ── LANG.zh: v1.12 runtime health, OCR queue, pf-deep, dashboard translations ── */
Object.assign(LANG.zh, {
field_python_interp: '当前 Python 解释器',
field_python_custom: '自定义 Python 路径',
btn_validate: '验证',
runtime_health: '运行时状态',
runtime_health_desc: '检查插件与 Python 运行时版本的匹配情况',
runtime_health_plugin_ver: '插件 v{0}',
runtime_health_package_ver: 'Python 包 v{0}',
runtime_health_match: '匹配',
runtime_health_mismatch: '不匹配',
runtime_health_checking: '正在检测…',
runtime_health_sync: '同步运行时',
runtime_health_syncing: '正在同步…',
runtime_health_sync_done: '运行时已同步至 v{0}',
runtime_health_sync_fail: '运行时同步失败:{0}',
dashboard_drift_warning: '插件版本与 Python 运行时版本不匹配。请在设置中点击"同步运行时"。',
error_copy_diagnostic: '复制诊断信息',
error_copied: '已复制!',
ocr_queue_add: '加入 OCR 队列',
ocr_queue_remove: '移出 OCR 队列',
ocr_queue_added: '已加入 OCR 队列',
ocr_queue_removed: '已移出 OCR 队列',
no_pending_ocr: '所有 OCR 任务已完成',
copy_pf_deep_cmd: '复制 /pf-deep 命令',
copied: '已复制!',
run_in_agent: '在 {0} 中运行',
ocr_privacy_title: 'OCR 隐私提示',
ocr_privacy_warning: 'OCR 会将 PDF 上传到 PaddleOCR API 进行处理。请不要上传包含敏感信息或无法外传的文献。',
ocr_understand: '我了解,继续',
install_validating: '正在校验安装环境…',
optional_later: '(稍后可在设置中补充)',
install_bootstrapping: '未检测到 PaperForge Python 包,正在自动安装…',
wizard_safety: '安全说明:如果你选择的目录里已经有文件,安装向导会保留已有内容,只补充缺失的 PaperForge 文件和目录。',
/* ── Tabbed Settings ── */
tab_setup: '安装',
tab_features: '功能',
/* ── 功能介绍的描述文本 ── */
feat_skills_desc: '管理 Vault 中已安装的 Agent 技能。每行对应一个 SKILL.md 文件,关闭开关可阻止 Agent 自动调用该技能。',
feat_skills_system: '系统技能随 PaperForge 一同发布,会跟随 PaperForge 版本更新。',
feat_skills_user: '用户技能是你自行安装或创建的自定义技能。',
feat_memory_desc: '记忆层是 PaperForge 的核心数据引擎,基于 SQLite 构建。它整合了所有文献元数据(论文、资源文件、别名、阅读事件),支持 FTS5 全文检索(可搜索标题、摘要、作者、分类),并为 agent-context 和 paper-status 命令提供数据支撑。始终运行,无需手动开启。',
feat_vector_desc: '向量数据库通过嵌入模型实现 OCR 全文的语义搜索。文档被切分为文本块(chunk),编码为向量存入 ChromaDB。支持本地模型(免费,CPU 运行)或 OpenAI API(付费,更快速)。',
feat_vector_config_label: '向量库配置',
feat_agent_platform: 'Agent 平台',
feat_agent_platform_desc: '选择要管理的 Agent 平台。',
feat_vector_enable: '启用向量检索',
feat_vector_enable_desc: '对 OCR 全文进行语义搜索。需安装: pip install chromadb sentence-transformers openai (~500MB)。',
feat_model: '模型',
feat_openai_key: 'OpenAI API Key',
feat_openai_key_desc: '用于 API 嵌入调用,模型在下方定义。',
feat_verify: '验证',
feat_checking: '检测中…',
feat_rebuild_vectors: '重建向量',
feat_rebuild_vectors_desc: '重建所有 OCR 全文向量。更换模型或模式后需要重建。',
feat_rebuild_vectors_changed: '模型已更换 — 需要重建向量。',
feat_install_deps: '安装依赖',
feat_api_base_url: 'API 地址',
feat_api_base_url_desc: '自定义 OpenAI 兼容 API 端点。留空使用默认地址。',
feat_api_model: 'API 模型',
feat_api_model_desc: '该端点使用的嵌入模型名称。',
feat_deps_missing: '依赖未安装。需要:chromadb, openai。',
feat_deps_checking: '正在检测依赖…',
feat_no_python: '未找到 Python。请查看安装标签页。',
feat_rebuild_btn: '重建',
feat_build_btn: '构建',
feat_vector_corrupted: '向量索引已损坏 — 需要强制重建。',
feat_vector_rebuild_force_btn: '强制重建',
feat_building: '构建中…',
feat_installing: '安装中…',
feat_install_btn: '安装',
feat_retry_btn: '重试',
feat_removing: '删除中…',
feat_not_cached: '未缓存',
feat_uninstall_btn: '卸载',
feat_verify_btn: '验证',
feat_checking_btn: '检测中…',
feat_valid_key: 'API Key 有效。',
feat_key_rejected: 'API Key 被拒绝。',
feat_enter_key: '请输入有效的 OpenAI API Key。',
feat_network_error: '网络错误:',
feat_build_complete: '向量构建完成。',
feat_build_failed: '构建失败。请查看终端输出。',
feat_output_copied: '输出已复制到剪贴板。',
feat_install_done: '依赖已安装。正在构建向量…',
feat_install_failed: '安装失败:',
feat_memory_rebuild_btn: '重建数据库',
feat_memory_rebuilding: '重建中…',
feat_memory_rebuild_done: '记忆数据库重建完成。',
feat_memory_rebuild_failed: '重建失败。',
feat_installing_pkgs: '正在安装 {pkgs}...',
feat_model_changed_warn: '模型已更换({0} -> {1})。已有向量不兼容——需要重建。',
feat_cache_removed: '模型缓存已清除。',
feat_cache_remove_failed: '失败:{0}',
orphan_title: '发现 {count} 篇残留文献',
orphan_desc: '这些文献已从 Zotero 中移除。',
orphan_select_all: '全选',
orphan_deselect_all: '取消全选',
orphan_delete_selected: '删除 {count} 篇',
orphan_keep_all: '保留全部',
orphan_explain: '已从 Zotero 中移除。工作区文件仍保留在磁盘上。',
orphan_deleted: '已删除 {count} 篇残留文献',
orphan_none_selected: '未选择任何文献',
orphan_delete_failed: '清理失败',
});
function langFromApp(app) {
try {
if (app && app.vault && typeof app.vault.getConfig === 'function') {
const l = app.vault.getConfig('language');
if (l && String(l).startsWith('zh')) return 'zh';
}
} catch {}
try {
if (typeof localStorage !== 'undefined') {
const l = localStorage.getItem('language');
if (l && String(l).startsWith('zh')) return 'zh';
}
} catch {}
return 'en'; // default English
}
function t(key) { return (T && T[key]) || (LANG.en[key]) || key; }
const DEFAULT_SETTINGS = {
vault_path: '',
setup_complete: false,
auto_update: true,
agent_platform: 'opencode',
language: '',
paddleocr_api_key: '',
zotero_data_dir: '',
python_path: '',
// Feature toggles
features: {
memory_layer: true,
vector_db: false,
},
selected_skill_platform: 'opencode',
vector_db_api_key: '',
vector_db_api_base: '',
vector_db_api_model: 'text-embedding-3-small',
frozen_skills: {},
};
// ACTIONS, resolvePythonExecutable extracted to src/ modules (Plan 53-001)
function overlayEntryWorkflowState(app, entry) {
if (!entry || !entry.note_path) return entry;
const noteFile = app.vault.getAbstractFileByPath(entry.note_path);
if (!noteFile) return entry;
const cache = app.metadataCache.getFileCache(noteFile);
const fm = cache && cache.frontmatter;
if (!fm) return entry;
const merged = { ...entry };
for (const key of ['do_ocr', 'analyze', 'ocr_status', 'deep_reading_status']) {
if (Object.prototype.hasOwnProperty.call(fm, key)) merged[key] = fm[key];
}
return merged;
}
function patchEntryWorkflowState(entry, patch) {
return entry ? { ...entry, ...patch } : entry;
}
class PaperForgeStatusView extends ItemView {
constructor(leaf) {
super(leaf);
this._currentMode = null; // 'global' | 'paper' | 'collection'
this._currentDomain = null; // domain name when in collection mode (D-15)
this._currentPaperKey = null; // zotero_key when in per-paper mode (D-03)
this._currentPaperEntry = null; // full entry when in per-paper mode
this._currentFilePath = null; // active file path for identity guard (D-06)
this._cachedItems = null; // lazy-loaded index items (Plan 28-01)
this._modeSubscribers = []; // event handler refs for cleanup
this._leafChangeTimer = null; // debounce timer for active-leaf-change
this._ocrPrivacyShown = false; // DASH-03: once-per-session privacy flag
}
getViewType() { return VIEW_TYPE_PAPERFORGE; }
getDisplayText() { return 'PaperForge'; }
getIcon() { return PF_ICON_ID; }
async onOpen() {
this._buildPanel();
this._contentEl = this.containerEl.querySelector('.paperforge-content-area');
this._modeSubscribers = [];
this._leafChangeTimer = null;
this._setupEventSubscriptions();
// Fetch Python package version (once, not from index)
this._fetchVersion();
this._detectAndSwitch();
}
onClose() {
// Unsubscribe from all event subscriptions (D-08, D-09)
if (this._modeSubscribers && this._modeSubscribers.length > 0) {
for (const sub of this._modeSubscribers) {
if (sub.event === 'active-leaf-change') {
this.app.workspace.off('active-leaf-change', sub.ref);
} else if (sub.event === 'modify') {
this.app.vault.off('modify', sub.ref);
}
}
this._modeSubscribers = [];
}
// Clear debounce timer
if (this._leafChangeTimer) {
clearTimeout(this._leafChangeTimer);
this._leafChangeTimer = null;
}
// Clear cached data
this._cachedItems = null;
this._cachedStats = null;
}
/* ---------------------------------------------------------------------- */
/* Build Panel */
/* ---------------------------------------------------------------------- */
_buildPanel() {
const root = this.containerEl;
root.empty();
root.addClass('paperforge-status-panel');
/* ── Header ── */
const header = root.createEl('div', { cls: 'paperforge-header' });
const headerLeft = header.createEl('div', { cls: 'paperforge-header-left' });
headerLeft.createEl('div', { cls: 'paperforge-header-logo', text: 'P' });
// Mode context area (populated by _renderModeHeader)
this._modeContextEl = headerLeft.createEl('div', { cls: 'paperforge-mode-context' });
this._headerTitle = headerLeft.createEl('h3', { cls: 'paperforge-header-title', text: 'PaperForge' });
this._versionBadge = headerLeft.createEl('span', { cls: 'paperforge-header-badge', text: 'v\u2014' });
const refreshBtn = header.createEl('button', { cls: 'paperforge-header-refresh', attr: { 'aria-label': 'Refresh' } });
refreshBtn.innerHTML = '\u21BB';
refreshBtn.addEventListener('click', () => {
this._invalidateIndex();
this._detectAndSwitch();
});
/* ── Status Message (command output) ── */
this._messageEl = root.createEl('div', { cls: 'paperforge-message' });
/* ── Mode-Switched Content Area (per D-06) ── */
this._contentEl = root.createEl('div', { cls: 'paperforge-content-area' });
}
/* ---------------------------------------------------------------------- */
/* Fetch & Render Stats */
/* ---------------------------------------------------------------------- */
_fetchVersion() {
const vp = this.app.vault.adapter.basePath;
const plugin = this.app.plugins.plugins['paperforge'];
const pluginVer = plugin?.manifest?.version || '?';
const { path: pythonExe, extraArgs = [] } = resolvePythonExecutable(vp, plugin?.settings);
checkRuntimeVersion(pythonExe, pluginVer, vp, 10000).then((result) => {
if (result.status === 'not-installed') {
return;
}
const v = result.pyVersion || '';
this._paperforgeVersion = v.startsWith('v') ? v : 'v' + v;
if (this._versionBadge) this._versionBadge.setText(this._paperforgeVersion);
// Check drift for dashboard banner
if (this._driftBannerEl && pluginVer && this._paperforgeVersion !== 'v' + pluginVer.replace(/^v/, '')) {
this._driftBannerEl.style.display = 'block';
this._driftBannerEl.setText(t('dashboard_drift_warning')
.replace('{0}', this._paperforgeVersion)
.replace('{1}', 'v' + pluginVer.replace(/^v/, '')));
} else if (this._driftBannerEl) {
this._driftBannerEl.style.display = 'none';
}
});
}
_fetchStats(quiet) {
if (!this._metricsEl) return;
if (!quiet && !this._cachedStats) {
this._metricsEl.empty();
this._metricsEl.createEl('div', { cls: 'paperforge-status-loading', text: 'Loading...' });
} else if (quiet && !this._cachedStats) {
return;
}
const vp = this.app.vault.adapter.basePath;
const plugin = this.app.plugins.plugins['paperforge'];
const { path: pythonExe, extraArgs = [] } = resolvePythonExecutable(vp, plugin?.settings);
// Plan 57-04: Try paperforge dashboard --json first, fallback to file
execFile(pythonExe, [...extraArgs, '-m', 'paperforge', 'dashboard', '--json'], { cwd: vp, timeout: 30000 }, (err, stdout) => {
if (!err) {
try {
const body = JSON.parse(stdout);
if (body.ok && body.data) {
const d = this._normalizeDashboardData(body.data);
this._cachedStats = d;
this._metricsEl.empty();
this._renderStats(d);
this._renderOcr(d);
this._dashboardPermissions = body.data.permissions || {};
return;
}
} catch (_) {
// Fall through to file fallback
}
}
// Fallback: read formal-library.json directly
this._fallbackFetchStats(quiet, vp, plugin);
});
}
_normalizeDashboardData(data) {
const stats = data.stats || {};
const ocrHealth = stats.ocr_health || {};
const pdfHealth = stats.pdf_health || {};
const ocrTotal = (ocrHealth.done || 0) + (ocrHealth.pending || 0) + (ocrHealth.failed || 0);
return {
total_papers: stats.papers || 0,
formal_notes: stats.papers || 0,
exports: 0,
bases: 0,
ocr: { total: ocrTotal, pending: ocrHealth.pending || 0, processing: 0, done: ocrHealth.done || 0, failed: ocrHealth.failed || 0 },
path_errors: (pdfHealth.broken || 0) + (pdfHealth.missing || 0),
};
}
_fallbackFetchStats(quiet, vp, plugin) {
const systemDir = plugin?.settings?.system_dir || 'System';
const indexPath = path.join(vp, systemDir, 'PaperForge', 'indexes', 'formal-library.json');
try {
const raw = fs.readFileSync(indexPath, 'utf-8');
const index = JSON.parse(raw);
const items = index.items || [];
// D-06: Single-pass aggregation — no item references held after loop
const lifecycleCounts = {};
const healthCounts = {
pdf_health: { healthy: 0, unhealthy: 0 },
ocr_health: { healthy: 0, unhealthy: 0 },
note_health: { healthy: 0, unhealthy: 0 },
asset_health: { healthy: 0, unhealthy: 0 },
};
let ocrTotal = 0, ocrDone = 0, ocrPending = 0, ocrProcessing = 0, ocrFailed = 0;
let formalNotes = 0;
for (const item of items) {
if (item.note_path) formalNotes++;
const lifecycle = item.lifecycle || 'pdf_ready';
lifecycleCounts[lifecycle] = (lifecycleCounts[lifecycle] || 0) + 1;
const health = item.health || {};
for (const dim of ['pdf_health', 'ocr_health', 'note_health', 'asset_health']) {
const val = health[dim] || 'healthy';
if (val === 'healthy') healthCounts[dim].healthy++;
else healthCounts[dim].unhealthy++;
}
const ocrStatus = item.ocr_status || '';
ocrTotal++;
if (ocrStatus === 'done') ocrDone++;
else if (ocrStatus === 'pending') ocrPending++;
else if (ocrStatus === 'processing' || ocrStatus === 'queued' || ocrStatus === 'running') ocrProcessing++;
else ocrFailed++;
}
this._cachedStats = {
version: index.paperforge_version || this._cachedStats?.version || '\u2014',
total_papers: items.length,
formal_notes: formalNotes,
exports: 0,
bases: 0,
ocr: { total: ocrTotal, pending: ocrPending, processing: ocrProcessing, done: ocrDone, failed: ocrFailed },
path_errors: 0,
lifecycle_level_counts: lifecycleCounts,
health_aggregate: healthCounts,
};
this._metricsEl.empty();
this._renderStats(this._cachedStats);
this._renderOcr(this._cachedStats);
} catch (err) {
// D-07: Second fallback — spawn CLI if file is missing or corrupt
if (!quiet && !this._cachedStats) {
this._metricsEl.createEl('div', { cls: 'paperforge-status-loading', text: 'No index \u2014 trying CLI...' });
}
const { path: pythonExe, extraArgs = [] } = resolvePythonExecutable(vp, plugin?.settings);
execFile(pythonExe, [...extraArgs, '-m', 'paperforge', 'status', '--json'], { cwd: vp, timeout: 30000 }, (err2, stdout) => {
if (err2) {
if (this._cachedStats) return;
this._metricsEl.createEl('div', { cls: 'paperforge-status-error', text: 'Cannot reach PaperForge CLI.\nMake sure paperforge is installed and in your PATH.' });
return;
}
try {
const d = JSON.parse(stdout);
this._cachedStats = d;
this._metricsEl.empty();
this._renderStats(d);
this._renderOcr(d);
} catch {
if (!this._cachedStats) {
this._metricsEl.createEl('div', { cls: 'paperforge-status-error', text: 'Invalid response from paperforge status.' });
}
}
});
}
}
/* ── Loading Skeleton Utility (D-24) ── */
_renderSkeleton(container) {
container.addClass('paperforge-loading');
}
/* ── Empty State Utility (D-25) ── */
_renderEmptyState(container, message) {
container.createEl('div', {
cls: 'paperforge-empty-state',
text: message || 'No data',
});
}
/* ── Metric Progress Bar Helper (D-05) ── */
_buildMetricBar(card, value, max) {
if (max <= 0) return;
const pct = Math.min(100, (value / max) * 100);
const bar = card.createEl('div', { cls: 'paperforge-metric-progress' });
bar.createEl('div', {
cls: 'paperforge-metric-progress-fill',
attr: { style: `width:${pct.toFixed(1)}%` },
});
}
/* ── Index Loading (D-11, D-17, D-19) ── */
_loadIndex() {
const vp = this.app.vault.adapter.basePath;
const plugin = this.app.plugins.plugins['paperforge'];
const systemDir = plugin?.settings?.system_dir || 'System';
const indexPath = path.join(vp, systemDir, 'PaperForge', 'indexes', 'formal-library.json');
try {
const raw = fs.readFileSync(indexPath, 'utf-8');
return JSON.parse(raw);
} catch {
return null;
}
}
/* ── Cached Index Accessor (D-14) ── */
_getCachedIndex() {
if (!this._cachedItems) {
const index = this._loadIndex();
this._cachedItems = index ? (index.items || []) : [];
}
return this._cachedItems;
}
/* ── Single Paper Lookup by Key (D-12, D-18) ── */
_findEntry(key) {
if (!key) return null;
const entry = this._getCachedIndex().find(item => item.zotero_key === key) || null;
return overlayEntryWorkflowState(this.app, entry);
}
_patchCachedEntry(key, patch) {
if (!key || !this._cachedItems) return;
const idx = this._cachedItems.findIndex(item => item.zotero_key === key);
if (idx === -1) return;
this._cachedItems[idx] = patchEntryWorkflowState(this._cachedItems[idx], patch);
}
/* ── Filter Papers by Domain (D-13, D-16) ── */
_filterByDomain(domain) {
if (!domain) return [];
return this._getCachedIndex().filter(item => item.domain === domain);
}
/* ── Metric Cards (Enhanced D-04, D-05, D-06) ── */
_renderStats(d) {
this._versionBadge.setText(this._paperforgeVersion || (d.version ? 'v' + d.version : 'v\u2014'));
if (!d || typeof d.total_papers === 'undefined') {
this._renderSkeleton(this._metricsEl);
return;
}
if (!this._metricsEl) return; // guard: global mode no longer creates metrics container
this._metricsEl.removeClass('paperforge-loading');
const totalPapers = d.total_papers || 0;
const totalFormal = d.formal_notes || 0;
const metrics = [
{ value: totalPapers, label: 'Papers', color: 'var(--color-cyan)', barMax: 0 },
{ value: totalFormal, label: 'Formal Notes', color: 'var(--color-blue)', barMax: totalPapers },
{ value: d.exports || 0, label: 'Exports', color: 'var(--color-purple)', barMax: 0 },
];
for (const m of metrics) {
const card = this._metricsEl.createEl('div', { cls: 'paperforge-metric-card' });
card.style.setProperty('--metric-color', m.color);
card.createEl('div', { cls: 'paperforge-metric-value', text: (m.value)?.toString() || '\u2014' });
card.createEl('div', { cls: 'paperforge-metric-label', text: m.label });
if (m.barMax > 0) {
this._buildMetricBar(card, m.value, m.barMax);
}
}
}
/* ── OCR Pipeline ── */
_renderOcr(d) {
if (!this._ocrSection) return; // guard: global mode no longer creates OCR container
const ocr = d.ocr || {};
const total = ocr.total || 0;
if (total === 0) {
this._ocrSection.style.display = 'none';
return;
}
this._ocrSection.style.display = 'block';
this._ocrEmpty.style.display = 'none';
const done = ocr.done || 0;
const pending = ocr.pending || 0;
const processing = ocr.processing || 0;
const failed = ocr.failed || 0;
/* Badge */
this._ocrBadge.removeClass('active', 'idle');
if (processing > 0) {
this._ocrBadge.addClass('active');
this._ocrBadge.setText('Processing');
} else if (pending > 0) {
this._ocrBadge.addClass('idle');
this._ocrBadge.setText('Pending');
} else {
this._ocrBadge.addClass('idle');
this._ocrBadge.setText('Idle');
}
/* Progress bar */
this._ocrTrack.empty();
if (processing > 0) {
this._ocrTrack.addClass('paperforge-processing');
} else {
this._ocrTrack.removeClass('paperforge-processing');
}
const segs = [
{ cls: 'pending', count: pending },
{ cls: 'active', count: processing },
{ cls: 'done', count: done },
{ cls: 'failed', count: failed },
];
for (const s of segs) {
if (s.count > 0) {
const pct = (s.count / total * 100).toFixed(1);
this._ocrTrack.createEl('div', {
cls: `paperforge-progress-seg ${s.cls}`,
attr: { style: `width:${pct}%` },
});
}
}
/* Counts row */
this._ocrCounts.empty();
const labels = [
{ cls: 'pending', value: pending, label: 'Pending' },
{ cls: 'active', value: processing, label: 'Processing' },
{ cls: 'done', value: done, label: 'Done' },
{ cls: 'failed', value: failed, label: 'Failed' },
];
for (const l of labels) {
const cnt = this._ocrCounts.createEl('div', { cls: 'paperforge-ocr-count' });
cnt.createEl('div', { cls: 'paperforge-ocr-count-value', text: l.value.toString() });
cnt.createEl('div', { cls: 'paperforge-ocr-count-label', text: l.label });
}
}
/* ── Lifecycle Stepper (D-07 through D-11) ── */
_renderLifecycleStepper(container, lifecycle, currentStage) {
if (!lifecycle || !currentStage) {
this._renderSkeleton(container);
return;
}
const stages = [
{ key: 'indexed', label: 'Indexed' },
{ key: 'pdf_ready', label: 'PDF Ready' },
{ key: 'fulltext_ready', label: 'Fulltext Ready' },
{ key: 'deep_read_done', label: 'Deep Read' },
];
const stepper = container.createEl('div', { cls: 'paperforge-lifecycle-stepper' });
let foundCurrent = false;
for (const stage of stages) {
const step = stepper.createEl('div', { cls: 'step' });
step.createEl('div', { cls: 'step-indicator' });
step.createEl('div', { cls: 'step-label', text: stage.label });
if (stage.key === currentStage) {
step.addClass('current');
foundCurrent = true;
} else if (!foundCurrent) {
step.addClass('completed');
} else {
step.addClass('pending');
}
}
}
/* ── Health Matrix (D-12 through D-16) ── */
_renderHealthMatrix(container, health) {
if (!health) {
this._renderSkeleton(container);
return;
}
const dimensions = [
{ key: 'pdf_health', label: 'PDF Health', iconOk: '\u2713', iconWarn: '\u26A0', iconFail: '\u2717' },
{ key: 'ocr_health', label: 'OCR Health', iconOk: '\u2713', iconWarn: '\u26A0', iconFail: '\u2717' },
{ key: 'note_health', label: 'Note Health', iconOk: '\u2713', iconWarn: '\u26A0', iconFail: '\u2717' },
{ key: 'asset_health', label: 'Asset Health', iconOk: '\u2713', iconWarn: '\u26A0', iconFail: '\u2717' },
];
const matrix = container.createEl('div', { cls: 'paperforge-health-matrix' });
for (const dim of dimensions) {
const status = health[dim.key] || 'healthy';
const cell = matrix.createEl('div', { cls: 'paperforge-health-cell' });
let icon, statusClass, tooltip;
if (status === 'healthy' || status === 'ok') {
icon = dim.iconOk;
statusClass = 'ok';
tooltip = `${dim.label}: OK`;
} else if (status === 'warn' || status === 'warning' || status === 'degraded') {
icon = dim.iconWarn;
statusClass = 'warn';
tooltip = `${dim.label}: Needs Attention`;
} else {
icon = dim.iconFail;
statusClass = 'fail';
tooltip = `${dim.label}: Failed`;
}
cell.addClass(statusClass);
cell.setAttribute('title', tooltip);
cell.createEl('div', { cls: 'paperforge-health-cell-icon', text: icon });
cell.createEl('div', { cls: 'paperforge-health-cell-label', text: dim.label });
}
}
/* ── Maturity Gauge (D-17 through D-20) ── */
_renderMaturityGauge(container, maturityLevel, blockingChecks) {
if (maturityLevel == null || maturityLevel === undefined) {
this._renderSkeleton(container);
return;
}
const gauge = container.createEl('div', { cls: 'paperforge-maturity-gauge' });
const track = gauge.createEl('div', { cls: 'gauge-track' });
const maxLevel = 4;
const currentLevel = Math.max(1, Math.min(maxLevel, Math.round(maturityLevel)));
for (let i = 1; i <= maxLevel; i++) {
const seg = track.createEl('div', { cls: 'gauge-segment' });
if (i <= currentLevel) {
seg.addClass('filled');
seg.addClass(`level-${i}`);
}
}
gauge.createEl('div', { cls: 'gauge-level', text: `Level ${currentLevel} / ${maxLevel}` });
if (currentLevel < maxLevel && blockingChecks) {
const blockers = typeof blockingChecks === 'string' ? [blockingChecks] : blockingChecks;
if (blockers.length > 0) {
const list = gauge.createEl('ul', { cls: 'gauge-blockers' });
for (const check of blockers) {
list.createEl('li', { text: check });
}
}
}
}
/* ── Bar Chart (D-21 through D-23) ── */
_renderBarChart(container, lifecycleCounts) {
if (!lifecycleCounts || Object.keys(lifecycleCounts).length === 0) {
this._renderEmptyState(container, 'No lifecycle data');
return;
}
const stages = [
{ key: 'indexed', label: 'Indexed', cls: 'stage-indexed' },
{ key: 'pdf_ready', label: 'PDF Ready', cls: 'stage-pdf-ready' },
{ key: 'fulltext_ready', label: 'Fulltext Ready', cls: 'stage-fulltext-ready' },
{ key: 'deep_read_done', label: 'Deep Read', cls: 'stage-deep-read' },
];
const chart = container.createEl('div', { cls: 'paperforge-bar-chart' });
const maxCount = Math.max(1, ...stages.map(s => lifecycleCounts[s.key] || 0));
for (const stage of stages) {
const count = lifecycleCounts[stage.key] || 0;
const pct = (count / maxCount) * 100;
const row = chart.createEl('div', { cls: 'bar-row' });
row.createEl('div', { cls: 'bar-label', text: stage.label });
const track = row.createEl('div', { cls: 'bar-track' });
const fill = track.createEl('div', {
cls: `bar-fill ${stage.cls}`,
attr: { style: `width:${pct.toFixed(1)}%` },
});
row.createEl('div', { cls: 'bar-count', text: count.toString() });
}
}
/* ── Invalidate cached index (D-14) ── */
_invalidateIndex() {
this._cachedItems = null;
}
/* ── Extract zotero_key from workspace directory name ── */
_extractZoteroKeyFromPath(filePath) {
if (!filePath) return null;
let dir = path.dirname(filePath);
while (true) {
const basename = path.basename(dir);
if (!basename || basename === '.') break;
const match = basename.match(/^([A-Z0-9]{8})(?:\s*-\s*)/i);
if (match) return match[1];
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
/* ── Pure Mode Resolution (D-07, Phase 32) ── */
_resolveModeForFile(file) {
if (!file) return { mode: 'global', filePath: null, key: null, domain: null };
const ext = file.extension;
const filePath = file.path;
if (ext === 'base') {
return { mode: 'collection', filePath, key: null, domain: file.basename.trim() };
}
if (ext === 'md') {
const cache = this.app.metadataCache.getFileCache(file);
const fmKey = cache && cache.frontmatter && cache.frontmatter.zotero_key;
if (fmKey) {
return { mode: 'paper', filePath, key: fmKey, domain: null };
}
}
// PDF files: match to paper via pdf_path in canonical index
if (ext === 'pdf') {
const items = this._getCachedIndex();
for (const item of items) {
const pathMatch = (item.pdf_path || '').match(/\[\[([^\]]+)\]\]/);
const targetPath = pathMatch ? pathMatch[1] : item.pdf_path;
if (targetPath === filePath) {
return { mode: 'paper', filePath, key: item.zotero_key, domain: null };
}
}
}
// Workspace path detection: any file inside a paper workspace directory
const wsKey = this._extractZoteroKeyFromPath(filePath);
if (wsKey) {
return { mode: 'paper', filePath, key: wsKey, domain: null };
}
return { mode: 'global', filePath, key: null, domain: null };
}
/* ── Context Detection & Mode Switch (D-01, D-02, D-03, D-04, D-10) ── */
_detectAndSwitch() {
const resolved = this._resolveModeForFile(this.app.workspace.getActiveFile());
this._currentDomain = resolved.domain || null;
this._currentPaperKey = resolved.key || null;
this._currentPaperEntry = resolved.key ? this._findEntry(resolved.key) : null;
this._switchMode(resolved.mode, resolved.filePath);
}
/* ── Mode Switching (D-05, D-06) ── */
_switchMode(mode, filePath) {
// D-06: Identity guard — check BOTH mode AND file path
if (this._currentMode === mode && this._currentFilePath === filePath) {
this._refreshCurrentMode();
return;
}
this._currentMode = mode;
this._currentFilePath = filePath;
this._techDetailsExpanded = false; // reset disclosure state on mode switch
// Clear existing content (D-06)
this._contentEl.empty();
this._contentEl.removeClass('switching');
// Update header (D-07)
this._renderModeHeader(mode);
// Render mode-specific content (D-06)
switch (mode) {
case 'global':
this._renderGlobalMode();
break;
case 'paper':
this._renderPaperMode();
break;
case 'collection':
this._renderCollectionMode();
break;
}
}
/* ── Global Mode Render: System Homepage ── */
_renderGlobalMode() {
const view = this._contentEl.createEl('div', { cls: 'paperforge-global-view' });
// Drift warning banner (hidden by default, shown on version mismatch)
this._driftBannerEl = view.createEl('div', { cls: 'paperforge-drift-banner' });
this._driftBannerEl.style.display = 'none';
// ── Library Snapshot ──
const items = this._getCachedIndex();
const totalPapers = items.length;
let pdfReady = 0, ocrDone = 0, deepReadDone = 0;
for (const item of items) {
if (item.has_pdf) pdfReady++;
if (item.ocr_status === 'done') ocrDone++;
if (item.deep_reading_status === 'done') deepReadDone++;
}
const snapshot = view.createEl('div', { cls: 'paperforge-library-snapshot' });
snapshot.createEl('div', { cls: 'paperforge-section-label', text: 'Library Snapshot' });
const pills = snapshot.createEl('div', { cls: 'paperforge-snapshot-pills' });
const snapData = [
{ value: totalPapers, label: 'papers' },
{ value: pdfReady, label: 'PDFs ready' },
{ value: ocrDone, label: 'OCR done' },
{ value: deepReadDone, label: 'deep-read done' },
];
for (const s of snapData) {
const pill = pills.createEl('div', { cls: 'paperforge-snapshot-pill' });
pill.createEl('span', { cls: 'paperforge-snapshot-value', text: String(s.value) });
pill.createEl('span', { cls: 'paperforge-snapshot-label', text: ' ' + s.label });
}
// ── System Status ──
const statusSection = view.createEl('div', { cls: 'paperforge-system-status' });
statusSection.createEl('div', { cls: 'paperforge-section-label', text: 'System Status' });
const statusGrid = statusSection.createEl('div', { cls: 'paperforge-status-grid' });
// Runtime
const plugin = this.app.plugins.plugins['paperforge'];
const pluginVer = plugin?.manifest?.version || '?';
let pyVer = this._paperforgeVersion;
if (!pyVer) {
// Async _fetchVersion() may not have resolved yet — try synchronous fallback
try {
const vp = this.app.vault.adapter.basePath;
const { path: pyExe, extraArgs = [] } = resolvePythonExecutable(vp, plugin?.settings);
const raw = execFileSync(pyExe, [...extraArgs, '-c', 'import paperforge; print(paperforge.__version__)'], { cwd: vp, timeout: 5000, encoding: 'utf-8', windowsHide: true }).trim();
if (raw) { pyVer = raw.startsWith('v') ? raw : 'v' + raw; this._paperforgeVersion = pyVer; }
} catch {}
}
pyVer = pyVer || '\u2014';
const runtimeOk = pyVer === 'v' + pluginVer;
this._renderSystemStatusRow(statusGrid, 'Runtime', runtimeOk ? 'healthy' : 'mismatch',
runtimeOk ? ('v' + pluginVer) : ('plugin v' + pluginVer + ' \u2260 CLI ' + pyVer));
// Index
const index = this._loadIndex();
const indexOk = index && index.items && index.items.length > 0;
this._renderSystemStatusRow(statusGrid, 'Index', indexOk ? 'healthy' : 'missing',
indexOk ? (index.items.length + ' entries') : 'formal-library.json not found');
// Zotero export (check if any JSON export exists)
const systemDir = plugin?.settings?.system_dir || 'System';
const vp = this.app.vault.adapter.basePath;
let exportOk = false, exportDetail = 'No exports found';
try {
const exportsDir = path.join(vp, systemDir, 'PaperForge', 'exports');
if (fs.existsSync(exportsDir)) {
const files = fs.readdirSync(exportsDir).filter(f => f.endsWith('.json'));
exportOk = files.length > 0;
exportDetail = exportOk ? (files.length + ' export(s)') : 'No JSON exports';
}
} catch (_) {}
this._renderSystemStatusRow(statusGrid, 'Zotero Export', exportOk ? 'healthy' : 'missing', exportDetail);
// OCR token (check: plugin settings → .env file → OS environment)
let tokenOk = !!(plugin?.settings?.paddleocr_api_key);
if (!tokenOk) {
try {
const sysDir = plugin?.settings?.system_dir || 'System';
const envPath = path.join(vp, sysDir, 'PaperForge', '.env');
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf-8');
const tokenMatch = envContent.match(/^PADDLEOCR_API_TOKEN\s*=\s*(.+)$/m);
tokenOk = !!(tokenMatch && tokenMatch[1] && tokenMatch[1].trim());
}
} catch (_) {}
}
if (!tokenOk) {
tokenOk = !!(process.env.PADDLEOCR_API_TOKEN || process.env.PADDLEOCR_API_KEY || process.env.OCR_TOKEN);
}
this._renderSystemStatusRow(statusGrid, 'OCR Token', tokenOk ? 'configured' : 'missing',
tokenOk ? 'Configured' : 'Not set');
// Memory Layer health
let memOk = false, memDetail = '';
const vp2 = this.app.vault.adapter.basePath;
const rh = memoryState.getRuntimeHealth(vp2);
memOk = memoryState.isHealthOk(vp2);
memDetail = rh?.summary?.reason || rh?.summary?.status || 'Unknown';
this._renderSystemStatusRow(statusGrid, 'Memory Layer', memOk ? 'healthy' : 'fail', memDetail);
// ── Issues Panel (only for serious blockers) ──
const hasVersionMismatch = !runtimeOk && pyVer !== '\u2014';
const hasIssues = hasVersionMismatch || !indexOk || !exportOk || !tokenOk;
if (hasIssues) {
const issueSection = view.createEl('div', { cls: 'paperforge-issue-summary' });
issueSection.createEl('div', { cls: 'paperforge-section-label', text: '需要处理' });
const issueList = issueSection.createEl('div', { cls: 'paperforge-issue-list' });
if (hasVersionMismatch) issueList.createEl('div', { cls: 'paperforge-issue-item', text: 'Runtime version mismatch' });
if (!indexOk) issueList.createEl('div', { cls: 'paperforge-issue-item', text: 'Index missing or corrupted' });
if (!exportOk) issueList.createEl('div', { cls: 'paperforge-issue-item', text: 'No Zotero export found' });
if (!tokenOk) issueList.createEl('div', { cls: 'paperforge-issue-item', text: 'PaddleOCR API key not configured' });
const issueActions = issueSection.createEl('div', { cls: 'paperforge-issue-actions' });
const doctorBtn = issueActions.createEl('button', { cls: 'paperforge-contextual-btn' });
doctorBtn.createEl('span', { text: 'Run Doctor' });
doctorBtn.addEventListener('click', () => {
const action = ACTIONS.find(a => a.id === 'paperforge-doctor');
if (action) this._runAction(action, doctorBtn);
});
const repairBtn = issueActions.createEl('button', { cls: 'paperforge-contextual-btn' });
repairBtn.createEl('span', { text: 'Repair Issues' });
repairBtn.addEventListener('click', () => {
const action = ACTIONS.find(a => a.id === 'paperforge-repair');
if (action) this._runAction(action, repairBtn);
});
}
// ── Contextual Actions ──
const actionsRow = view.createEl('div', { cls: 'paperforge-global-actions' });
actionsRow.createEl('div', { cls: 'paperforge-section-label', text: 'Start Working' });
const btnsRow = actionsRow.createEl('div', { cls: 'paperforge-global-actions-row' });
const hubBtn = btnsRow.createEl('button', { cls: 'paperforge-contextual-btn primary' });
hubBtn.createEl('span', { cls: 'paperforge-contextual-btn-icon', text: '\uD83D\uDCC1' });
hubBtn.createEl('span', { text: 'Open Literature Hub' });
hubBtn.addEventListener('click', () => {
const baseDir = plugin?.settings?.base_dir || 'Bases';
const baseFolder = this.app.vault.getAbstractFileByPath(baseDir);
if (baseFolder) {
// Find first .base file in the base directory
let baseFile = null;
if (baseFolder.children) {
baseFile = baseFolder.children.find(f => f.extension === 'base');
}
if (baseFile) {
const leaf = this.app.workspace.getLeaf(false);
if (leaf) leaf.openFile(baseFile);
} else {
new Notice('[!!] No .base file found in ' + baseDir, 6000);
}
} else {
new Notice('[!!] Base directory not found: ' + baseDir, 6000);
}
});
const globalSyncBtn = btnsRow.createEl('button', { cls: 'paperforge-contextual-btn' });
globalSyncBtn.createEl('span', { cls: 'paperforge-contextual-btn-icon', text: '\u21BB' });
globalSyncBtn.createEl('span', { text: 'Sync Library' });
globalSyncBtn.addEventListener('click', () => {
const action = ACTIONS.find(a => a.id === 'paperforge-sync');
if (action) this._runAction(action, globalSyncBtn);
});
const globalOcrBtn = btnsRow.createEl('button', { cls: 'paperforge-contextual-btn' });
globalOcrBtn.createEl('span', { cls: 'paperforge-contextual-btn-icon', text: '\u229E' });
globalOcrBtn.createEl('span', { text: 'Run OCR' });
globalOcrBtn.addEventListener('click', () => {
const action = ACTIONS.find(a => a.id === 'paperforge-ocr');
if (action) this._runAction(action, globalOcrBtn);
});
const globalDoctorBtn = btnsRow.createEl('button', { cls: 'paperforge-contextual-btn' });
globalDoctorBtn.createEl('span', { cls: 'paperforge-contextual-btn-icon', text: '\u2695' });
globalDoctorBtn.createEl('span', { text: 'Run Doctor' });
globalDoctorBtn.addEventListener('click', () => {
const action = ACTIONS.find(a => a.id === 'paperforge-doctor');
if (action) this._runAction(action, globalDoctorBtn);
});
const globalStatusBtn = btnsRow.createEl('button', { cls: 'paperforge-contextual-btn' });
globalStatusBtn.createEl('span', { cls: 'paperforge-contextual-btn-icon', text: '\u2139' });
globalStatusBtn.createEl('span', { text: 'Refresh Status' });
globalStatusBtn.addEventListener('click', () => {
this._fetchStats();
});
const globalRepairBtn = btnsRow.createEl('button', { cls: 'paperforge-contextual-btn' });
globalRepairBtn.createEl('span', { cls: 'paperforge-contextual-btn-icon', text: '\u21BA' });
globalRepairBtn.createEl('span', { text: 'Repair Issues' });
globalRepairBtn.addEventListener('click', () => {
const action = ACTIONS.find(a => a.id === 'paperforge-repair');
if (action) this._runAction(action, globalRepairBtn);
});
}
/* ── System Status Row helper ── */
_renderSystemStatusRow(container, label, status, detail) {
const row = container.createEl('div', { cls: 'paperforge-status-row' });
const dot = row.createEl('span', { cls: 'paperforge-status-dot' });
dot.addClass(status === 'healthy' || status === 'configured' ? 'ok' : 'fail');
row.createEl('span', { cls: 'paperforge-status-label', text: label });
row.createEl('span', { cls: 'paperforge-status-detail', text: detail || '' });
}
/* ── Per-Paper Mode Render: Reading Companion ── */
_renderPaperMode() {
const entry = this._currentPaperEntry;
const key = this._currentPaperKey;
if (!key) {
this._renderEmptyState(this._contentEl, 'No paper data available.');
return;
}
if (!entry) {
this._contentEl.createEl('div', {
cls: 'paperforge-content-placeholder',
text: 'Paper "' + key + '" not found in canonical index. Sync first.',
});
return;
}
const view = this._contentEl.createEl('div', { cls: 'paperforge-paper-view' });
// ── Header ──
const header = view.createEl('div', { cls: 'paperforge-paper-header' });
header.createEl('div', { cls: 'paperforge-paper-title', text: entry.title || 'Untitled' });
const meta = header.createEl('div', { cls: 'paperforge-paper-meta' });
if (entry.authors && entry.authors.length > 0) {
meta.createEl('span', { cls: 'paperforge-paper-authors', text: entry.authors.join(', ') });
}
if (entry.year) {
meta.createEl('span', { cls: 'paperforge-paper-year', text: String(entry.year) });
}
// ── Status Strip + File Buttons (merged row) ──
const strip = view.createEl('div', { cls: 'paperforge-status-strip' });
const stripLeft = strip.createEl('div', { cls: 'paperforge-status-strip-left' });
const stripRight = strip.createEl('div', { cls: 'paperforge-status-strip-right' });
const items = [
{ key: 'pdf', label: 'PDF', ok: entry.has_pdf === true },
{ key: 'ocr', label: 'OCR',
ok: entry.ocr_status === 'done',
pending: ['pending','queued','processing'].includes(entry.ocr_status || ''),
fail: ['failed','blocked','done_incomplete','nopdf'].includes(entry.ocr_status || '') },
{ key: 'deep', label: '精读', ok: entry.deep_reading_status === 'done' },
];
for (const item of items) {
const pill = stripLeft.createEl('span', { cls: 'paperforge-status-pill' });
let statusCls = 'pending';
if (item.ok) statusCls = 'ok';
else if (item.fail) statusCls = 'fail';
else if (item.pending) statusCls = 'pending';
pill.addClass(statusCls);
const icon = item.ok ? '\u2713' : (item.fail ? '\u2717' : '\u25CB');
pill.createEl('span', { cls: 'paperforge-status-pill-icon', text: icon });
pill.createEl('span', { text: ' ' + item.label });
}
// File buttons in status strip row
if (entry.pdf_path) {
const pdfBtn = stripRight.createEl('button', { cls: 'paperforge-contextual-btn' });
pdfBtn.createEl('span', { cls: 'paperforge-contextual-btn-icon', text: '\uD83D\uDCC4' });
pdfBtn.createEl('span', { text: '打开 PDF' });
pdfBtn.addEventListener('click', () => {
const pathMatch = entry.pdf_path.match(/\[\[([^\]]+)\]\]/);
const targetPath = pathMatch ? pathMatch[1] : entry.pdf_path;
const file = this.app.vault.getAbstractFileByPath(targetPath);
if (file) { this.app.workspace.openLinkText(targetPath, ''); }
else { new Notice('[!!] PDF not found: ' + targetPath, 6000); }
});
}
if (entry.fulltext_path) {
const ftBtn = stripRight.createEl('button', { cls: 'paperforge-contextual-btn' });
ftBtn.createEl('span', { cls: 'paperforge-contextual-btn-icon', text: '\uD83D\uDCDD' });
ftBtn.createEl('span', { text: '打开全文' });
ftBtn.addEventListener('click', () => this._openFulltext(entry.fulltext_path));
}
// ── Paper Overview ──
this._renderPaperOverviewCard(view, entry);
// ── Complete state or Next Step ──
if (entry.next_step === 'ready' && entry.deep_reading_status === 'done') {
const complete = view.createEl('div', { cls: 'paperforge-complete-row' });
complete.createEl('span', { text: '\u2713' });
complete.createEl('span', { text: '已完成,可直接使用' });
} else {
this._renderNextStepCard(view, entry, key);
}
// ── Recent Discussion ──
this._renderRecentDiscussionCard(view, entry);
// ── Technical Details (disclosure) ──
this._renderPaperTechnicalDetails(view, entry);
}
/* ── Paper Overview Card: read from formal note body ── */
_renderPaperOverviewCard(container, entry) {
const card = container.createEl('div', { cls: 'paperforge-paper-overview' });
const header = card.createEl('div', { cls: 'paperforge-paper-overview-header' });
header.createEl('span', { cls: 'paperforge-paper-overview-title', text: '文章概览' });
const body = card.createEl('div', { cls: 'paperforge-paper-overview-body' });
const excerptEl = body.createEl('div', { cls: 'paperforge-paper-overview-excerpt', text: '加载中...' });
// Async read formal note body
if (entry.note_path) {
const noteFile = this.app.vault.getAbstractFileByPath(entry.note_path);
if (noteFile) {
this.app.vault.read(noteFile).then((content) => {
const extracted = this._extractOverviewFromNote(content);
if (extracted) {
const truncated = extracted.length > 200 ? extracted.slice(0, 200) + '...' : extracted;
excerptEl.setText(truncated);
if (extracted.length > 200) {
const expandContainer = body.createEl('div', { cls: 'paperforge-expand-container' });
const expandBtn = expandContainer.createEl('button', { cls: 'paperforge-expand-icon', title: '展开/收起' });
// Insert arrow SVG
expandBtn.innerHTML = '';
let expanded = false;
// Make the whole container clickable
expandContainer.addEventListener('click', () => {
excerptEl.setText(expanded ? truncated : extracted);
expandBtn.innerHTML = expanded
? ''
: '';
expanded = !expanded;
});
}
} else {
excerptEl.setText('尚未生成文章概览。运行 /pf-deep 开始精读。');
}
}).catch(() => {
excerptEl.setText('无法读取笔记内容');
});
} else {
excerptEl.setText('笔记文件不存在');
}
} else {
excerptEl.setText('尚未生成文章概览');
}
}
/* ── Extract overview from formal note body ── */
_extractOverviewFromNote(content) {
if (!content) return null;
// Find "## 🔍 精读" section
const deepIdx = content.indexOf('## 🔍 精读');
if (deepIdx === -1) return null;
const section = content.slice(deepIdx);
// Extract "**一句话总览**" or "**文章摘要**" or "**一句话总览:**"
const markers = ['**一句话总览:**', '**一句话总览**', '**文章摘要:**', '**文章摘要**'];
for (const marker of markers) {
const idx = section.indexOf(marker);
if (idx !== -1) {
const after = section.slice(idx + marker.length);
// Cut at next marker or double newline
const cutMarkers = ['**5 Cs', '**Figure', '**证据', '### Pass 2', '## '];
let nextCut = after.length;
for (const cm of cutMarkers) {
const ci = after.indexOf(cm);
if (ci !== -1 && ci < nextCut) nextCut = ci;
}
// Also stop at double newline
const nnIdx = after.indexOf('\n\n');
if (nnIdx !== -1 && nnIdx < nextCut) nextCut = nnIdx;
let text = after.slice(0, nextCut).trim();
// Clean up leading ** if marker didn't include trailing **
if (text.startsWith('**')) text = text.slice(2);
if (text.endsWith('**')) text = text.slice(0, -2);
return text || null;
}
}
// Fallback: return first paragraph of the deep reading section
const firstNewline = section.indexOf('\n');
if (firstNewline === -1) return null;
const para = section.slice(firstNewline + 1).split('\n\n')[0].trim();
// Skip empty or header-only lines
if (!para || para.startsWith('###') || para.startsWith('##')) return null;
return para.length > 300 ? para.slice(0, 300) + '...' : para;
}
/* ── Recent Discussion Card: read ai/discussion.md ── */
_renderRecentDiscussionCard(container, entry) {
const card = container.createEl('div', { cls: 'paperforge-discussion-card' });
card.style.display = 'none';
if (!entry.note_path) return;
const lastSlash = entry.note_path.lastIndexOf('/');
const wsDir = lastSlash !== -1 ? entry.note_path.substring(0, lastSlash) : '.';
const mdPath = wsDir + '/ai/discussion.md';
this.app.vault.adapter.exists(mdPath).then((exists) => {
if (!exists) return;
return this.app.vault.adapter.read(mdPath);
}).then(async (raw) => {
if (!raw) return;
const pairs = this._parseDiscussionMD(raw);
if (!pairs || pairs.length === 0) return;
card.style.display = 'block';
const header = card.createEl('div', { cls: 'paperforge-discussion-header' });
header.createEl('span', { cls: 'paperforge-discussion-title', text: '最近讨论' });
for (const qa of pairs) {
const item = card.createEl('div', { cls: 'paperforge-discussion-item' });
const qEl = item.createEl('div', { cls: 'paperforge-discussion-q' });
qEl.createEl('span', { cls: 'paperforge-discussion-q-label', text: '提问:' });
qEl.createEl('span', { cls: 'paperforge-discussion-q-text', text: qa.question });
const aEl = item.createEl('div', { cls: 'paperforge-discussion-a' });
let longAnswer = false;
if (qa.answer && qa.answer.length > 500) {
longAnswer = true;
aEl.classList.add('paperforge-discussion-a-collapsed');
}
await MarkdownRenderer.render(this.app, qa.answer || '', aEl, mdPath, this);
if (longAnswer) {
let expanded = false;
item.style.cursor = 'pointer';
item.addEventListener('click', () => {
expanded = !expanded;
aEl.classList.toggle('paperforge-discussion-a-collapsed', !expanded);
aEl.classList.toggle('paperforge-discussion-a-expanded', expanded);
});
}
}
// "查看全部" link
const viewAll = card.createEl('a', { cls: 'paperforge-discussion-viewall', text: '查看全部讨论 →' });
viewAll.addEventListener('click', (e) => {
e.preventDefault();
const discFile = this.app.vault.getAbstractFileByPath(mdPath);
if (discFile) {
this.app.workspace.openLinkText(mdPath, '');
} else {
new Notice('讨论文件尚未生成');
}
});
}).catch((e) => {
console.error('PaperForge: discussion.md read error', mdPath, e.message);
});
}
_parseDiscussionMD(content) {
const sessions = content.split(/\n## /).slice(1);
if (sessions.length === 0) return null;
const lastSession = sessions[sessions.length - 1];
const pairs = [];
const qaBlocks = lastSession.split(/\*\*问题:\*\*/).slice(1);
for (const block of qaBlocks) {
const answerMatch = block.match(/\*\*解答:\*\*/);
if (!answerMatch) continue;
const question = block.substring(0, answerMatch.index).trim();
const answer = block.substring(answerMatch.index + '**解答:**'.length).trim();
pairs.push({ question, answer });
}
return pairs.slice(-3);
}
/* ── Paper Technical Details (disclosure with workflow toggles) ── */
_renderPaperTechnicalDetails(container, entry) {
const key = this._currentPaperKey;
const section = container.createEl('div', { cls: 'paperforge-technical-details' });
const toggle = section.createEl('button', { cls: 'paperforge-technical-details-toggle' });
const body = section.createEl('div', { cls: 'paperforge-technical-details-body' });
body.style.display = 'none';
// Restore expanded state from previous render (prevents flash on refresh)
if (this._techDetailsExpanded) {
body.style.display = 'block';
toggle.setText('技术详情 ▾');
} else {
toggle.setText('技术详情 ▸');
}
toggle.addEventListener('click', () => {
const visible = body.style.display !== 'none';
body.style.display = visible ? 'none' : 'block';
toggle.setText(visible ? '技术详情 ▸' : '技术详情 ▾');
this._techDetailsExpanded = !visible;
});
// Workflow toggles inside disclosure
const togglesRow = body.createEl('div', { cls: 'paperforge-workflow-toggles' });
const toggleFields = [
{ key: 'do_ocr', label: 'OCR', hint: '加入 OCR' },
{ key: 'analyze', label: '精读', hint: '标记精读' },
];
for (const tf of toggleFields) {
const label = togglesRow.createEl('label', { cls: 'paperforge-workflow-toggle' });
const cb = label.createEl('input', { type: 'checkbox', cls: 'paperforge-workflow-checkbox' });
cb.checked = entry[tf.key] === true;
label.createEl('span', { cls: 'paperforge-workflow-toggle-label', text: tf.label });
label.createEl('span', { cls: 'paperforge-workflow-toggle-hint', text: tf.hint });
cb.addEventListener('change', async () => {
const noteFile = entry.note_path ? this.app.vault.getAbstractFileByPath(entry.note_path) : null;
if (!noteFile) { new Notice('[!!] Note file not found', 6000); return; }
const newVal = cb.checked;
await this.app.fileManager.processFrontMatter(noteFile, (fm) => { fm[tf.key] = newVal; });
this._patchCachedEntry(key, { [tf.key]: newVal });
this._currentPaperEntry = patchEntryWorkflowState(this._currentPaperEntry, { [tf.key]: newVal });
});
}
const health = entry.health || {};
const rows = [
['PDF Health', health.pdf_health || '\u2014'],
['OCR Status', entry.ocr_status || '\u2014'],
['Asset Health', health.asset_health || '\u2014'],
['Note Path', entry.note_path || '\u2014'],
['Fulltext Path', entry.fulltext_path || '\u2014'],
];
for (const [l, v] of rows) {
const row = body.createEl('div', { cls: 'paperforge-technical-row' });
row.createEl('span', { cls: 'paperforge-technical-label', text: l });
row.createEl('span', { cls: 'paperforge-technical-value', text: String(v) });
}
}
/* ── Next-Step Recommendation Card (D-08, D-09) ── */
_renderNextStepCard(container, entry, key) {
const nextStep = entry.next_step || 'ready';
// Map next_step value to human-readable info
const stepInfo = {
'sync': { label: 'Sync Needed', text: 'This paper needs to be synced from Zotero. Click to run sync.', cmd: 'sync', icon: '\u21BB' },
'ocr': { label: 'OCR Needed', text: 'Fulltext is missing but PDF is present. Click to run OCR.', cmd: 'ocr', icon: '\u229E' },
'repair': { label: 'Repair Needed', text: 'State divergence or path errors detected. Click to repair.', cmd: 'repair', icon: '\u21BA' },
'rebuild index':{ label: 'Rebuild Needed', text: 'Index may be stale. Click to run sync to rebuild.', cmd: 'sync', icon: '\u21BB' },
'/pf-deep': { label: 'Ready for Deep Reading', text: 'Fulltext is ready. Copy /pf-deep command and run in your agent.', cmd: null, icon: '\uD83D\uDD0D' },
'ready': { label: 'All Set', text: 'This paper is fully processed and ready for use.', cmd: 'ready', icon: '\u2713' },
};
const info = stepInfo[nextStep] || stepInfo['ready'];
// Build the card
const card = container.createEl('div', { cls: 'paperforge-next-step-card' });
if (nextStep === 'ready') card.addClass('ready');
card.createEl('div', { cls: 'paperforge-next-step-label', text: 'Recommended Next Step' });
card.createEl('div', { cls: 'paperforge-next-step-text', text: info.text });
if (info.cmd && info.cmd !== 'ready') {
// Action button for sync/ocr/repair/rebuild index — reuse existing _runAction
const trigger = card.createEl('button', { cls: 'paperforge-next-step-trigger' });
trigger.createEl('span', { text: info.icon + ' ' + info.label });
trigger.addEventListener('click', () => {
const action = ACTIONS.find(a => a.cmd === info.cmd);
if (action) this._runAction(action, trigger);
});
} else if (nextStep === '/pf-deep') {
// Copy full command /pf-deep to clipboard (DASH-02)
const trigger = card.createEl('button', { cls: 'paperforge-next-step-trigger' });
trigger.createEl('span', { text: '\uD83D\uDCCB ' + t('copy_pf_deep_cmd') });
trigger.addEventListener('click', () => {
const fullCmd = '/pf-deep ' + key;
navigator.clipboard.writeText(fullCmd).then(() => {
trigger.setText('\u2713 ' + t('copied'));
new Notice(fullCmd + ' copied');
}).catch(() => {
new Notice('[!!] Clipboard write failed', 6000);
});
});
// Show "Run in [agent_platform]" label below the button (DASH-02)
const platformKey = this.app.plugins.plugins['paperforge']?.settings?.agent_platform || 'opencode';
const AGENTS = {
'opencode': 'OpenCode',
'claude': 'Claude Code',
'cursor': 'Cursor',
'github_copilot': 'GitHub Copilot',
'windsurf': 'Windsurf',
'codex': 'Codex',
'cline': 'Cline'
};
const platformName = AGENTS[platformKey] || platformKey;
const labelEl = card.createEl('div', { cls: 'paperforge-agent-platform-label' });
labelEl.setText(t('run_in_agent').replace('{0}', platformName));
} else if (nextStep === 'ready') {
const trigger = card.createEl('button', { cls: 'paperforge-next-step-trigger' });
trigger.createEl('span', { text: '✓ ' + info.label });
}
}
/* ── Open Fulltext File in Obsidian (D-12) ── */
_openFulltext(fulltextPath) {
if (!fulltextPath) {
new Notice('[!!] No fulltext path available for this paper', 6000);
return;
}
// fulltext_path is relative to vault root (e.g., "Literature/domain/key - Title/fulltext.md")
// Use Obsidian API to open the file
const file = this.app.vault.getAbstractFileByPath(fulltextPath);
if (file) {
this.app.workspace.openLinkText(file.path, '');
} else {
new Notice('[!!] Fulltext file not found: ' + fulltextPath, 6000);
}
}
/* ── Collection Mode Render: Batch Workflow Workspace ── */
_renderCollectionMode() {
const domain = this._currentDomain || 'Unknown';
const domainItems = this._filterByDomain(domain);
if (domainItems.length === 0) {
// Fall back to global mode if no papers match this domain (e.g. "Literature Hub")
this._renderGlobalMode();
return;
}
const view = this._contentEl.createEl('div', { cls: 'paperforge-collection-view' });
// ── Single-pass aggregation ──
const totalPapers = domainItems.length;
let hasPdf = 0, ocrDone = 0, analyzeReady = 0, deepRead = 0;
let ocrPending = 0, ocrProcessing = 0, ocrFailed = 0;
for (const item of domainItems) {
if (item.has_pdf) hasPdf++;
if (item.ocr_status === 'done') ocrDone++;
if (item.ocr_status === 'done' && item.analyze === true) analyzeReady++;
if (item.deep_reading_status === 'done') deepRead++;
const ocs = item.ocr_status || '';
if (ocs === 'pending' || ocs === 'queued') ocrPending++;
else if (ocs === 'processing') ocrProcessing++;
else if (ocs === 'failed' || ocs === 'blocked' || ocs === 'done_incomplete' || ocs === 'nopdf') ocrFailed++;
}
// ── Header ──
const header = view.createEl('div', { cls: 'paperforge-collection-header' });
header.createEl('div', { cls: 'paperforge-collection-title', text: domain });
// ── Workflow Overview (funnel) ──
const wfSection = view.createEl('div', { cls: 'paperforge-workflow-overview' });
wfSection.createEl('div', { cls: 'paperforge-section-label', text: 'Workflow Overview' });
const funnel = wfSection.createEl('div', { cls: 'paperforge-workflow-funnel' });
const stages = [
{ value: totalPapers, label: 'Total' },
{ value: hasPdf, label: 'PDF Ready' },
{ value: ocrDone, label: 'OCR Done' },
{ value: deepRead, label: 'Deep Read' },
];
for (let i = 0; i < stages.length; i++) {
const stage = funnel.createEl('div', { cls: 'paperforge-workflow-stage' });
stage.createEl('div', { cls: 'paperforge-workflow-stage-value', text: String(stages[i].value) });
stage.createEl('div', { cls: 'paperforge-workflow-stage-label', text: stages[i].label });
if (i < stages.length - 1) {
funnel.createEl('div', { cls: 'paperforge-workflow-arrow', text: '\u2192' });
}
}
// ── OCR Pipeline (scoped to this base) ──
if (ocrPending + ocrProcessing + ocrDone + ocrFailed > 0) {
const ocrSection = view.createEl('div', { cls: 'paperforge-ocr-section' });
const ocrHeader = ocrSection.createEl('div', { cls: 'paperforge-collection-ocr-header' });
ocrHeader.createEl('h4', { cls: 'paperforge-ocr-title', text: 'OCR Pipeline' });
const ocrBadge = ocrHeader.createEl('span', { cls: 'paperforge-ocr-badge idle' });
if (ocrProcessing > 0) { ocrBadge.addClass('active'); ocrBadge.setText('Processing'); }
else if (ocrPending > 0) ocrBadge.setText('Pending');
else { ocrBadge.addClass('idle'); ocrBadge.setText('Idle'); }
const ocrTrack = ocrSection.createEl('div', { cls: 'paperforge-progress-track' });
if (ocrProcessing > 0) ocrTrack.addClass('paperforge-processing');
const totalOcr = ocrPending + ocrProcessing + ocrDone + ocrFailed;
const ocrSegs = [
{ cls: 'pending', count: ocrPending },
{ cls: 'active', count: ocrProcessing },
{ cls: 'done', count: ocrDone },
{ cls: 'failed', count: ocrFailed },
];
for (const s of ocrSegs) {
if (s.count > 0) {
const pct = (s.count / totalOcr * 100).toFixed(1);
ocrTrack.createEl('div', { cls: `paperforge-progress-seg ${s.cls}`, attr: { style: `width:${pct}%` } });
}
}
const ocrCounts = ocrSection.createEl('div', { cls: 'paperforge-ocr-counts' });
const ocrLabels = [
{ cls: 'pending', value: ocrPending, label: 'Pending' },
{ cls: 'active', value: ocrProcessing, label: 'Processing' },
{ cls: 'done', value: ocrDone, label: 'Done' },
{ cls: 'failed', value: ocrFailed, label: 'Attention' },
];
for (const l of ocrLabels) {
const cnt = ocrCounts.createEl('div', { cls: 'paperforge-ocr-count' });
cnt.createEl('div', { cls: 'paperforge-ocr-count-value', text: l.value.toString() });
cnt.createEl('div', { cls: 'paperforge-ocr-count-label', text: l.label });
}
}
// ── Contextual Actions ──
const actionsRow = view.createEl('div', { cls: 'paperforge-collection-actions' });
const ocrActionBtn = actionsRow.createEl('button', { cls: 'paperforge-contextual-btn primary' });
ocrActionBtn.createEl('span', { cls: 'paperforge-contextual-btn-icon', text: '\u229E' });
ocrActionBtn.createEl('span', { text: 'Run OCR' });
ocrActionBtn.addEventListener('click', () => {
const action = ACTIONS.find(a => a.id === 'paperforge-ocr');
if (action) this._runAction(action, ocrActionBtn);
});
const syncBtn = actionsRow.createEl('button', { cls: 'paperforge-contextual-btn' });
syncBtn.createEl('span', { cls: 'paperforge-contextual-btn-icon', text: '\u21BB' });
syncBtn.createEl('span', { text: 'Sync Library' });
syncBtn.addEventListener('click', () => {
const action = ACTIONS.find(a => a.id === 'paperforge-sync');
if (action) this._runAction(action, syncBtn);
});
}
/* ── Refresh current mode (called on index change, D-09, REFR-01) ── */
_refreshCurrentMode() {
if (!this._currentMode) return;
this._contentEl.empty();
this._contentEl.addClass('switching');
this._invalidateIndex();
this._currentPaperEntry = this._currentPaperKey ? this._findEntry(this._currentPaperKey) : null;
this._renderModeHeader(this._currentMode);
try {
switch (this._currentMode) {
case 'global':
this._renderGlobalMode();
break;
case 'paper':
this._renderPaperMode();
break;
case 'collection':
this._renderCollectionMode();
break;
}
} finally {
// Always remove switching, even if render throws
setTimeout(() => {
if (this._contentEl) this._contentEl.removeClass('switching');
}, 50);
}
}
/* ── Run Action ── */
_runAction(a, card) {
// Guard: disabled actions show coming-soon notice
if (a.disabled) {
new Notice(`[i] ${a.disabledMsg || 'This action is not yet available.'}`, 6000);
return;
}
// Guard: prevent running the same action simultaneously
if (card.classList.contains('running')) {
return;
}
card.addClass('running');
const vp = this.app.vault.adapter.basePath;
this._showMessage('Processing...', 'running');
// Resolve extra arguments based on action flags
let extraArgs = Array.isArray(a.args) ? [...a.args] : [];
if (a.needsKey) {
// Resolve zotero_key from active file frontmatter
const activeFile = this.app.workspace.getActiveFile();
let key = null;
if (activeFile) {
const cache = this.app.metadataCache.getFileCache(activeFile);
if (cache && cache.frontmatter && cache.frontmatter.zotero_key) {
key = cache.frontmatter.zotero_key;
} else if (cache && cache.frontmatter) {
this._showMessage('[!!] No zotero_key in active note frontmatter', 'error');
new Notice('[!!] Open a paper note with a zotero_key in its frontmatter first', 6000);
card.removeClass('running');
return;
} else {
this._showMessage('[!!] No frontmatter in active note', 'error');
new Notice('[!!] The active note has no frontmatter with a zotero_key', 6000);
card.removeClass('running');
return;
}
} else {
this._showMessage('[!!] No active note open', 'error');
new Notice('[!!] Open a paper note with a zotero_key in its frontmatter first', 6000);
card.removeClass('running');
return;
}
extraArgs = [...extraArgs, key];
}
if (a.needsFilter) {
// Default to --all for the initial implementation
extraArgs = [...extraArgs, '--all'];
}
const { spawn } = require('node:child_process');
const cmdTimeout = a.needsFilter ? 60000 : (a.needsKey ? 30000 : 600000);
const { path: pythonExe, extraArgs: pyExtra = [] } = resolvePythonExecutable(vp, this.app.plugins.plugins['paperforge']?.settings);
const child = spawn(pythonExe, [...pyExtra, '-m', 'paperforge', a.cmd, ...extraArgs], { cwd: vp, timeout: cmdTimeout });
const log = [];
const startTime = Date.now();
const pollTimer = setInterval(() => this._fetchStats(true), 4000);
child.stdout.on('data', (data) => {
const lines = data.toString('utf-8').split('\n').filter(Boolean);
for (const l of lines) {
const clean = l.trim();
if (clean) { log.push(clean); this._showMessage(log.slice(-8).join('\n'), 'running'); new Notice(clean, 5000); }
}
});
child.stderr.on('data', (data) => {
const lines = data.toString('utf-8').split('\n').filter(Boolean);
for (const l of lines) {
if (l.includes('\r') || l.includes('%') || l.includes('\u2588')) continue;
const trim = l.trim();
if (trim && !trim.match(/^\d+%|^\|/)) { log.push(trim); this._showMessage(log.slice(-8).join('\n'), 'running'); new Notice(trim, 5000); }
}
});
child.on('close', (code) => {
clearInterval(pollTimer);
card.removeClass('running');
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (code !== 0) {
const last = log.slice(-3).join(' | ') || 'exit code ' + code;
if ((a.cmd === 'repair' || a.cmd === 'ocr') && code === 1) {
this._showMessage('[WARN] ' + last, 'running');
new Notice('[WARN] ' + a.cmd + ' partial: ' + last, 8000);
this._fetchStats(true);
} else {
this._showMessage('[!!] ' + last, 'error');
new Notice('[!!] ' + a.cmd + ' failed: ' + last, 8000);
}
} else if (a.needsKey || a.needsFilter) {
// Context actions: copy JSON output to clipboard
const output = log.join('\n');
if (output.trim()) {
try {
JSON.parse(output);
navigator.clipboard.writeText(output).then(() => {
const summary = `${elapsed}s \u2014 ${output.length} chars copied`;
this._showMessage('[OK] ' + a.title + ': ' + summary, 'ok');
new Notice('[OK] ' + a.okMsg + ' \u2014 ' + output.length + ' chars');
}).catch((err) => {
this._showMessage('[!!] Clipboard write failed: ' + err.message, 'error');
new Notice('[!!] Clipboard error', 6000);
});
} catch (e) {
this._showMessage('[!!] Invalid JSON from ' + a.title, 'error');
new Notice('[!!] ' + a.title + ' returned invalid JSON: ' + e.message.slice(0, 100), 8000);
}
} else {
this._showMessage('[!!] No output from context command', 'error');
new Notice('[!!] Context command returned empty output', 8000);
}
this._fetchStats(true);
} else {
// Existing actions (sync, ocr, doctor, repair): build summary from log lines
const updated = log.filter(l => l.match(/updated \d+/));
const lastUpdated = updated.pop() || log[log.length - 1] || '';
const summary = `${elapsed}s \u2014 ${lastUpdated}`;
this._showMessage('[OK] ' + a.title + ': ' + summary, 'ok');
new Notice('[OK] ' + a.okMsg);
if (this._contentEl) this._contentEl.removeClass('switching');
this._cachedStats = null;
try { this._fetchStats(); } catch (e) { console.log('[PF] fetchStats error:', e); }
console.log('[PF] close cmd=' + a.cmd + ' id=' + a.id);
if (a.cmd === 'sync') checkOrphanState(this.app, this.app.plugins.plugins['paperforge'], vp);
}
});
child.on('error', (err) => {
card.removeClass('running');
if (this._contentEl) this._contentEl.removeClass('switching');
this._showMessage('[!!] ' + err.message, 'error');
new Notice('[!!] Cannot start: ' + err.message, 8000);
});
}
_showMessage(msg, cls) {
if (this._messageEl) {
this._messageEl.setText(msg);
this._messageEl.className = `paperforge-message msg-${cls}`;
}
}
/* ── Mode-Aware Header (D-07) ── */
_renderModeHeader(mode) {
this._modeContextEl.empty();
// Build mode badge
const badge = this._modeContextEl.createEl('span', { cls: 'paperforge-mode-badge' });
let modeName = '';
switch (mode) {
case 'global':
badge.addClass('global');
badge.setText('Global');
this._headerTitle.setText('PaperForge');
break;
case 'paper':
badge.addClass('paper');
badge.setText('Paper');
this._headerTitle.setText('Paper');
if (this._currentPaperEntry && this._currentPaperEntry.title) {
modeName = this._currentPaperEntry.title;
} else if (this._currentPaperKey) {
modeName = this._currentPaperKey;
// Show warning if entry not found (D-18)
this._modeContextEl.createEl('span', {
cls: 'paperforge-mode-warning',
text: 'Not found in index',
});
} else {
modeName = 'Unknown paper';
}
break;
case 'collection':
badge.addClass('collection');
badge.setText('Collection');
this._headerTitle.setText('Collection');
modeName = this._currentDomain || 'Unknown Domain';
break;
}
if (modeName) {
this._modeContextEl.createEl('span', {
cls: 'paperforge-mode-name',
text: modeName,
});
}
}
/* ── Event Subscriptions (D-08, D-09, D-19) ── */
_setupEventSubscriptions() {
// D-08: Active leaf change -- debounced with 300ms delay
const leafHandler = this.app.workspace.on('active-leaf-change', () => {
clearTimeout(this._leafChangeTimer);
this._leafChangeTimer = setTimeout(() => {
const resolved = this._resolveModeForFile(this.app.workspace.getActiveFile());
const nextMode = resolved.mode;
const nextFilePath = resolved.filePath;
// Clicking inside the dashboard can activate its leaf without changing
// the underlying paper/base context. Avoid rebuilding the whole mode
// tree in that case, or transient UI state like discussion expansion resets.
if (this._currentMode === nextMode && this._currentFilePath === nextFilePath) {
return;
}
this._detectAndSwitch();
}, 300);
});
this._modeSubscribers.push({ event: 'active-leaf-change', ref: leafHandler });
// D-09: File modification -- formal-library.json only (deep-finalize signals completion)
const modifyHandler = this.app.vault.on('modify', (file) => {
if (file && file.path && file.path.endsWith('formal-library.json')) {
this._invalidateIndex(); // D-14: invalidate cache
this._refreshCurrentMode();
}
});
this._modeSubscribers.push({ event: 'modify', ref: modifyHandler });
}
/* ── Static: open or reveal view ── */
static async open(plugin) {
const leaves = plugin.app.workspace.getLeavesOfType(VIEW_TYPE_PAPERFORGE);
if (leaves.length > 0) {
plugin.app.workspace.revealLeaf(leaves[0]);
return;
}
const leaf = plugin.app.workspace.getRightLeaf(false);
if (leaf) {
await leaf.setViewState({ type: VIEW_TYPE_PAPERFORGE, active: true });
plugin.app.workspace.revealLeaf(leaf);
}
}
}
class PaperForgeSettingTab extends PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
this._saveTimeout = null;
this._pfConfig = null; // cached paperforge.json config
this._lastSyncTime = null;
this._memoryStatusText = null; // null = not checked yet, string = cached result
this._vectorDepsOk = null; // null = not checked, bool = cached
this._embedStatusText = null;
this._skillsCollapsed = { user: true }; // User skills collapsed by default
this._featurePanelsCollapsed = {};
this.activeTab = 'setup';
}
/** Reload path config from paperforge.json */
_refreshPfConfig() {
this._pfConfig = this.plugin.readPaperforgeJson();
}
display() {
const { containerEl } = this;
containerEl.empty();
this._refreshPfConfig();
// Inject tab CSS once
if (!document.getElementById('paperforge-tab-styles')) {
const style = document.createElement('style');
style.id = 'paperforge-tab-styles';
style.textContent = `
.paperforge-settings-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--background-modifier-border); }
.paperforge-settings-tab { padding: 6px 16px; border: none; background: none; cursor: pointer; border-bottom: 2px solid transparent; font-size: 14px; color: var(--text-muted); }
.paperforge-settings-tab--active { color: var(--text-accent); border-bottom-color: var(--text-accent); }
.paperforge-tab-content { display: none; }
.paperforge-tab-content--active { display: block; }
.paperforge-skills-collapse-header { display: flex !important; align-items: center; cursor: pointer; padding: 6px 0 !important; margin: 0 !important; }
.paperforge-skills-collapse-header h4 { margin: 0 !important; }
.paperforge-skills-collapse-content { margin: 0 !important; padding: 0 !important; }
.paperforge-skills-group { margin-bottom: 10px; }
.paperforge-skills-group:last-child { margin-bottom: 0; }
.vertical-tab-content-container { overflow-y: scroll !important; }
`;
document.head.appendChild(style);
}
// --- Tab bar ---
const tabBar = containerEl.createDiv({ cls: 'paperforge-settings-tabs' });
const tabs = [
{ id: 'setup', label: t('tab_setup') || 'Installation' },
{ id: 'features', label: t('tab_features') || 'Features' },
];
const tabContents = {};
tabs.forEach(tab => {
const btn = tabBar.createEl('button', {
cls: 'paperforge-settings-tab' + (tab.id === this.activeTab ? ' paperforge-settings-tab--active' : ''),
text: tab.label,
});
btn.addEventListener('click', () => {
this.activeTab = tab.id;
this.display(); // re-render with new active tab
});
});
// --- Tab content containers ---
tabs.forEach(tab => {
tabContents[tab.id] = containerEl.createDiv({
cls: 'paperforge-tab-content' + (tab.id === this.activeTab ? ' paperforge-tab-content--active' : ''),
});
});
// --- Render active tab ---
if (this.activeTab === 'setup') {
this._renderSetupTab(tabContents.setup);
} else {
this._renderFeaturesTab(tabContents.features);
}
}
_renderSetupTab(containerEl) {
const vaultPath = this.app.vault.adapter.basePath;
if (!this.plugin.settings.vault_path) {
this.plugin.settings.vault_path = vaultPath;
this._debouncedSave();
}
/* ── Validate setup_complete against paperforge.json ── */
if (this.plugin.settings.setup_complete) {
const fs = require('fs');
const path = require('path');
if (!fs.existsSync(path.join(vaultPath, 'paperforge.json'))) {
this.plugin.settings.setup_complete = false;
this._debouncedSave();
}
}
/* ── Header ── */
containerEl.createEl('h2', { text: t('header_title') || 'PaperForge' });
containerEl.createEl('p', {
text: t('desc'),
cls: 'paperforge-settings-desc'
});
/* ── Setup Status ── */
const statusRow = containerEl.createEl('div', { cls: 'paperforge-setup-bar' });
const statusLabel = statusRow.createEl('span', { cls: 'paperforge-setup-label' });
if (this.plugin.settings.setup_complete) {
statusLabel.setText(t('setup_done'));
statusLabel.addClass('paperforge-setup-done');
} else {
statusLabel.setText(t('setup_pending'));
statusLabel.addClass('paperforge-setup-pending');
}
/* ── Python Interpreter Section ── */
const vaultPathForPython = this.app.vault.adapter.basePath;
const pyResult = resolvePythonExecutable(vaultPathForPython, this.plugin.settings);
const pyPath = pyResult.path;
const pySource = this.plugin.settings._python_path_stale ? 'stale' : pyResult.source;
// 2a — Read-only current interpreter row
const pyInterpSetting = new Setting(containerEl)
.setName(t('field_python_interp'))
.setDesc(this._getPythonDesc(pyPath, pySource));
this._pythonInterpDescEl = pyInterpSetting.descEl;
// 2b+2c — Manual override text input + Validate button
const customSetting = new Setting(containerEl)
.setName(t('field_python_custom'))
.setDesc('');
this._customPathDescEl = customSetting.descEl;
customSetting.addText(text => {
text.setPlaceholder('e.g. C:\\Python310\\python.exe')
.setValue(this.plugin.settings.python_path || '')
.onChange(value => {
this.plugin.settings.python_path = value;
this.plugin.saveSettings();
// Clear stale flag when user modifies the path
if (value && value.trim()) {
const fs = require('fs');
const exists = fs.existsSync(value.trim());
this.plugin.settings._python_path_stale = !exists;
} else {
this.plugin.settings._python_path_stale = false;
}
// Re-render the read-only Python interpreter row desc
const pyResult2 = resolvePythonExecutable(this.app.vault.adapter.basePath, this.plugin.settings);
const pySource2 = this.plugin.settings._python_path_stale ? 'stale' : pyResult2.source;
if (this._pythonInterpDescEl) {
this._pythonInterpDescEl.textContent = this._getPythonDesc(pyResult2.path, pySource2);
}
});
});
customSetting.addButton(btn => {
btn.setButtonText(t('btn_validate'))
.onClick(() => this._validatePythonOverride());
});
/* ── Runtime Health Section ── */
containerEl.createEl('h3', { text: t('runtime_health') });
containerEl.createEl('p', { text: t('runtime_health_desc'), cls: 'paperforge-settings-desc' });
const versionRow = new Setting(containerEl)
.setName('PaperForge')
.setDesc(t('runtime_health_checking'));
const badgeEl = versionRow.descEl.createEl('span', { cls: 'paperforge-runtime-badge' });
let syncBtn = null;
versionRow.addButton(btn => {
syncBtn = btn;
btn.setButtonText(t('runtime_health_sync'))
.setDisabled(true)
.onClick(() => this._syncRuntime(btn));
});
// Fetch version asynchronously
{
const vp = this.app.vault.adapter.basePath;
const { path: pythonExe, extraArgs = [] } = resolvePythonExecutable(vp, this.plugin.settings);
const pluginVer = this.plugin.manifest.version || '?';
execFile(pythonExe, [...extraArgs, '-c', 'import paperforge; print(paperforge.__version__)'], { cwd: vp, timeout: 10000 }, (err, stdout) => {
const setupDone = this.plugin.settings.setup_complete;
const pyVer = (!err && stdout) ? stdout.trim() : null;
const descText = pyVer
? `${t('runtime_health_plugin_ver').replace('{0}', pluginVer)} → ${t('runtime_health_package_ver').replace('{0}', pyVer)}`
: (setupDone ? `Plugin v${pluginVer} → Python package not installed. Click "Sync Runtime" to install.`
: `Plugin v${pluginVer} → Not configured. Please open the setup wizard first.`);
versionRow.setDesc(descText);
if (pyVer === pluginVer) {
badgeEl.setText(t('runtime_health_match'));
badgeEl.className = 'paperforge-runtime-badge match';
if (syncBtn) syncBtn.setDisabled(true);
} else if (pyVer) {
badgeEl.setText(t('runtime_health_mismatch'));
badgeEl.className = 'paperforge-runtime-badge mismatch';
if (syncBtn) syncBtn.setDisabled(false);
} else {
badgeEl.setText(setupDone ? 'Not installed' : 'Setup needed');
badgeEl.className = 'paperforge-runtime-badge missing';
if (syncBtn) syncBtn.setDisabled(false);
}
});
}
/* ── Preparation Guide ── */
containerEl.createEl('h3', { text: t('section_prep') });
containerEl.createEl('p', { text: t('section_prep_desc'), cls: 'paperforge-settings-desc' });
const prep = containerEl.createEl('div', { cls: 'paperforge-guide' });
const prepData = [
['prep_python', 'prep_python_desc'],
['prep_zotero', 'prep_zotero_desc'],
['prep_bbt', 'prep_bbt_desc'],
['prep_key', 'prep_key_desc'],
];
for (const [kTitle, kDesc] of prepData) {
const row = prep.createEl('div', { cls: 'paperforge-guide-item' });
row.createEl('strong', { text: t(kTitle) });
row.createEl('span', { text: ' — ' + t(kDesc) });
}
/* ── Pre-check status area ── */
this._checkEl = containerEl.createEl('div', { cls: 'paperforge-message' });
/* ── Install / Reconfigure Button ── */
const needSetup = !this.plugin.settings.setup_complete;
new Setting(containerEl)
.setName(t(needSetup ? 'btn_install' : 'btn_reconfig'))
.setDesc(t(needSetup ? 'btn_install_desc' : 'btn_reconfig_desc'))
.addButton((btn) => {
btn.setButtonText(t(needSetup ? 'btn_install' : 'btn_reconfig'))
.setCta()
.onClick(() => {
if (!needSetup) {
new PaperForgeSetupModal(this.app, this.plugin).open();
} else {
this._preCheck(() => {
new PaperForgeSetupModal(this.app, this.plugin).open();
});
}
});
});
/* ── Operation Guide ── */
containerEl.createEl('h3', { text: t('section_guide') });
const guide = containerEl.createEl('div', { cls: 'paperforge-guide' });
const guideData = [
['guide_open', 'guide_open_desc'],
['guide_sync', 'guide_sync_desc'],
['guide_ocr', 'guide_ocr_desc'],
];
for (const [kTitle, kDesc] of guideData) {
const row = guide.createEl('div', { cls: 'paperforge-guide-item' });
row.createEl('strong', { text: t(kTitle) });
row.createEl('span', { text: ' — ' + t(kDesc) });
}
/* ── Config Summary (only after install) ── */
if (this.plugin.settings.setup_complete) {
containerEl.createEl('h3', { text: t('section_config') });
const summary = containerEl.createEl('div', { cls: 'paperforge-summary' });
const s = this.plugin.settings;
const pf = this._pfConfig; // source of truth for path fields
const items = [
{ label: t('dir_vault'), val: vaultPath },
{ label: t('dir_resources'), val: `${vaultPath}/${pf.resources_dir}` },
{ label: ' ' + t('dir_notes'), val: `${vaultPath}/${pf.resources_dir}/${pf.literature_dir}` },
{ label: t('dir_base'), val: `${vaultPath}/${pf.base_dir}` },
{ label: t('dir_system'), val: `${vaultPath}/${pf.system_dir}` },
{ label: 'API Key', val: s.paddleocr_api_key ? t('api_key_set') : t('api_key_missing') },
{ label: t('field_zotero_data'), val: s.zotero_data_dir || t('not_set') },
];
for (const item of items) {
const row = summary.createEl('div', { cls: 'paperforge-summary-row' });
row.createEl('span', { cls: 'paperforge-summary-label', text: item.label });
row.createEl('span', { cls: 'paperforge-summary-value', text: item.val });
}
}
}
_execMemoryStatus(pythonPath, vp, callback) {
const { exec } = require('child_process');
exec(`"${pythonPath}" -m paperforge --vault "${vp}" memory status --json`, { encoding: 'utf-8', timeout: 15000 }, (err, stdout) => {
if (err) { callback('Status unavailable'); return; }
try {
const data = JSON.parse(stdout);
if (data.ok) {
const s = data.data;
const freshness = s.fresh ? 'fresh' : 'stale';
callback(`Papers: ${s.paper_count_db} | ${freshness}${s.needs_rebuild ? ' - needs rebuild' : ''}`);
} else {
callback('DB not found. Run paperforge memory build.');
}
} catch(e) { callback('Could not parse status.'); }
});
}
_execEmbedStatus(pythonPath, vp, callback) {
const { exec } = require('child_process');
exec(`"${pythonPath}" -m paperforge --vault "${vp}" embed status --json`, { encoding: 'utf-8', timeout: 15000 }, (err, stdout) => {
if (err) { callback('Status unavailable'); return; }
try {
const data = JSON.parse(stdout);
if (data.ok) {
callback(`Chunks: ${data.data.chunk_count} | ${data.data.model} | ${data.data.mode}`);
} else {
callback('Could not parse status.');
}
} catch(e) { callback('Could not parse status.'); }
});
}
_callPython(command, opts) {
const vp = this.app.vault.adapter.basePath;
const py = memoryState.getCachedPython(vp, this.plugin.settings);
const args = [...py.extraArgs, '-m', 'paperforge', '--vault', vp, ...command];
if (opts && opts.stream) {
const { spawn } = require('node:child_process');
const child = spawn(py.path, args, { cwd: vp, env: opts.env || process.env, windowsHide: true });
if (opts.onData) child.stdout.on('data', opts.onData);
if (opts.onStderr) child.stderr.on('data', opts.onStderr);
if (opts.onError) child.on('error', opts.onError);
child.on('close', opts.onClose);
return child;
}
const { execFile } = require('node:child_process');
execFile(py.path, args, { cwd: vp, timeout: opts && opts.timeout || 60000 },
(err, stdout, stderr) => { if (opts && opts.onClose) opts.onClose(err ? 1 : 0, stdout, stderr); });
return null;
}
_renderMemoryStatusText(el, text, extraInfo) {
el.innerHTML = '';
el.createEl('span', { text: text, cls: 'paperforge-memory-text' }).style.cssText = 'flex:1;';
if (extraInfo === 'syncing') {
const syncEl = el.createEl('span', { text: 'Syncing...', cls: 'paperforge-sync-status' });
syncEl.style.cssText = 'opacity:0.7; margin-right:8px;';
} else if (extraInfo) {
const timeEl = el.createEl('span', { text: extraInfo, cls: 'paperforge-sync-status' });
timeEl.style.cssText = 'opacity:0.7; margin-right:8px;';
}
const rebuildBtn = el.createEl('button', { cls: 'paperforge-rebuild-btn', text: t('feat_memory_rebuild_btn') });
rebuildBtn.style.cssText = 'margin-left:auto; border:1px solid var(--background-modifier-border); background:var(--background-secondary); cursor:pointer; font-size:11px; padding:2px 6px; border-radius:3px; margin-right:4px;';
rebuildBtn.title = 'Rebuild memory database';
rebuildBtn.onclick = () => {
const vp = this.app.vault.adapter.basePath;
const py = memoryState.getCachedPython(vp, this.plugin.settings);
if (!py.path) { new Notice(t('feat_no_python')); return; }
console.log('[PaperForge] Rebuilding memory:', py.path);
rebuildBtn.setText(t('feat_memory_rebuilding'));
rebuildBtn.setAttr('disabled', '');
this._callPython(['memory', 'build'], {
timeout: 60000,
onClose: (code, stdout, stderr) => {
console.log('[PaperForge] memory build exit:', code ? 'FAIL:'+code : 'OK', (stdout||'').slice(0, 200), (stderr||'').slice(0, 200));
rebuildBtn.setText(t('feat_memory_rebuild_btn'));
rebuildBtn.removeAttribute('disabled');
if (code === 0) {
new Notice(t('feat_memory_rebuild_done'));
} else {
new Notice(t('feat_memory_rebuild_failed') + (stderr ? ' ' + stderr.slice(0, 80) : ''));
}
// Refresh status regardless
this._memoryStatusText = memoryState.getMemoryStatusText(vp);
this._refreshSnapshots(vp);
}
});
};
const refreshBtn = el.createEl('button', { cls: 'paperforge-refresh-btn', text: '\u21BB' });
refreshBtn.style.cssText = 'border:none; background:none; cursor:pointer; font-size:16px; padding:0 4px;';
refreshBtn.title = 'Sync now';
refreshBtn.onclick = () => {
this._memoryStatusText = null;
this._runManualSync();
};
}
_getBuildCommand(settings) {
const vp = this.app.vault.adapter.basePath;
const pyResult = resolvePythonExecutable(vp, settings);
if (!pyResult.path) return null;
return `"${pyResult.path}" -m paperforge --vault "${vp}" sync`;
}
_runManualSync() {
const vp = this.app.vault.adapter.basePath;
const py = memoryState.getCachedPython(vp, this.plugin.settings);
if (!py.path) return;
const statusRow = document.querySelector('.paperforge-memory-status');
if (statusRow) {
this._renderMemoryStatusText(statusRow, 'Checking...', 'syncing');
}
this.plugin._autoSyncRunning = true;
this._callPython(['sync'], {
timeout: 120000,
onClose: (code) => {
this.plugin._autoSyncRunning = false;
this._memoryStatusText = null;
if (code === 0) {
this._lastSyncTime = new Date().toLocaleTimeString();
this.plugin._lastSyncTime = this._lastSyncTime;
}
this.display();
this._refreshSnapshots(vp);
checkOrphanState(this.app, this.plugin, vp);
}
});
}
_refreshSnapshots(vp) {
const py = memoryState.getCachedPython(vp, this.plugin.settings);
const { execFile } = require('node:child_process');
const args = [...py.extraArgs, '-m', 'paperforge', '--vault', vp, 'runtime-health', '--json'];
this._refreshPending = true;
execFile(py.path, args, { cwd: vp, timeout: 30000, windowsHide: true },
(err, stdout, stderr) => {
this._refreshPending = false;
this._memoryStatusText = memoryState.getMemoryStatusText(vp);
this._embedStatusText = memoryState.getVectorStatusText(vp);
this.display();
}
);
}
_renderFeaturesTab(containerEl) {
// --- Section: Skills ---
containerEl.createEl('h3', { text: 'Skills' });
const skillsDescEl = containerEl.createEl('div', { cls: 'paperforge-desc-box' });
skillsDescEl.style.cssText = 'padding:8px 12px; margin:0 0 12px; background:var(--background-secondary); border-radius:4px; font-size:12px; color:var(--text-muted); line-height:1.5;';
skillsDescEl.setText(t('feat_skills_desc'));
skillsDescEl.createEl('br');
skillsDescEl.createEl('span', { text: t('feat_skills_system'), cls: '' }).style.opacity = '0.7';
// Agent platform selector
const agentPlatforms = {
'opencode': 'OpenCode',
'claude': 'Claude Code',
'codex': 'Codex',
'cursor': 'Cursor',
'windsurf': 'Windsurf',
'github_copilot': 'GitHub Copilot',
};
const agentDirs = {
'opencode': '.opencode/skills',
'claude': '.claude/skills',
'codex': '.codex/skills',
'cursor': '.cursor/skills',
'windsurf': '.windsurf/skills',
'github_copilot': '.github/skills',
};
const vaultPath = this.app.vault.adapter.basePath;
const fs = require('fs');
const path = require('path');
let selectedPlatform = this.plugin.settings.selected_skill_platform || 'opencode';
new Setting(containerEl)
.setName(t('feat_agent_platform'))
.setDesc(t('feat_agent_platform_desc'))
.addDropdown(dropdown => {
Object.entries(agentPlatforms).forEach(([key, label]) => dropdown.addOption(key, label));
dropdown.setValue(selectedPlatform)
.onChange(value => {
this.plugin.settings.selected_skill_platform = value;
this.plugin.saveSettings();
this.display();
});
})
.addExtraButton(btn => {
btn.setIcon('folder')
.setTooltip('Open skills folder')
.onClick(() => {
const dir = agentDirs[selectedPlatform] || '.opencode/skills';
const fullPath = path.join(vaultPath, dir);
if (fs.existsSync(fullPath)) {
const { exec } = require('child_process');
exec(`start "" "${fullPath}"`);
} else {
new Notice(`Skills folder not found: ${dir}`);
}
});
});
// Show skills for selected platform
const skillDir = path.join(vaultPath, agentDirs[selectedPlatform]);
let systemSkills = [];
let userSkills = [];
if (fs.existsSync(skillDir)) {
fs.readdirSync(skillDir, { withFileTypes: true }).forEach(entry => {
if (!entry.isDirectory()) return;
const skillFile = path.join(skillDir, entry.name, 'SKILL.md');
if (!fs.existsSync(skillFile)) return;
const content = fs.readFileSync(skillFile, 'utf-8');
const nameMatch = content.match(/^name:\s*(.+)$/m);
const lines = content.split('\n');
const descIdx = lines.findIndex(l => /^description:/.test(l));
let desc = '';
if (descIdx >= 0) {
const first = lines[descIdx].match(/^description:\s*(.+)$/);
if (first && first[1] && first[1] !== '>' && first[1] !== '|-' && first[1] !== '|') {
desc = first[1].trim();
} else {
for (let i = descIdx + 1; i < lines.length; i++) {
if (/^\s{2,}/.test(lines[i]) || lines[i].trim() === '') {
desc += lines[i].trim() + ' ';
} else break;
}
desc = desc.trim();
}
}
const sourceMatch = content.match(/^source:\s*(.+)$/m);
const disableMatch = content.match(/^disable-model-invocation:\s*(.+)$/m);
const versionMatch = content.match(/^version:\s*(.+)$/m);
const skill = {
name: nameMatch ? nameMatch[1].trim() : entry.name,
desc: desc,
source: sourceMatch ? sourceMatch[1].trim() : 'user',
disabled: disableMatch && disableMatch[1].trim() === 'true',
version: versionMatch ? versionMatch[1].trim() : '',
path: skillFile,
content: content,
dirName: entry.name,
};
if (skill.source === 'paperforge') {
systemSkills.push(skill);
} else {
userSkills.push(skill);
}
});
}
const skillsBox = containerEl.createEl('div');
skillsBox.style.cssText = 'background:var(--background-secondary); border-radius:8px; padding:12px 12px 10px; margin:8px 0 16px;';
const renderCollapsibleSkills = (label, skills, isSystem) => {
if (skills.length === 0) return;
// Group wrapper for spacing between groups
const group = skillsBox.createEl('div', { cls: 'paperforge-skills-group' });
// Header row with toggle arrow (created first so it appears above content)
const header = group.createEl('div', { cls: 'paperforge-skills-collapse-header' });
// Content wrapper
const content = group.createEl('div', { cls: 'paperforge-skills-collapse-content' });
const arrow = header.createEl('span', { text: '\u25BC', cls: 'paperforge-skills-arrow' });
arrow.style.cssText = 'display:inline-block; font-size:10px; margin-right:6px; transition:transform 0.2s; transform:rotate(0deg);';
header.createEl('h4', { text: `${label} (${skills.length})`, cls: 'paperforge-skills-subheader' });
skills.forEach(s => {
const nameText = s.name + (s.version ? ' v' + s.version : '');
const sourceLabel = isSystem ? ' [system]' : ' [user]';
const descText = s.desc || '';
const setting = new Setting(content)
.setName(nameText + sourceLabel)
.setDesc(descText);
setting.settingEl.style.opacity = s.disabled ? '0.4' : '1';
setting.addToggle(toggle => {
toggle.setValue(!s.disabled)
.onChange(value => {
const newDisabled = !value;
const disableMatch = s.content.match(/^disable-model-invocation:\s*(.+)$/m);
const newContent = disableMatch
? s.content.replace(/^disable-model-invocation:\s*.+$/m, `disable-model-invocation: ${newDisabled}`)
: s.content.replace(/^(---\r?\n)/, `$1disable-model-invocation: ${newDisabled}\n`);
fs.writeFileSync(s.path, newContent, 'utf-8');
s.disabled = newDisabled;
s.content = newContent;
setting.settingEl.style.opacity = s.disabled ? '0.4' : '1';
});
});
});
// Toggle with state preservation
const stateKey = isSystem ? 'system' : 'user';
const collapsed = this._skillsCollapsed[stateKey] || false;
if (collapsed) {
content.style.display = 'none';
arrow.style.transform = 'rotate(-90deg)';
}
header.addEventListener('click', () => {
const nowCollapsed = content.style.display !== 'none';
if (nowCollapsed) {
content.style.display = 'none';
arrow.style.transform = 'rotate(-90deg)';
} else {
content.style.display = '';
arrow.style.transform = 'rotate(0deg)';
}
this._skillsCollapsed[stateKey] = content.style.display === 'none';
});
};
// System skills
renderCollapsibleSkills('System Skills', systemSkills, true);
// User skills
renderCollapsibleSkills('User Skills', userSkills, false);
if (systemSkills.length === 0 && userSkills.length === 0) {
skillsBox.createEl('p', {
text: `No skills found in ${agentDirs[selectedPlatform]}. Run setup to deploy skills.`,
cls: 'setting-item-description'
});
}
// --- Section: Advanced (Memory Layer + Vector DB, collapsed by default) ---
if (this._advCollapsed === undefined) this._advCollapsed = true;
const advHeader = containerEl.createEl('div', { cls: 'paperforge-collapsible-header' });
advHeader.style.cssText = 'cursor:pointer; display:flex; align-items:center; gap:8px; padding:8px 0; user-select:none;';
const advArrow = advHeader.createEl('span', { text: '\u25B6', cls: 'paperforge-collapsible-arrow' });
advArrow.style.cssText = 'display:inline-block; transition:transform 0.2s; font-size:10px; transform:' + (this._advCollapsed ? 'rotate(0deg)' : 'rotate(90deg)') + ';';
const advTitle = advHeader.createEl('span', { text: 'Advanced' });
advTitle.style.cssText = 'font-size:16px; font-weight:700; line-height:1.4;';
const advSub = advHeader.createEl('span', { text: 'Memory + Vector DB + Embedding' });
advSub.style.cssText = 'font-size:12px; color:var(--text-muted); margin-left:10px;';
const advContent = containerEl.createEl('div', { cls: 'paperforge-collapsible-content' });
advContent.style.display = this._advCollapsed ? 'none' : '';
advHeader.addEventListener('click', () => {
this._advCollapsed = !this._advCollapsed;
advContent.style.display = this._advCollapsed ? 'none' : '';
advArrow.style.transform = this._advCollapsed ? 'rotate(0deg)' : 'rotate(90deg)';
});
// Memory Layer section (inside Advanced)
advContent.createEl('h4', { text: 'Memory Layer' });
const memoryDescEl = advContent.createEl('div', { cls: 'paperforge-desc-box' });
memoryDescEl.style.cssText = 'padding:8px 12px; margin:0 0 12px; background:var(--background-secondary); border-radius:4px; font-size:12px; color:var(--text-muted); line-height:1.5;';
memoryDescEl.setText(t('feat_memory_desc'));
const statusRow = advContent.createEl('div', { cls: 'paperforge-memory-status' });
statusRow.style.cssText = 'display:flex; align-items:center; padding:8px 12px; margin:8px 0; background:var(--background-secondary); border-radius:4px;';
const vp = this.app.vault.adapter.basePath;
if (this.plugin._lastSyncTime && !this._lastSyncTime) {
this._lastSyncTime = this.plugin._lastSyncTime;
}
if (this._memoryStatusText === null) {
this._memoryStatusText = memoryState.getMemoryStatusText(vp);
}
this._renderMemoryStatusText(statusRow, this._memoryStatusText, this._lastSyncTime);
this._renderVectorSection(advContent);
}
_renderVectorSection(containerEl) {
// --- Vector Database (within Memory Layer) ---
containerEl.createEl('h4', { text: 'Vector Database' });
// Ensure features object exists for upgrades from older settings
if (!this.plugin.settings.features) {
this.plugin.settings.features = { memory_layer: true, vector_db: false };
}
const vecDescEl = containerEl.createEl('div', { cls: 'paperforge-desc-box' });
vecDescEl.style.cssText = 'padding:8px 12px; margin:0 0 8px; background:var(--background-secondary); border-radius:4px; font-size:12px; color:var(--text-muted); line-height:1.5;';
vecDescEl.setText(t('feat_vector_desc'));
new Setting(containerEl)
.setName(t('feat_vector_enable'))
.setDesc(t('feat_vector_enable_desc'))
.addToggle(toggle => {
toggle.setValue(this.plugin.settings.features.vector_db)
.onChange(value => {
this.plugin.settings.features.vector_db = value;
this.plugin.saveSettings();
this._vectorDepsOk = null;
this._embedStatusText = null;
this.display();
});
});
if (!this.plugin.settings.features.vector_db) return;
const vp = this.app.vault.adapter.basePath;
// Collapsible config section
const vecConfigHeader = containerEl.createEl('div', { cls: 'paperforge-skills-collapse-header' });
vecConfigHeader.style.cssText = 'display:flex; align-items:center; cursor:pointer; padding:6px 0 2px; margin:0;';
const vecArrow = vecConfigHeader.createEl('span', { text: '\u25BC' });
vecArrow.style.cssText = 'display:inline-block; font-size:10px; margin-right:6px; transition:transform 0.2s;';
vecConfigHeader.createEl('span', { text: t('feat_vector_config_label'), cls: '' }).style.cssText = 'font-size:12px; color:var(--text-muted);';
const vecConfigContent = containerEl.createEl('div', { cls: 'paperforge-vector-config' });
const applyVectorConfigDisclosure = (collapsed) => {
vecConfigContent.style.display = collapsed ? 'none' : '';
vecArrow.style.transform = collapsed ? 'rotate(-90deg)' : 'rotate(0deg)';
};
applyVectorConfigDisclosure(getDisclosureState(this._featurePanelsCollapsed, 'vectorConfig', false));
vecConfigHeader.addEventListener('click', () => {
const collapsed = toggleDisclosureState(this._featurePanelsCollapsed, 'vectorConfig', false);
applyVectorConfigDisclosure(collapsed);
});
// === Resolve state ===
if (this._vectorDepsOk === true) {
this._renderVectorReady(vecConfigContent, vp);
return;
}
if (this._vectorDepsOk === false) {
this._renderVectorNoDeps(vecConfigContent);
return;
}
// First check — deps unknown, resolve from snapshot
if (this._vectorDepsOk === null) {
const vr = memoryState.getVectorRuntime(vp);
this._vectorDepsOk = vr ? vr.deps_installed : false;
if (this._vectorDepsOk) {
this._embedStatusText = memoryState.getVectorStatusText(vp);
}
this.display();
}
}
_renderApiConfig(containerEl) {
new Setting(containerEl)
.setName(t('feat_openai_key'))
.setDesc(t('feat_openai_key_desc'))
.addText(text => {
text.setPlaceholder('sk-...')
.setValue(this.plugin.settings.vector_db_api_key || '')
.onChange(value => {
this.plugin.settings.vector_db_api_key = value;
this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName(t('feat_api_base_url'))
.setDesc(t('feat_api_base_url_desc'))
.addText(text => {
text.setPlaceholder('https://api.openai.com/v1')
.setValue(this.plugin.settings.vector_db_api_base || '')
.onChange(value => {
this.plugin.settings.vector_db_api_base = value;
this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName(t('feat_api_model'))
.setDesc(t('feat_api_model_desc'))
.addText(text => {
text.setPlaceholder('text-embedding-3-small')
.setValue(this.plugin.settings.vector_db_api_model || 'text-embedding-3-small')
.onChange(value => {
this.plugin.settings.vector_db_api_model = value;
this.plugin.saveSettings();
});
});
}
_renderVectorNoDeps(containerEl) {
const box = containerEl.createEl('div');
box.style.cssText = 'padding:8px 12px; margin:8px 0; background:var(--background-secondary); border-radius:4px;';
box.setText(t('feat_deps_missing'));
new Setting(containerEl)
.setName(t('feat_install_deps'))
.setDesc(t('feat_install_deps_desc'))
.addButton(button => {
button.setButtonText(t('feat_install_btn'))
.setCta()
.onClick(async () => {
const vp = this.app.vault.adapter.basePath;
const pyResult = memoryState.getCachedPython(vp, this.plugin.settings);
if (!pyResult.path) { new Notice(t('feat_no_python')); return; }
button.setButtonText(t('feat_installing'));
button.setDisabled(true);
const pkgs = 'chromadb openai';
const notice = new Notice(t('feat_installing_pkgs').replace('{pkgs}', pkgs), 0);
try {
const { execFile: execFileInstall } = require('node:child_process');
const env = Object.assign({}, process.env, { PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' });
const pkgsArg = pkgs.split(' ');
await new Promise((resolve, reject) => {
execFileInstall(pyResult.path, [...pyResult.extraArgs, '-m', 'pip', 'install', ...pkgsArg], {
cwd: vp, timeout: 300000, env: env, windowsHide: true,
}, (error, stdout, stderr) => { error ? reject(error) : resolve(); });
});
notice.hide();
new Notice(t('feat_install_done'));
// Auto-build after install
this._vectorDepsOk = true;
this._embedStatusText = memoryState.getVectorStatusText(vp);
this.display();
} catch (e) {
notice.hide();
new Notice(t('feat_install_failed') + (e.stderr || e.message || e));
button.setButtonText(t('feat_retry_btn'));
button.setDisabled(false);
}
});
});
}
_renderVectorReady(containerEl, vp) {
// Status line
const statusEl = containerEl.createEl('div');
statusEl.style.cssText = 'padding:8px 12px; margin:8px 0; background:var(--background-secondary); border-radius:4px;';
statusEl.setText(memoryState.getVectorStatusText(vp));
// API config
this._renderApiConfig(containerEl);
// Embed build section with progress bar
const embedSection = containerEl.createEl('div');
embedSection.style.cssText = 'padding:4px 0;';
const embedHeader = embedSection.createEl('div');
embedHeader.style.cssText = 'display:flex; align-items:center; justify-content:space-between; margin-bottom:8px;';
embedHeader.createEl('span', { text: t('feat_rebuild_vectors'), cls: 'setting-item-name' });
const embedControls = embedSection.createEl('div');
embedControls.style.cssText = 'display:flex; align-items:center; gap:8px;';
const embedStatusText = embedSection.createEl('div', { cls: 'paperforge-embed-status-text' });
const renderEmbedUI = () => {
embedControls.empty();
embedStatusText.empty();
const buildState = memoryState.getVectorRuntime(vp)?.build_state || {};
this.plugin._embedProgress = this.plugin._embedProgress || { current: 0, total: 0, key: '' };
// Detached mode: process running on plugin (survived tab close), poll file for progress
if (!this.plugin._embedProcess && buildState.status === 'running') {
this.plugin._embedProgress = {
current: buildState.current || 0,
total: buildState.total || 1,
key: buildState.paper_id || ''
};
}
const { current, total, key } = this.plugin._embedProgress;
const isRunning = !!(this.plugin._embedProcess) || buildState.status === 'running';
if (isRunning) {
// Progress bar
const track = embedControls.createEl('div', { cls: 'paperforge-progress-track' });
track.style.cssText = 'flex:1;';
const pct = total > 0 ? (current / total * 100).toFixed(1) : 0;
const doneSeg = track.createEl('div', { cls: 'paperforge-progress-seg done' });
doneSeg.style.cssText = `width:${pct}%; min-width:${current > 0 ? '2px' : '0'};`;
if (current < total) {
const pendingSeg = track.createEl('div', { cls: 'paperforge-progress-seg pending' });
pendingSeg.style.cssText = `width:${(100 - parseFloat(pct)).toFixed(1)}%;`;
}
embedStatusText.createEl('span', { cls: 'paperforge-embed-progress-text',
text: `${current}/${total} papers` });
if (key) {
embedStatusText.createEl('span', { cls: 'paperforge-embed-progress-key',
text: ` (${key})` });
}
const stopBtn = embedControls.createEl('button');
stopBtn.setText('Stop');
stopBtn.className = 'mod-warning';
stopBtn.addEventListener('click', () => {
this._callPython(['embed', 'stop', '--json'], { timeout: 8000 });
if (this.plugin._embedProcess) {
this.plugin._embedProcess.kill();
this.plugin._embedProcess = null;
}
this.display();
});
} else {
const embedInfo = memoryState.getVectorRuntime(vp);
const hasChunks = embedInfo && embedInfo.chunk_count > 0;
const isCorrupted = embedInfo && embedInfo.corrupted;
const startBuild = (flag) => {
const py = memoryState.getCachedPython(vp, this.plugin.settings);
if (!py.path) { new Notice(t('feat_no_python')); return; }
const env = Object.assign({}, process.env, {
PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1',
VECTOR_DB_API_KEY: this.plugin.settings.vector_db_api_key || '',
VECTOR_DB_API_BASE: this.plugin.settings.vector_db_api_base || '',
VECTOR_DB_API_MODEL: this.plugin.settings.vector_db_api_model || ''
});
this.plugin._embedStderr = '';
this.plugin._embedProgress = { current: 0, total: 0, key: '' };
this.plugin._embedProcess = this._callPython(['embed', 'build', flag], {
stream: true,
env: env,
onData: (data) => {
const lines = data.toString('utf-8').split('\n');
for (const line of lines) {
if (line.startsWith('EMBED_START:')) {
this.plugin._embedProgress.total = parseInt(line.split(':')[1]) || 0;
} else if (line.startsWith('EMBED_PROGRESS:')) {
const parts = line.split(':');
this.plugin._embedProgress.current = parseInt(parts[1]) || 0;
this.plugin._embedProgress.key = parts[3] || '';
} else if (line.startsWith('EMBED_DONE')) {
this.plugin._embedProcess = null;
this.plugin._embedProgress.current = this.plugin._embedProgress.total;
}
}
this.display();
},
onStderr: (data) => {
if (!this.plugin._embedStderr) this.plugin._embedStderr = '';
this.plugin._embedStderr += data.toString('utf-8');
},
onError: (err) => {
this.plugin._embedProcess = null;
new Notice(t('feat_build_failed') + ': ' + (err.message || err));
this.display();
},
onClose: (code) => {
this.plugin._embedProcess = null;
if (code === 0) {
this.plugin._embedProgress.current = this.plugin._embedProgress.total;
this.plugin.saveSettings();
this._embedStatusText = memoryState.getVectorStatusText(vp);
new Notice(t('feat_build_complete'));
} else {
this._embedStatusText = null;
const errMsg = (this.plugin._embedStderr || '').slice(0, 200);
new Notice(t('feat_build_failed') + (errMsg ? ': ' + errMsg : ''), 8000);
}
this.plugin._embedStderr = '';
this.display();
this._refreshSnapshots(vp);
}
});
this.display();
}
// Corruption warning
if (isCorrupted) {
const warnEl = embedSection.createEl('div');
warnEl.style.cssText = 'padding:8px 12px; margin:8px 0; background:var(--background-modifier-warning); border-radius:4px; font-size:12px; display:flex; align-items:center; justify-content:space-between;';
warnEl.createEl('span', { text: t('feat_vector_corrupted') });
const forceBtn = warnEl.createEl('button', { text: t('feat_vector_rebuild_force_btn') });
forceBtn.className = 'mod-cta';
forceBtn.addEventListener('click', () => startBuild('--force'));
}
// Chunk count + buttons
if (hasChunks && !isCorrupted) {
embedControls.createEl('span', {
text: embedInfo.chunk_count + ' chunks embedded',
cls: 'setting-item-description'
});
}
const buildBtn = embedControls.createEl('button');
buildBtn.setText(hasChunks ? t('feat_rebuild_btn') : t('feat_build_btn'));
buildBtn.addClass('mod-cta');
buildBtn.addEventListener('click', () => startBuild('--resume'));
if (!isCorrupted && hasChunks) {
const forceBtn2 = embedControls.createEl('button');
forceBtn2.setText(t('feat_vector_rebuild_force_btn'));
forceBtn2.style.marginLeft = '6px';
forceBtn2.addEventListener('click', () => startBuild('--force'));
}
}
};
renderEmbedUI();
}
_getCurrentModelKey() {
return this.plugin.settings.vector_db_api_model || 'text-embedding-3-small';
}
_parseEmbedStatus(text) {
// Parse " key: value" lines from paperforge embed status output
const info = {};
if (!text) return info;
text.split('\n').forEach(line => {
const m = line.match(/^\s*([^:]+):\s*(.*)/);
if (m) info[m[1].trim()] = m[2].trim();
});
// Normalize bools
if (info.db_exists !== undefined) info.db_exists = info.db_exists === 'True';
if (info.chunk_count !== undefined) info.chunk_count = parseInt(info.chunk_count, 10) || 0;
return info;
}
_getPythonDesc(pyPath, source) {
if (source === 'stale') {
return `[!!] ${pyPath} (stale — path no longer exists, update or clear the override below)`;
}
if (source === 'manual') {
return `${pyPath} (manual)`;
}
return `${pyPath} (auto-detected)`;
}
_refreshPythonInterpDesc(pyPath, source) {
const desc = this._pythonInterpDescEl;
if (desc) {
if (source === 'stale') {
desc.textContent = `[!!] ${pyPath} (stale — path no longer exists, update or clear the override below)`;
} else if (source === 'manual') {
desc.textContent = `${pyPath} (manual)`;
} else {
desc.textContent = `${pyPath} (auto-detected)`;
}
}
}
_validatePythonOverride() {
const fs = require('fs');
const { execFile } = require('node:child_process');
const customPath = this.plugin.settings.python_path ? this.plugin.settings.python_path.trim() : '';
const desc = this._customPathDescEl;
if (!customPath) {
const msg = '请输入路径 / Enter a path first';
if (desc) desc.textContent = msg;
new Notice(msg);
return;
}
// Check exists
if (!fs.existsSync(customPath)) {
const msg = '路径不存在 / Path does not exist';
if (desc) desc.innerHTML = `\u2717 ${msg}`;
new Notice(msg, 4000);
return;
}
// Check executable
try {
fs.accessSync(customPath, fs.constants.X_OK);
} catch {
const msg = '不可执行 / Not executable';
if (desc) desc.innerHTML = `\u2717 ${msg}`;
new Notice(msg, 4000);
return;
}
// Check version >= 3.10
execFile(customPath, ['--version'], { timeout: 8000 }, (verErr, verOut) => {
if (verErr || !verOut) {
const msg = '无法运行 / Cannot run';
if (desc) desc.innerHTML = `\u2717 ${msg}`;
new Notice(msg, 4000);
return;
}
const match = verOut.match(/Python (\d+)\.(\d+)/);
if (!match) {
const msg = '无法解析版本 / Cannot parse version';
if (desc) desc.innerHTML = `\u2717 ${msg}`;
new Notice(msg, 4000);
return;
}
const major = parseInt(match[1], 10);
const minor = parseInt(match[2], 10);
if (major < 3 || (major === 3 && minor < 10)) {
const msg = 'Python 版本过低,需要 3.10+ / Python version too low, need 3.10+';
if (desc) desc.innerHTML = `\u2717 ${msg}`;
new Notice(msg, 4000);
return;
}
// Check pip
execFile(customPath, ['-m', 'pip', '--version'], { timeout: 8000 }, (pipErr) => {
if (pipErr) {
const warnMsg = `\u2713 Python ${major}.${minor} 有效,但未检测到 pip / Valid, but pip not found`;
if (desc) desc.innerHTML = `\u26A0 ${warnMsg}`;
new Notice(warnMsg, 4000);
} else {
const okMsg = `\u2713 Python ${major}.${minor} 有效 / Valid`;
if (desc) desc.innerHTML = `${okMsg}`;
new Notice(okMsg, 4000);
}
});
});
}
_syncRuntime(btn) {
const vp = this.app.vault.adapter.basePath;
const { path: pythonExe, extraArgs = [] } = resolvePythonExecutable(vp, this.plugin.settings);
const ver = this.plugin.manifest.version;
const installCmd = buildRuntimeInstallCommand(pythonExe, ver, extraArgs);
btn.setDisabled(true);
btn.setButtonText(t('runtime_health_syncing'));
const tryInstall = (args, label) => {
console.log(`[PaperForge] Sync Runtime: trying ${label}`);
return runSubprocess(installCmd.cmd, args, vp, installCmd.timeout, undefined, paperforgeEnrichedEnv());
};
const deploySkills = () => {
let agentKey = 'opencode';
try {
const cfgRaw = fs.readFileSync(path.join(vp, 'paperforge.json'), 'utf-8');
const cfg = JSON.parse(cfgRaw);
if (cfg.agent_key) agentKey = cfg.agent_key;
} catch {}
const { spawn } = require('node:child_process');
const deployArgs = [...extraArgs, '-c',
'from paperforge.services.skill_deploy import deploy_skills; ' +
'from pathlib import Path; ' +
'r=deploy_skills(vault=Path(r"' + vp.replace(/\\/g, '\\\\') + '"), agent_key="' + agentKey + '", overwrite=True); ' +
'print("skills deployed" if r["skill_deployed"] else "skills skipped", flush=True)'
];
const child = spawn(pythonExe, deployArgs, { cwd: vp, timeout: 30000, windowsHide: true });
let out = '';
child.stdout.on('data', (d) => { out += d.toString('utf-8'); });
child.on('close', (code) => {
console.log(`[PaperForge] Skill deploy: ${out.trim()} (exit ${code})`);
});
};
tryInstall(installCmd.pypiArgs, 'PyPI').then((result) => {
if (result.exitCode === 0) {
console.log('[PaperForge] Sync Runtime: installed via PyPI');
deploySkills();
new Notice(t('runtime_health_sync_done').replace('{0}', ver), 5000);
this.display();
return;
}
console.warn('[PaperForge] Sync Runtime: PyPI failed, falling back to git...');
tryInstall(installCmd.gitArgs, 'git').then((r2) => {
if (r2.exitCode === 0) {
console.log('[PaperForge] Sync Runtime: installed via git');
deploySkills();
new Notice(t('runtime_health_sync_done').replace('{0}', ver), 5000);
this.display();
} else {
btn.setDisabled(false);
btn.setButtonText(t('runtime_health_sync'));
console.error('[PaperForge] git fallback stderr:', r2.stderr);
new Notice(t('runtime_health_sync_fail').replace('{0}', 'pip exit code ' + r2.exitCode), 8000);
}
});
});
}
_debouncedSave() {
clearTimeout(this._saveTimeout);
this._saveTimeout = setTimeout(() => this.plugin.saveSettings(), 500);
}
_preCheck(onPass) {
const fs = require('fs');
const path = require('path');
const { execFile } = require('node:child_process');
const vaultPath = this.app.vault.adapter.basePath;
const { path: pythonExe, extraArgs = [] } = resolvePythonExecutable(vaultPath, this.plugin?.settings);
execFile(pythonExe, [...extraArgs, '--version'], { timeout: 8000 }, (pyErr, pyOut) => {
const results = [];
/* 1 — Python */
results.push({ label: 'Python', ok: !pyErr, detail: pyErr ? t('check_python_fail') : pyOut.trim() });
/* 2 — Zotero (check install + data dir) */
let zotOk = false;
const home = process.env.HOME || process.env.USERPROFILE || os.homedir() || '';
if (process.platform === 'darwin') {
const macZot = [
'/Applications/Zotero.app',
path.join(home, 'Applications', 'Zotero.app'),
];
zotOk = macZot.some(d => { try { return fs.existsSync(d); } catch { return false; } });
} else if (process.platform === 'win32') {
const progFiles = process.env.ProgramFiles || '';
const localAppData = process.env.LOCALAPPDATA || '';
const zotInstallDirs = [
path.join(progFiles, 'Zotero'),
path.join(progFiles, '(x86)', 'Zotero'),
path.join(localAppData, 'Programs', 'Zotero'),
path.join(localAppData, 'Zotero'),
path.join(home, 'AppData', 'Local', 'Programs', 'Zotero'),
].filter(Boolean);
zotOk = zotInstallDirs.some(d => { try { return fs.existsSync(d); } catch { return false; } });
} else {
const linuxPaths = [
path.join(home, '.local', 'share', 'zotero', 'zotero'),
'/usr/bin/zotero',
'/usr/local/bin/zotero',
];
zotOk = linuxPaths.some(d => { try { return fs.existsSync(d); } catch { return false; } });
}
// Fallback: check if data dir is configured
const zotDataDir = this.plugin.settings.zotero_data_dir;
if (!zotOk && zotDataDir) {
try { zotOk = fs.existsSync(zotDataDir); } catch {}
}
results.push({ label: 'Zotero', ok: zotOk, detail: zotOk ? t('check_zotero_ok') : t('check_zotero_fail') });
/* 3 — Better BibTeX (Profiles-aware scan) */
let bbtOk = false;
const appData = process.env.APPDATA || '';
if (process.platform === 'win32' && appData) {
bbtOk = scanBbtUnderProfiles(path.join(appData, 'Zotero', 'Zotero', 'Profiles'));
}
if (!bbtOk && process.platform === 'darwin' && home) {
bbtOk = scanBbtUnderProfiles(path.join(home, 'Library', 'Application Support', 'Zotero', 'Profiles'));
}
if (!bbtOk && process.platform !== 'win32' && process.platform !== 'darwin' && home) {
bbtOk = scanBbtUnderProfiles(path.join(home, '.zotero', 'zotero', 'Profiles'));
}
if (!bbtOk && zotDataDir && String(zotDataDir).trim()) {
bbtOk = scanBbtDirectChildren(zotDataDir.trim());
}
if (!bbtOk && home) {
bbtOk = scanBbtDirectChildren(path.join(home, 'Zotero'));
}
results.push({ label: 'Better BibTeX', ok: bbtOk, detail: bbtOk ? t('check_bbt_ok') : t('check_bbt_fail') });
/* Render */
const marks = { true: '✓', false: '✗' };
if (this._checkEl) {
this._checkEl.setText(results.map(r => `${marks[r.ok]} ${r.label}: ${r.detail}`).join('\n'));
const anyFail = results.some(r => !r.ok);
this._checkEl.className = `paperforge-message msg-${anyFail ? 'error' : 'ok'}`;
}
const bad = results.filter(r => !r.ok);
if (bad.length > 0) {
new Notice(`[!!] 未通过: ${bad.map(r => r.label).join(', ')}`, 6000);
}
onPass();
});
}
}
/* ── OCR Privacy Warning Modal (DASH-03) ── */
class PaperForgeOcrPrivacyModal extends Modal {
constructor(app, onConfirm) {
super(app);
this._onConfirm = onConfirm;
}
onOpen() {
const { contentEl } = this;
contentEl.addClass('paperforge-modal');
contentEl.addClass('paperforge-ocr-privacy-modal');
// Title
contentEl.createEl('h2', { text: t('ocr_privacy_title') });
// Warning text
const warningEl = contentEl.createEl('div', { cls: 'paperforge-ocr-privacy-warning' });
warningEl.createEl('p', { text: t('ocr_privacy_warning') });
// "I Understand" button
const btnRow = contentEl.createEl('div', { cls: 'paperforge-ocr-privacy-actions' });
const confirmBtn = btnRow.createEl('button', {
cls: 'paperforge-step-btn mod-cta',
text: t('ocr_understand'),
});
confirmBtn.addEventListener('click', () => {
this.close();
if (this._onConfirm) this._onConfirm();
});
}
onClose() {
this.contentEl.empty();
}
}
/* ── Orphan Paper Cleanup Modal ── */
class PaperForgeOrphanModal extends Modal {
constructor(app, orphans, vaultPath, py) {
super(app);
this.orphans = orphans.map((o, i) => ({ ...o, _selected: true, _idx: i }));
this.vaultPath = vaultPath;
this.py = py;
}
_updateUI() {
const sel = this.orphans.filter(o => o._selected);
this._countEl.setText(t('orphan_delete_selected').replace('{count}', sel.length));
this._selectAllBtn.setText(sel.length === this.orphans.length ? t('orphan_deselect_all') : t('orphan_select_all'));
for (const o of this.orphans) {
const row = this._rowEls[o._idx];
if (!row) continue;
row.toggleClass('paperforge-orphan-dimmed', !o._selected);
}
}
onOpen() {
const { contentEl } = this;
contentEl.addClass('paperforge-modal');
contentEl.createEl('h2', { text: t('orphan_title').replace('{count}', this.orphans.length) });
contentEl.createEl('p', {
cls: 'paperforge-modal-desc',
text: t('orphan_desc')
});
this._rowEls = [];
const listEl = contentEl.createEl('div', { cls: 'paperforge-orphan-list' });
for (const o of this.orphans) {
const row = listEl.createEl('div', { cls: 'paperforge-orphan-row' + (o._selected ? '' : ' paperforge-orphan-dimmed') });
this._rowEls.push(row);
const left = row.createEl('div', { cls: 'paperforge-orphan-info' });
// Header: citation key + tags
const hdr = left.createEl('div', { cls: 'paperforge-orphan-header' });
hdr.createEl('span', { cls: 'paperforge-orphan-key', text: o.citation_key || o.key });
const tags = hdr.createEl('span', { cls: 'paperforge-orphan-tags' });
tags.createEl('span', { cls: 'paperforge-tag ' + (o.has_pdf ? 'tag-pdf' : 'tag-nopdf'), text: o.has_pdf ? 'PDF' : 'no PDF' });
if (o.collection_path) tags.createEl('span', { cls: 'paperforge-tag tag-collection', text: o.collection_path });
if (o.title) left.createEl('div', { cls: 'paperforge-orphan-title', text: o.title });
const meta = [];
if (o.authors) meta.push(o.authors);
if (o.year) meta.push(o.year);
if (meta.length > 0) left.createEl('div', { cls: 'paperforge-orphan-meta', text: meta.join(' \u00B7 ') });
left.createEl('div', { cls: 'paperforge-orphan-explain', text: t('orphan_explain') });
row.addEventListener('click', () => {
o._selected = !o._selected;
this._updateUI();
});
}
const btnRow = contentEl.createEl('div', { cls: 'paperforge-modal-actions' });
this._selectAllBtn = btnRow.createEl('button', { cls: 'paperforge-step-btn', text: 'Deselect all' });
this._selectAllBtn.addEventListener('click', () => {
const allSel = this.orphans.every(o => o._selected);
for (const o of this.orphans) o._selected = !allSel;
this._updateUI();
});
this._countEl = btnRow.createEl('button', { cls: 'paperforge-step-btn mod-cta', text: 'Delete ' + this.orphans.length + ' selected' });
btnRow.createEl('button', { cls: 'paperforge-step-btn', text: 'Keep all' }).addEventListener('click', () => this.close());
this._countEl.addEventListener('click', () => {
const selected = this.orphans.filter(o => o._selected);
if (selected.length === 0) { new Notice(t('orphan_none_selected')); return; }
this._countEl.setText('Deleting...');
this._countEl.setAttr('disabled', '');
this._selectAllBtn.setAttr('disabled', '');
if (!this.py || !this.py.path) { new Notice('PaperForge: Python not found'); this.close(); return; }
const keys = selected.map(o => o.key);
const { execFile } = require('node:child_process');
execFile(this.py.path, [...this.py.extraArgs, '-m', 'paperforge', '--vault', this.vaultPath, 'prune', '--force', '--json', ...keys],
{ cwd: this.vaultPath, timeout: 60000 }, (err, stdout) => {
if (err) { new Notice('PaperForge: prune failed'); this.close(); return; }
try {
const r = JSON.parse(stdout);
const deleted = (r.data && r.data.deleted) || [];
new Notice('Deleted ' + deleted.length + ' orphan workspace(s)');
} catch (_) { new Notice('PaperForge: prune done'); }
this.close();
});
});
}
onClose() {
this.contentEl.empty();
}
}
/* ==========================================================================
Setup Wizard Modal
========================================================================== */
class PaperForgeSetupModal extends Modal {
constructor(app, plugin) {
super(app);
this.plugin = plugin;
this._step = 1;
}
onOpen() {
this._render();
}
onClose() {
this.contentEl.empty();
}
_render() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('paperforge-modal');
this._renderStepIndicator();
this._renderStepContent();
this._renderNavigation();
}
_renderStepIndicator() {
const steps = [t('wizard_step1'), t('wizard_step2'), t('wizard_step3'), t('wizard_step4'), t('wizard_step5')];
const bar = this.contentEl.createEl('div', { cls: 'paperforge-step-bar' });
steps.forEach((label, i) => {
const n = i + 1;
const dot = bar.createEl('div', {
cls: `paperforge-step-dot ${n === this._step ? 'active' : ''} ${n < this._step ? 'done' : ''}`
});
dot.createEl('span', { cls: 'paperforge-step-num', text: `${n}` });
dot.createEl('span', { cls: 'paperforge-step-label', text: label });
});
}
_renderStepContent() {
const el = this.contentEl.createEl('div', { cls: 'paperforge-step-content' });
switch (this._step) {
case 1: this._stepOverview(el); break;
case 2: this._stepDirectories(el); break;
case 3: this._stepKeys(el); break;
case 4: this._stepInstall(el); break;
case 5: this._stepComplete(el); break;
}
}
_renderNavigation() {
const nav = this.contentEl.createEl('div', { cls: 'paperforge-step-nav' });
if (this._step > 1) {
nav.createEl('button', { cls: 'paperforge-step-btn', text: t('nav_prev') })
.addEventListener('click', () => { this._step--; this._render(); });
}
if (this._step < 5) {
const nextBtn = nav.createEl('button', { cls: 'paperforge-step-btn mod-cta', text: t('nav_next') });
nextBtn.addEventListener('click', () => {
if (this._step === 3 && !this._validateStep3()) return;
this._step++;
this._render();
});
} else {
nav.createEl('button', { cls: 'paperforge-step-btn', text: t('nav_close') })
.addEventListener('click', () => this.close());
}
}
_validateStep3() {
const s = this.plugin.settings;
const fs = require('fs');
// Check API key validated
if (!this._apiKeyValidated) {
new Notice('请先验证 PaddleOCR API 密钥');
return false;
}
// Check Zotero data directory: required + exists + is directory + has storage/
if (!s.zotero_data_dir || !s.zotero_data_dir.trim()) {
new Notice('Zotero 数据目录为必填项,请填写路径');
return false;
}
const zotPath = s.zotero_data_dir.trim();
if (!fs.existsSync(zotPath)) {
new Notice('Zotero 数据目录路径不存在');
return false;
}
if (!fs.statSync(zotPath).isDirectory()) {
new Notice('Zotero 数据目录路径不是一个目录');
return false;
}
const storagePath = path.join(zotPath, 'storage');
if (!fs.existsSync(storagePath) || !fs.statSync(storagePath).isDirectory()) {
new Notice('Zotero 数据目录中未找到 storage/ 子目录');
return false;
}
return true;
}
/* ── Step 1: Overview ── */
_stepOverview(el) {
el.createEl('h2', { text: t('wizard_title') });
el.createEl('p', { text: t('wizard_intro') });
const s = this.plugin.settings;
const vault = this.app.vault.adapter.basePath;
const tree = el.createEl('div', { cls: 'paperforge-dir-tree' });
// HARDEN-05: Use createEl() DOM API instead of innerHTML to prevent XSS
// from user-configured directory names containing HTML/script tags.
const rootNode = tree.createEl('div', { cls: 'paperforge-dir-node root' });
rootNode.textContent = `📁 Vault (${vault})`;
const children = tree.createEl('div', { cls: 'paperforge-dir-children' });
const resourcesFolder = children.createEl('div', { cls: 'paperforge-dir-node folder' });
resourcesFolder.textContent = `📁 ${s.resources_dir || 'Resources'}/ — 文献卡片目录(Base 数据来源)`;
const resourcesChildren = resourcesFolder.createEl('div', { cls: 'paperforge-dir-children' });
resourcesChildren.createEl('div', { cls: 'paperforge-dir-node file',
text: `📁 ${s.literature_dir || 'Literature'}/ — 文献卡片` });
children.createEl('div', { cls: 'paperforge-dir-node folder',
text: `📁 ${s.base_dir || 'Bases'}/ — 数据管理面板` });
children.createEl('div', { cls: 'paperforge-dir-node folder',
text: `📁 ${s.system_dir || 'System'}/ — Zotero 软链接 + PaperForge 系统文件夹` });
el.createEl('p', { text: t('wizard_preview'), cls: 'paperforge-modal-hint' });
el.createEl('p', { text: t('wizard_safety'), cls: 'paperforge-modal-hint' });
const summary = el.createEl('div', { cls: 'paperforge-summary' });
const overviewItems = [
{ label: t('dir_resources'), val: `${vault}/${s.resources_dir || 'Resources'}` },
{ label: t('dir_notes'), val: `${vault}/${s.resources_dir || 'Resources'}/${s.literature_dir || 'Literature'}` },
{ label: t('dir_base'), val: `${vault}/${s.base_dir || 'Bases'}` },
{ label: t('dir_system'), val: `${vault}/${s.system_dir || 'System'}` },
];
for (const item of overviewItems) {
const row = summary.createEl('div', { cls: 'paperforge-summary-row' });
row.createEl('span', { cls: 'paperforge-summary-label', text: item.label });
row.createEl('span', { cls: 'paperforge-summary-value', text: item.val });
}
}
/* ── Step 2: Directory Config (editable) ── */
_stepDirectories(el) {
el.createEl('h2', { text: t('wizard_step2') });
el.createEl('p', { text: t('wizard_intro') });
const s = this.plugin.settings;
const vault = this.app.vault.adapter.basePath;
this._modalField(el, t('dir_vault'), vault, true);
el.createEl('p', { text: t('wizard_dir_hint'), cls: 'paperforge-modal-hint' });
this._modalInput(el, '资源目录(创建文献卡片目录的地方)', 'resources_dir', s.resources_dir, 'Resources');
el.createEl('p', { text: t('wizard_dir_sub_hint'), cls: 'paperforge-modal-hint' });
this._modalInput(el, '文献卡片目录(存放文献卡片的地方,Base 数据来源)', 'literature_dir', s.literature_dir, 'Literature');
el.createEl('p', { text: t('wizard_sys_hint'), cls: 'paperforge-modal-hint' });
this._modalInput(el, '系统目录(存放 Zotero 软链接和 PaperForge 系统文件)', 'system_dir', s.system_dir, 'System');
this._modalInput(el, 'Base 目录(存放数据管理面板的地方)', 'base_dir', s.base_dir, 'Bases');
el.createEl('p', { text: t('wizard_safety'), cls: 'paperforge-modal-hint' });
const preview = el.createEl('div', { cls: 'paperforge-summary' });
const previewItems = [
{ label: t('dir_resources'), val: `${vault}/${s.resources_dir || ''}` },
{ label: t('dir_notes'), val: `${vault}/${s.resources_dir || ''}/${s.literature_dir || ''}` },
{ label: t('dir_system'), val: `${vault}/${s.system_dir || ''}` },
{ label: t('dir_base'), val: `${vault}/${s.base_dir || ''}` },
];
for (const item of previewItems) {
const row = preview.createEl('div', { cls: 'paperforge-summary-row' });
row.createEl('span', { cls: 'paperforge-summary-label', text: item.label });
row.createEl('span', { cls: 'paperforge-summary-value', text: item.val });
}
}
/* ── Step 3: Keys, Zotero & Agent ── */
_stepKeys(el) {
el.createEl('h2', { text: t('wizard_step3') });
const s = this.plugin.settings;
el.createEl('p', { text: t('wizard_agent_hint'), cls: 'paperforge-modal-hint' });
const AGENTS = [
{ key: 'opencode', name: 'OpenCode' },
{ key: 'claude', name: 'Claude Code' },
{ key: 'cursor', name: 'Cursor' },
{ key: 'github_copilot', name: 'GitHub Copilot' },
{ key: 'windsurf', name: 'Windsurf' },
{ key: 'codex', name: 'Codex' },
{ key: 'cline', name: 'Cline' },
];
const agentRow = el.createEl('div', { cls: 'paperforge-modal-field' });
agentRow.createEl('label', { cls: 'paperforge-modal-label', text: t('label_agent') });
const select = agentRow.createEl('select', { cls: 'paperforge-modal-select' });
for (const a of AGENTS) {
const opt = select.createEl('option', { text: a.name, attr: { value: a.key } });
if (a.key === (s.agent_platform || 'opencode')) opt.selected = true;
}
select.addEventListener('change', () => {
s.agent_platform = select.value;
if (this._pendingSave) clearTimeout(this._pendingSave);
this._pendingSave = setTimeout(() => { this.plugin.saveSettings(); this._pendingSave = null; }, 500);
});
el.createEl('p', { text: t('wizard_keys_hint'), cls: 'paperforge-modal-hint' });
// PaddleOCR API Key with validate button
const apiRow = el.createEl('div', { cls: 'paperforge-modal-field' });
apiRow.createEl('label', { cls: 'paperforge-modal-label', text: t('field_paddleocr') });
const apiInput = apiRow.createEl('input', {
cls: 'paperforge-modal-input',
attr: { type: 'password', placeholder: 'API Key' }
});
apiInput.value = s.paddleocr_api_key || '';
this._apiKeyValidated = false;
this._apiKeyStatus = apiRow.createEl('span', { cls: 'paperforge-apikey-status', text: '' });
const validateBtn = apiRow.createEl('button', { cls: 'paperforge-step-btn', text: '验证' });
validateBtn.addEventListener('click', () => this._validateApiKey(apiInput.value, validateBtn));
apiInput.addEventListener('input', () => {
s.paddleocr_api_key = apiInput.value;
this._apiKeyValidated = false;
this._apiKeyStatus.textContent = '';
this._apiKeyStatus.className = 'paperforge-apikey-status';
});
if (this._pendingSave) clearTimeout(this._pendingSave);
this._pendingSave = setTimeout(() => { this.plugin.saveSettings(); this._pendingSave = null; }, 500);
// Zotero data directory (now required)
const zotRow = el.createEl('div', { cls: 'paperforge-modal-field' });
zotRow.createEl('label', { cls: 'paperforge-modal-label', text: t('field_zotero_data') });
const zotInput = zotRow.createEl('input', {
cls: 'paperforge-modal-input',
attr: { type: 'text', placeholder: t('field_zotero_placeholder') }
});
zotInput.value = s.zotero_data_dir || '';
zotInput.addEventListener('input', () => {
s.zotero_data_dir = zotInput.value;
if (this._pendingSave) clearTimeout(this._pendingSave);
this._pendingSave = setTimeout(() => { this.plugin.saveSettings(); this._pendingSave = null; }, 500);
});
}
_validateApiKey(key, btn) {
if (!key || key.length < 10) {
this._apiKeyStatus.textContent = '密钥格式不正确';
this._apiKeyStatus.className = 'paperforge-apikey-status error';
return;
}
btn.disabled = true;
btn.textContent = '验证中…';
this._apiKeyStatus.textContent = '正在验证…';
this._apiKeyStatus.className = 'paperforge-apikey-status';
const https = require('https');
const postData = JSON.stringify({ model: 'PaddleOCR-VL-1.5' });
const options = {
hostname: 'paddleocr.aistudio-app.com',
path: '/api/v2/ocr/jobs',
method: 'POST',
headers: {
'Authorization': 'bearer ' + key,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
},
timeout: 10000,
};
const req = https.request(options, (res) => {
btn.disabled = false;
btn.textContent = '验证';
let body = '';
res.on('data', (chunk) => body += chunk);
res.on('end', () => {
try {
const json = JSON.parse(body);
if (res.statusCode === 400 && json.code === 10001) {
// 400 code=10001 = auth passed, file missing (expected)
this._apiKeyStatus.textContent = '✓ 密钥有效';
this._apiKeyStatus.className = 'paperforge-apikey-status ok';
this._apiKeyValidated = true;
} else if (res.statusCode === 401) {
this._apiKeyStatus.textContent = '验证失败:密钥无效';
this._apiKeyStatus.className = 'paperforge-apikey-status error';
this._apiKeyValidated = false;
} else {
this._apiKeyStatus.textContent = '验证失败:API 返回 ' + res.statusCode;
this._apiKeyStatus.className = 'paperforge-apikey-status error';
this._apiKeyValidated = false;
}
} catch (e) {
this._apiKeyStatus.textContent = '验证失败:无法解析响应';
this._apiKeyStatus.className = 'paperforge-apikey-status error';
this._apiKeyValidated = false;
}
});
});
req.on('error', (e) => {
btn.disabled = false;
btn.textContent = '验证';
this._apiKeyStatus.textContent = '验证失败:无法连接 (' + e.message + ')';
this._apiKeyStatus.className = 'paperforge-apikey-status error';
this._apiKeyValidated = false;
});
req.write(postData);
req.end();
}
/* ── Modal form helpers ── */
_modalField(el, label, value, disabled) {
const row = el.createEl('div', { cls: 'paperforge-modal-field' });
row.createEl('label', { cls: 'paperforge-modal-label', text: label });
const input = row.createEl('input', { cls: 'paperforge-modal-input', attr: { type: 'text' } });
input.value = value;
input.disabled = !!disabled;
}
_modalInput(el, label, key, value, placeholder) {
const row = el.createEl('div', { cls: 'paperforge-modal-field' });
row.createEl('label', { cls: 'paperforge-modal-label', text: label });
const input = row.createEl('input', {
cls: 'paperforge-modal-input',
attr: { type: 'text', placeholder: placeholder || '' }
});
input.value = value;
const settings = this.plugin.settings;
input.addEventListener('input', () => {
settings[key] = input.value;
if (this._pendingSave) clearTimeout(this._pendingSave);
this._pendingSave = setTimeout(() => {
this.plugin.saveSettings();
this._pendingSave = null;
}, 500);
});
}
_modalSecret(el, label, key, value, placeholder) {
const row = el.createEl('div', { cls: 'paperforge-modal-field' });
row.createEl('label', { cls: 'paperforge-modal-label', text: label });
const input = row.createEl('input', {
cls: 'paperforge-modal-input',
attr: { type: 'password', placeholder: placeholder || '' }
});
input.value = value;
const settings = this.plugin.settings;
input.addEventListener('input', () => {
settings[key] = input.value;
if (this._pendingSave) clearTimeout(this._pendingSave);
this._pendingSave = setTimeout(() => {
this.plugin.saveSettings();
this._pendingSave = null;
}, 500);
});
}
/* ── Step 4: Install ── */
_stepInstall(el) {
el.createEl('h2', { text: t('wizard_step4') });
this._installLog = el.createEl('div', { cls: 'paperforge-install-log' });
const startBtn = el.createEl('button', { cls: 'paperforge-step-btn mod-cta', text: t('install_btn') });
startBtn.addEventListener('click', () => this._runInstall(startBtn));
}
async _runInstall(btn) {
btn.disabled = true;
btn.textContent = t('install_btn_running');
this._installLog.setText(t('install_validating') + '\n');
this._log(t('install_validating'));
const s = this.plugin.settings;
const errors = this._validate();
if (errors.length > 0) {
this._log(t('validate_fail') + ':');
errors.forEach(e => this._log(' ✗ ' + e));
btn.disabled = false;
btn.textContent = t('install_btn_retry');
return;
}
const { spawn } = require('node:child_process');
const runPython = (args, options = {}) => new Promise((resolve, reject) => {
const { path: pyExe, extraArgs: pyExtra = [] } = resolvePythonExecutable(s.vault_path.trim(), this.plugin.settings);
const child = spawn(pyExe, [...pyExtra, ...args], {
cwd: s.vault_path.trim(),
env: paperforgeEnrichedEnv(),
timeout: 120000,
...options,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
const text = data.toString('utf-8');
stdout += text;
if (options.logStdout) this._processSetupOutput(text);
});
child.stderr.on('data', (data) => {
const text = data.toString('utf-8');
stderr += text;
this._log('[stderr] ' + text.trim());
});
child.on('close', (code) => {
code === 0 ? resolve({ stdout, stderr }) : reject(new Error(stderr.trim() || stdout.trim() || `exit code ${code}`));
});
child.on('error', (err) => reject(err));
});
const setupArgs = [
'-m', 'paperforge',
'--vault', s.vault_path.trim(),
'setup', '--headless',
'--system-dir', s.system_dir.trim(),
'--resources-dir', s.resources_dir.trim(),
'--literature-dir', s.literature_dir.trim(),
'--base-dir', s.base_dir.trim(),
'--agent', s.agent_platform || 'opencode',
];
if (s.zotero_data_dir && s.zotero_data_dir.trim()) setupArgs.push('--zotero-data', s.zotero_data_dir.trim());
if (s.paddleocr_api_key && s.paddleocr_api_key.trim()) setupArgs.push('--paddleocr-key', s.paddleocr_api_key.trim());
try {
let hasPaperforge = true;
try {
await runPython(['-c', 'import paperforge']);
} catch {
hasPaperforge = false;
}
if (!hasPaperforge) {
this._log(t('install_bootstrapping'));
const ver = this.plugin.manifest.version;
this._log(`[install] Trying PyPI: pip install paperforge==${ver}`);
const pypiArgs = ['-m', 'pip', 'install', '--upgrade'];
if (process.platform !== 'win32') pypiArgs.push('--user');
pypiArgs.push(`paperforge==${ver}`);
try {
await runPython(pypiArgs, { logStdout: true });
} catch (pypiErr) {
this._log(`[install] PyPI failed, falling back to git: git+https://...@v${ver}`);
console.warn('[PaperForge] PyPI install failed, falling back to git:', pypiErr.message?.slice(0, 200));
const gitArgs = ['-m', 'pip', 'install', '--upgrade'];
if (process.platform !== 'win32') gitArgs.push('--user');
gitArgs.push(`git+https://github.com/LLLin000/PaperForge.git@v${ver}`);
await runPython(gitArgs, { logStdout: true });
}
}
await runPython(setupArgs, {
logStdout: true,
env: paperforgeEnrichedEnv(),
});
this._log(t('install_complete'));
s.setup_complete = true;
await this.plugin.saveSettings();
setTimeout(() => { this._step = 5; this._render(); }, 800);
} catch (err) {
console.error('PaperForge setup failed:', err.message);
const errorMsg = this._formatSetupError(err.message);
this._log(t('install_failed') + errorMsg);
// Add "Copy diagnostic" button
const diagBtn = this._installLog.parentElement?.createEl('button', {
cls: 'paperforge-copy-diag-btn',
text: t('error_copy_diagnostic') || 'Copy diagnostic',
});
if (diagBtn) {
const rawError = err.message;
const pyInfo = this.plugin?.settings?.python_path || 'auto';
const pluginVer = this.plugin?.manifest?.version || '?';
const osInfo = process.platform + ' ' + process.arch;
let gitDir, resolvedPy;
try { gitDir = resolveGitDir() || '(not found)'; } catch (_) { gitDir = '(error)'; }
try { resolvedPy = resolvePythonExecutable(s.vault_path.trim(), this.plugin.settings); } catch (_) { resolvedPy = null; }
const pathLen = (process.env.PATH || '').length;
const pathHasGit = (process.env.PATH || '').toLowerCase().includes('git');
const diagnostic = [
'[PaperForge Diagnostic]',
'Category: ' + errorMsg,
'Plugin version: ' + pluginVer,
'Python: ' + pyInfo,
'Resolved Python: ' + (resolvedPy?.path || '?'),
'OS: ' + osInfo,
'Vault path: ' + (s.vault_path || '?'),
'--- Git ---',
'Git dir (resolved): ' + gitDir,
'PATH length: ' + pathLen + ' chars',
'PATH contains git: ' + pathHasGit,
'--- Raw error ---',
rawError.slice(0, 2000),
].join('\n');
diagBtn.addEventListener('click', () => {
navigator.clipboard.writeText(diagnostic).then(() => {
diagBtn.setText(t('error_copied') || 'Copied!');
setTimeout(() => {
diagBtn.setText(t('error_copy_diagnostic') || 'Copy diagnostic');
}, 3000);
}).catch(() => {
new Notice('[!!] Clipboard write failed', 6000);
});
});
}
btn.disabled = false;
btn.textContent = t('install_btn_retry');
}
}
_log(msg) {
if (this._installLog) {
this._installLog.setText(this._installLog.textContent + msg + '\n');
}
}
_validate() {
const errors = [];
const s = this.plugin.settings;
if (!s.vault_path || !s.vault_path.trim()) errors.push(t('validate_vault'));
if (!s.resources_dir || !s.resources_dir.trim()) errors.push(t('validate_resources'));
if (!s.literature_dir || !s.literature_dir.trim()) errors.push(t('validate_notes'));
if (!s.base_dir || !s.base_dir.trim()) errors.push(t('validate_base'));
if (!s.paddleocr_api_key || !s.paddleocr_api_key.trim()) this._log(' ! ' + t('validate_key') + ' ' + t('optional_later'));
if (!s.zotero_data_dir || !s.zotero_data_dir.trim()) this._log(' ! ' + t('validate_zotero') + ' ' + t('optional_later'));
return errors;
}
_processSetupOutput(text) {
const lines = text.split('\n').filter(Boolean);
for (const line of lines) {
if (line.includes('[*]') || line.includes('[OK]') || line.includes('[FAIL]')) {
const clean = line.replace(/^\[\*\].*\d+:?\s*/, '').replace(/^\[OK\]\s*/, '').replace(/^\[FAIL\]\s*/, '');
this._log(' ' + clean);
}
}
}
_formatSetupError(raw) {
if (process.platform === 'darwin' && /No module named ['"]?paperforge/i.test(raw)) {
return 'PaperForge not installed — install Python from Homebrew or python.org (Apple CLT /Library/Developer/CommandLineTools python often fails); then: python3 -m pip install --user git+https://github.com/LLLin000/PaperForge.git';
}
const patterns = [
// New: pip not found (before generic command not found)
{ match: /pip.*not found|No module named.*pip|command not found.*pip/i, msg: 'pip not found' },
// Existing: Python not found (keep broad)
{ match: /command not found|No such file|not recognized/i, msg: 'Python not found' },
// New: Network error (DNS resolution, connection refused, etc.)
{ match: /resolve host|getaddrinfo.*nodename|connect ETIMEDOUT|connect ECONNREFUSED|fetch failed|Network error|ENOTFOUND|ECONNREFUSED|ECONNRESET/i, msg: 'Network error' },
// New: SSL certificate
{ match: /certificate verify failed|SSL.*certificate|self.signed.cert|CERTIFICATE_VERIFY_FAILED/i, msg: 'SSL certificate error' },
// New: Disk full
{ match: /No space left on device|disk full|ENOSPC/i, msg: 'Disk full' },
// Existing: PaperForge not installed
{ match: /paperforge.*not found|cannot import|ModuleNotFoundError|No module named/i, msg: 'PaperForge not installed' },
// Existing + New: Permission denied (expand pattern)
{ match: /permission denied|EACCES|EPERM/i, msg: 'Permission denied' },
// Existing: Path not found
{ match: /ENOENT/i, msg: 'Path not found' },
// Existing: Timeout
{ match: /timeout|timed out/i, msg: 'Timeout' },
];
for (const p of patterns) { if (p.match.test(raw)) return p.msg; }
const fallback = raw.split('\n').filter(Boolean).slice(0, 3).join(' | ');
return fallback.slice(0, 200) || 'Unknown error';
}
/* ── Step 5: Complete ── */
_stepComplete(el) {
el.createEl('h2', { text: t('complete_title') });
const summary = el.createEl('div', { cls: 'paperforge-summary' });
summary.createEl('div', { cls: 'paperforge-summary-title', text: t('complete_summary') });
const s = this.plugin.settings;
const vault = this.app.vault.adapter.basePath;
const items = [
{ label: t('dir_vault'), val: vault },
{ label: t('dir_resources'), val: `${vault}/${s.resources_dir}` },
{ label: t('dir_notes'), val: `${vault}/${s.resources_dir}/${s.literature_dir}` },
{ label: t('dir_base'), val: `${vault}/${s.base_dir}` },
{ label: t('dir_system'), val: `${vault}/${s.system_dir}` },
{ label: 'API Key', val: s.paddleocr_api_key ? t('api_key_set') : t('api_key_missing') },
{ label: t('field_zotero_data'), val: s.zotero_data_dir || t('not_set') },
];
for (const item of items) {
const row = summary.createEl('div', { cls: 'paperforge-summary-row' });
row.createEl('span', { cls: 'paperforge-summary-label', text: item.label });
row.createEl('span', { cls: 'paperforge-summary-value', text: item.val });
}
// PaperForge version (fetched async)
const verRow = summary.createEl('div', { cls: 'paperforge-summary-row' });
verRow.createEl('span', { cls: 'paperforge-summary-label', text: 'PaperForge' });
const verVal = verRow.createEl('span', { cls: 'paperforge-summary-value', text: '\u2014' });
{
const vp = vault;
const { path: pythonExe, extraArgs = [] } = resolvePythonExecutable(vp, this.plugin.settings);
execFile(pythonExe, [...extraArgs, '-c', 'import paperforge; print(paperforge.__version__)'], { cwd: vp, timeout: 10000 }, (err, stdout) => {
if (!err && stdout) verVal.textContent = 'v' + stdout.trim();
});
}
for (const item of items) {
const row = summary.createEl('div', { cls: 'paperforge-summary-row' });
row.createEl('span', { cls: 'paperforge-summary-label', text: item.label });
row.createEl('span', { cls: 'paperforge-summary-value', text: item.val });
}
el.createEl('h3', { text: t('complete_next') });
const nextList = el.createEl('div', { cls: 'paperforge-nextsteps' });
const steps = [
[t('complete_step4'), t('complete_step4_desc')],
['', `${t('complete_export_path')} ${vault}/${s.system_dir}/PaperForge/exports/`],
[t('complete_step1'), t('complete_step1_desc')],
[t('complete_step2'), t('complete_step2_desc')],
[t('complete_step3'), t('complete_step3_desc')],
];
for (const [title, desc] of steps) {
const item = nextList.createEl('div', { cls: 'paperforge-nextstep-item' });
if (title) item.createEl('strong', { text: title });
item.createEl('span', { text: desc });
}
}
}
module.exports = class PaperForgePlugin extends Plugin {
async onload() {
await this.loadSettings();
// ── Automatic file polling state ──
this._lastExportMtime = 0;
this._lastOcrMtimes = {};
this._autoSyncRunning = false;
this._lastSyncTime = null;
this._pollTimer = null;
// Embed build process tracking (survives settings tab close)
this._embedProcess = null;
this._embedProgress = { current: 0, total: 0, key: '' };
this._embedStderr = '';
// Clean stale path fields from plugin data.json (migrated to paperforge.json)
this.saveSettings();
T = (langFromApp(this.app) === 'zh') ? LANG.zh : LANG.en;
this.registerView(VIEW_TYPE_PAPERFORGE, (leaf) => new PaperForgeStatusView(leaf));
try { addIcon(PF_ICON_ID, PF_RIBBON_SVG); } catch (_) {}
this.addRibbonIcon(PF_ICON_ID, 'PaperForge Dashboard', () => PaperForgeStatusView.open(this));
this.addSettingTab(new PaperForgeSettingTab(this.app, this));
this.addCommand({
id: 'paperforge-status-panel',
name: `PaperForge: ${t('guide_open')}`,
callback: () => PaperForgeStatusView.open(this),
});
for (const a of ACTIONS) {
this.addCommand({
id: a.id,
name: `PaperForge: ${a.title}`,
callback: () => {
if (a.disabled) {
new Notice(`[i] ${a.disabledMsg || 'This action is not yet available.'}`, 6000);
return;
}
const vp = this.app.vault.adapter.basePath;
new Notice(`PaperForge: running ${a.cmd}...`);
const { path: cmdPythonExe, extraArgs: cmdExtra = [] } = resolvePythonExecutable(vp, this.settings);
execFile(cmdPythonExe, [...cmdExtra, '-m', 'paperforge', a.cmd], { cwd: vp, timeout: 300000 }, (err, stdout, stderr) => {
if (err) {
new Notice(`[!!] ${a.cmd} failed: ${(stderr || err.message).slice(0, 120)}`, 8000);
return;
}
new Notice(`[OK] ${a.okMsg || stdout.trim().split('\n')[0].slice(0, 80)}`);
});
},
});
}
/* ── PDF annotation overlay (deferred — after startup) ── */
setTimeout(() => {
if (this.settings.setup_complete && this.app.vault.adapter.basePath) {
installAnnotationOverlay(this.app, this.app.vault.adapter.basePath, this);
}
}, 2000);
/* ── Auto-update PaperForge (deferred — don't slow startup) ── */
if (this.settings.auto_update_on_startup === true && this.settings.setup_complete) {
setTimeout(() => this._autoUpdate(), 3000);
}
this._startFilePolling();
// First-launch snapshot migration — generate runtime snapshots once if missing
(() => {
const vp = this.app.vault.adapter.basePath;
if (!vp) return;
const runtimePaths = memoryState.resolveVaultPaths(vp);
const memSnap = runtimePaths.memoryStatePath;
const fs = require('fs');
if (!fs.existsSync(memSnap)) {
// No snapshots — first launch or upgrade. Fire and forget.
const py = resolvePythonExecutable(vp, this.settings);
const { execFile } = require('node:child_process');
const commands = [
['runtime-health', '--json'],
['memory', 'status', '--json'],
['embed', 'status', '--json'],
];
commands.forEach((cmdArgs) => {
const args = [...py.extraArgs, '-m', 'paperforge', '--vault', vp, ...cmdArgs];
execFile(py.path, args, { cwd: vp, timeout: 60000, windowsHide: true }, () => {});
});
}
})();
}
_autoUpdate() {
const vp = this.app.vault.adapter.basePath;
const { path: pythonExe, extraArgs = [] } = resolvePythonExecutable(vp, this.settings);
const ver = this.manifest.version;
const pypiPkg = `paperforge==${ver}`;
const gitUrl = `git+https://github.com/LLLin000/PaperForge.git@v${ver}`;
const doInstall = (pkg, onDone) => {
const { spawn } = require('node:child_process');
const child = spawn(pythonExe, [...extraArgs, '-m', 'pip', 'install', '--upgrade', pkg], { cwd: vp, timeout: 120000, env: paperforgeEnrichedEnv() });
child.on('close', (code) => onDone(code === 0));
};
// Check if installed package version matches plugin version
const { execFile } = require('node:child_process');
execFile(pythonExe, [...extraArgs, '-c', 'import paperforge; print(paperforge.__version__)'], { cwd: vp, timeout: 10000 }, (err, stdout) => {
const install = (label) => {
console.log(`[PaperForge] Auto-update: trying PyPI (paperforge==${ver})`);
doInstall(pypiPkg, (ok) => {
if (ok) { console.log('[PaperForge] Auto-update: installed via PyPI'); new Notice(`[OK] PaperForge CLI ${label}`, 5000); return; }
console.warn('[PaperForge] Auto-update: PyPI failed, falling back to git...');
doInstall(gitUrl, (ok2) => {
if (ok2) { console.log('[PaperForge] Auto-update: installed via git'); new Notice(`[OK] PaperForge CLI ${label} (via git)`, 5000); }
});
});
};
if (err) {
install('installed');
return;
}
const pyVer = stdout.trim();
if (pyVer !== ver) {
install(`${pyVer} -> ${ver}`);
}
});
}
/* ── Automatic file polling for seamless memory layer ── */
_startFilePolling() {
const vaultPath = this.app.vault.adapter.basePath;
const fs = require('fs');
const path = require('path');
const { exec } = require('child_process');
this._pollTimer = setInterval(() => {
this._checkExports(vaultPath, fs, path, exec);
this._checkOcr(vaultPath, fs, path, exec);
}, 120000); // every 120 seconds
}
_checkExports(vaultPath, fs, path, exec) {
if (this._autoSyncRunning) return;
const exportsDir = memoryState.resolveVaultPaths(vaultPath).exportsDir;
if (!fs.existsSync(exportsDir)) return;
let newestMtime = 0;
try {
fs.readdirSync(exportsDir).forEach(f => {
if (!f.endsWith('.json')) return;
const stat = fs.statSync(path.join(exportsDir, f));
if (stat.mtimeMs > newestMtime) newestMtime = stat.mtimeMs;
});
} catch(e) { return; }
if (newestMtime > this._lastExportMtime) {
this._lastExportMtime = newestMtime;
this._autoSync(vaultPath, exec);
}
}
_autoSync(vaultPath, exec) {
if (this._autoSyncRunning) return;
this._autoSyncRunning = true;
const pyResult = resolvePythonExecutable(vaultPath, this.settings);
if (!pyResult.path) { this._autoSyncRunning = false; return; }
const cmd = `"${pyResult.path}" -m paperforge --vault "${vaultPath}" sync`;
exec(cmd, { timeout: 120000, encoding: 'utf-8' }, (err, stdout, stderr) => {
this._autoSyncRunning = false;
this._memoryStatusText = null; // force re-check next time
if (!err) {
this._lastSyncTime = new Date().toLocaleTimeString();
}
// Update last export mtime to avoid re-trigger during build
try {
const fs = require('fs');
const path = require('path');
const exportsDir = memoryState.resolveVaultPaths(vaultPath).exportsDir;
let newest = 0;
fs.readdirSync(exportsDir).forEach(f => {
if (!f.endsWith('.json')) return;
newest = Math.max(newest, fs.statSync(path.join(exportsDir, f)).mtimeMs);
});
this._lastExportMtime = newest;
} catch(e) {}
});
}
_checkOcr(vaultPath, fs, path, exec) {
if (this._autoSyncRunning) return;
const ocrDir = memoryState.resolveVaultPaths(vaultPath).ocrDir;
if (!fs.existsSync(ocrDir)) return;
try {
fs.readdirSync(ocrDir, { withFileTypes: true }).forEach(entry => {
if (!entry.isDirectory()) return;
const metaPath = path.join(ocrDir, entry.name, 'meta.json');
if (!fs.existsSync(metaPath)) return;
const stat = fs.statSync(metaPath);
const prevMtime = this._lastOcrMtimes[entry.name] || 0;
if (stat.mtimeMs <= prevMtime) return;
this._lastOcrMtimes[entry.name] = stat.mtimeMs;
if (this._autoSyncRunning) return;
this._autoSyncRunning = true;
const pyResult = resolvePythonExecutable(vaultPath, this.settings);
if (!pyResult.path) { this._autoSyncRunning = false; return; }
// `paperforge sync` has no `--key` selector. Fall back to a full sync
// so OCR state changes still propagate into the canonical index.
const cmd = `"${pyResult.path}" -m paperforge --vault "${vaultPath}" sync`;
exec(cmd, { timeout: 30000, encoding: 'utf-8' }, () => {
this._autoSyncRunning = false;
this._memoryStatusText = null;
});
});
} catch(e) {}
}
/**
* Read path configuration from the canonical paperforge.json file.
* Falls back to Python-level DEFAULT_CONFIG values if file does not exist.
* Returns {system_dir, resources_dir, literature_dir, base_dir}.
*/
readPaperforgeJson() {
const fs = require('fs');
const path = require('path');
const vaultPath = this.app.vault.adapter.basePath;
const pfPath = path.join(vaultPath, 'paperforge.json');
// Python DEFAULT_CONFIG values as fallback (must match config.py exactly)
const DEFAULTS = {
system_dir: 'System',
resources_dir: 'Resources',
literature_dir: 'Literature',
base_dir: 'Bases',
};
try {
if (!fs.existsSync(pfPath)) {
return DEFAULTS;
}
const raw = fs.readFileSync(pfPath, 'utf-8');
const data = JSON.parse(raw);
// Try vault_config block first (canonical), fall back to top-level (legacy)
const vc = data.vault_config || {};
return {
system_dir: vc.system_dir || data.system_dir || DEFAULTS.system_dir,
resources_dir: vc.resources_dir || data.resources_dir || DEFAULTS.resources_dir,
literature_dir: vc.literature_dir || data.literature_dir || DEFAULTS.literature_dir,
base_dir: vc.base_dir || data.base_dir || DEFAULTS.base_dir,
};
} catch (e) {
console.warn('PaperForge: Failed to read paperforge.json, using defaults', e);
return DEFAULTS;
}
}
/**
* Write path configuration back to paperforge.json vault_config block.
* Reads the existing file, updates vault_config with the new values,
* and writes back preserving all other keys.
*/
savePaperforgeJson(pathConfig) {
const fs = require('fs');
const path = require('path');
const vaultPath = this.app.vault.adapter.basePath;
const pfPath = path.join(vaultPath, 'paperforge.json');
let data = {};
try {
if (fs.existsSync(pfPath)) {
data = JSON.parse(fs.readFileSync(pfPath, 'utf-8'));
}
} catch (e) {
console.warn('PaperForge: Failed to read paperforge.json for update', e);
}
// Ensure vault_config block exists
if (!data.vault_config || typeof data.vault_config !== 'object') {
data.vault_config = {};
}
// Update vault_config with new path values
const validPathKeys = ['system_dir', 'resources_dir', 'literature_dir', 'base_dir'];
for (const key of validPathKeys) {
if (pathConfig[key] !== undefined) {
data.vault_config[key] = pathConfig[key];
}
}
// Ensure schema_version
if (!data.schema_version) {
data.schema_version = '2';
}
// Remove any stale top-level path keys (they were migrated to vault_config)
for (const key of validPathKeys) {
delete data[key];
}
try {
fs.writeFileSync(pfPath, JSON.stringify(data, null, 2), 'utf-8');
// Refresh the in-memory settings
if (this.settings) {
const pfConfig = this.readPaperforgeJson();
this.settings.system_dir = pfConfig.system_dir;
this.settings.resources_dir = pfConfig.resources_dir;
this.settings.literature_dir = pfConfig.literature_dir;
this.settings.base_dir = pfConfig.base_dir;
}
} catch (e) {
console.error('PaperForge: Failed to write paperforge.json', e);
new Notice('PaperForge: Failed to save configuration to paperforge.json');
}
}
onunload() {
if (this._pollTimer) clearInterval(this._pollTimer);
this.app.workspace.detachLeavesOfType(VIEW_TYPE_PAPERFORGE);
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
// Deep-merge nested objects (features, frozen_skills) to avoid overwrite
if (this.settings.features && DEFAULT_SETTINGS.features) {
this.settings.features = Object.assign({}, DEFAULT_SETTINGS.features, this.settings.features || {});
}
if (!this.settings.frozen_skills) { this.settings.frozen_skills = {}; }
// Path fields come from paperforge.json, not from DEFAULT_SETTINGS or plugin data.json
const pfConfig = this.readPaperforgeJson();
this.settings.system_dir = pfConfig.system_dir;
this.settings.resources_dir = pfConfig.resources_dir;
this.settings.literature_dir = pfConfig.literature_dir;
this.settings.base_dir = pfConfig.base_dir;
// Re-validate saved python_path override
if (this.settings.python_path && this.settings.python_path.trim()) {
const pp = this.settings.python_path.trim();
if (!fs.existsSync(pp)) {
console.warn(`PaperForge: Saved python_path "${pp}" no longer exists — showing stale warning`);
this.settings._python_path_stale = true;
} else {
this.settings._python_path_stale = false;
}
}
}
async saveSettings() {
// Only persist non-path settings to plugin data.json
const dataToSave = {};
for (const key of Object.keys(DEFAULT_SETTINGS)) {
if (key in this.settings) {
dataToSave[key] = this.settings[key];
}
}
await this.saveData(dataToSave);
}
};