feat: paperforge status --json, plugin panel with OCR progress bar

This commit is contained in:
Research Assistant 2026-04-28 19:21:52 +08:00
parent 11ebc85c4e
commit f617054d5a
6 changed files with 182 additions and 131 deletions

View file

@ -148,7 +148,8 @@ def build_parser() -> argparse.ArgumentParser:
)
# status
sub.add_parser("status", help="Run the literature pipeline status check")
status_p = sub.add_parser("status", help="Run the literature pipeline status check")
status_p.add_argument("--json", action="store_true", dest="json_output", help="Output JSON")
# sync (new unified command)
p_sync = sub.add_parser("sync", help="Sync Zotero selection and refresh literature index")

View file

@ -40,4 +40,4 @@ def run(args: argparse.Namespace) -> int:
vault = resolve_vault(cli_vault=getattr(args, "vault", None))
run_status = _get_run_status()
return run_status(vault, verbose=getattr(args, "verbose", False))
return run_status(vault, verbose=getattr(args, "verbose", False), json_output=getattr(args, "json_output", False))

View file

@ -4,34 +4,18 @@ const { exec } = require('node:child_process');
const VIEW_TYPE_PAPERFORGE = 'paperforge-status';
const COMMANDS = [
{
id: 'paperforge-sync',
name: 'PaperForge: 同步文献并生成笔记',
cmd: 'sync',
okMsg: 'Sync complete',
},
{
id: 'paperforge-ocr',
name: 'PaperForge: 运行 OCR',
cmd: 'ocr',
okMsg: 'OCR started — check status for completion',
},
{ id: 'paperforge-sync', name: 'PaperForge: 同步文献并生成笔记', cmd: 'sync', okMsg: 'Sync complete' },
{ id: 'paperforge-ocr', name: 'PaperForge: 运行 OCR', cmd: 'ocr', okMsg: 'OCR started' },
];
class PaperForgeStatusView extends ItemView {
constructor(leaf) {
super(leaf);
}
constructor(leaf) { super(leaf); }
getViewType() { return VIEW_TYPE_PAPERFORGE; }
getDisplayText() { return 'PaperForge'; }
getIcon() { return 'book-open'; }
async onOpen() {
this._buildPanel();
this._fetchStats();
}
async onOpen() { this._buildPanel(); this._fetchStats(); }
onClose() {}
_buildPanel() {
@ -41,85 +25,95 @@ class PaperForgeStatusView extends ItemView {
root.createEl('div', { cls: 'paperforge-status-header' }, (el) => {
el.createEl('h3', { text: 'PaperForge' });
const refreshBtn = el.createEl('button', {
cls: 'paperforge-status-refresh',
text: 'Refresh',
});
refreshBtn.addEventListener('click', () => this._fetchStats());
const btn = el.createEl('button', { cls: 'paperforge-status-refresh', text: 'Refresh' });
btn.addEventListener('click', () => this._fetchStats());
});
this._metricsEl = root.createEl('div', { cls: 'paperforge-metrics' });
this._statsEl = root.createEl('div', { cls: 'paperforge-stats' });
this._ocrEl = root.createEl('div', { cls: 'paperforge-ocr-section' });
this._actionsEl = root.createEl('div', { cls: 'paperforge-actions' }, (el) => {
el.createEl('h4', { text: 'Quick Actions' });
const btnGrp = el.createEl('div', { cls: 'paperforge-actions-buttons' });
el.createEl('h4', { text: 'Actions' });
const bg = el.createEl('div', { cls: 'paperforge-actions-buttons' });
for (const c of COMMANDS) {
const btn = btnGrp.createEl('button', {
cls: 'paperforge-action-btn',
text: c.name,
});
btn.addEventListener('click', () => this._runCommand(c));
const b = bg.createEl('button', { cls: 'paperforge-action-btn', text: c.name });
b.addEventListener('click', () => this._runCmd(c));
}
});
}
_fetchStats() {
this._metricsEl.empty();
this._metricsEl.createEl('div', {
text: 'Loading...',
cls: 'paperforge-status-loading',
});
this._statsEl.empty();
this._ocrEl.empty();
this._statsEl.setText('Loading...');
const vaultPath = this.app.vault.adapter.basePath;
exec('python -m paperforge status', { cwd: vaultPath, timeout: 30000 }, (err, stdout) => {
this._metricsEl.empty();
const vp = this.app.vault.adapter.basePath;
exec('python -m paperforge status --json', { cwd: vp, timeout: 30000 }, (err, stdout) => {
this._statsEl.empty();
if (err) {
this._metricsEl.createEl('div', {
text: 'Error connecting to PaperForge. Is it installed?',
cls: 'paperforge-status-error',
});
this._statsEl.createEl('div', { text: 'Error connecting to PaperForge', cls: 'paperforge-status-error' });
return;
}
this._renderMetrics(stdout);
try {
const d = JSON.parse(stdout);
this._renderStats(d);
this._renderOcr(d);
} catch {
this._statsEl.createEl('div', { text: 'Invalid response from paperforge', cls: 'paperforge-status-error' });
}
});
}
_renderMetrics(statusText) {
const lines = statusText.split('\n').filter(Boolean);
const stats = {};
for (const line of lines) {
const m = line.match(/^\S+/);
if (!m) continue;
const key = m[0].toLowerCase();
stats.total = stats.total || 0;
if (key === 'found') {
const n = parseInt(line.match(/\d+/)?.[0] || '0');
stats.total += n;
}
}
_renderStats(d) {
const metrics = [
{ label: 'Papers', value: stats.total || '—', color: 'var(--color-cyan)' },
{ label: 'Papers', value: d.total_papers, color: 'var(--color-cyan)' },
{ label: 'Notes', value: d.formal_notes, color: 'var(--color-blue)' },
{ label: 'Exports', value: d.exports, color: 'var(--color-purple)' },
];
for (const metric of metrics) {
const card = this._metricsEl.createEl('div', { cls: 'paperforge-metric-card' });
card.createEl('div', { text: metric.value.toString(), cls: 'paperforge-metric-value' });
card.createEl('div', { text: metric.label, cls: 'paperforge-metric-label' });
if (metric.color) {
card.style.setProperty('--metric-color', metric.color);
}
const grid = this._statsEl.createEl('div', { cls: 'paperforge-metrics' });
for (const m of metrics) {
const card = grid.createEl('div', { cls: 'paperforge-metric-card' });
card.style.setProperty('--metric-color', m.color);
card.createEl('div', { cls: 'paperforge-metric-value', text: m.value?.toString() || '—' });
card.createEl('div', { cls: 'paperforge-metric-label', text: m.label });
}
}
_runCommand(c) {
const vaultPath = this.app.vault.adapter.basePath;
_renderOcr(d) {
const ocr = d.ocr || {};
const total = ocr.total || 0;
const done = ocr.done || 0;
const pending = ocr.pending || 0;
const failed = ocr.failed || 0;
const pct = total > 0 ? Math.round(done / total * 100) : 0;
const section = this._ocrEl;
section.createEl('h4', { text: 'OCR Progress' });
const bar = section.createEl('div', { cls: 'paperforge-progress-bar' });
bar.createEl('div', { cls: 'paperforge-progress-fill', attr: { style: `width:${pct}%` } });
bar.createEl('span', { cls: 'paperforge-progress-label', text: `${done}/${total} (${pct}%)` });
const detail = section.createEl('div', { cls: 'paperforge-progress-detail' });
if (pending > 0) {
detail.createEl('div', { cls: 'paperforge-progress-row' }, (el) => {
el.createEl('span', { text: 'Processing', cls: 'paperforge-progress-row-label' });
el.createEl('span', { text: pending.toString(), cls: 'paperforge-progress-row-value' });
});
}
if (failed > 0) {
detail.createEl('div', { cls: 'paperforge-progress-row' }, (el) => {
el.createEl('span', { text: 'Failed', cls: 'paperforge-progress-row-label' });
el.createEl('span', { text: failed.toString(), cls: 'paperforge-progress-row-value paperforge-progress-row-failed' });
});
}
}
_runCmd(c) {
const vp = this.app.vault.adapter.basePath;
new Notice(`PaperForge: running ${c.cmd}...`);
exec(`python -m paperforge ${c.cmd}`, { cwd: vaultPath, timeout: 300000 }, (err, stdout, stderr) => {
exec(`python -m paperforge ${c.cmd}`, { cwd: vp, timeout: 300000 }, (err, stdout, stderr) => {
if (err) {
const msg = stderr
? stderr.split('\n').filter(Boolean).slice(-2).join(' | ')
: err.message;
const msg = stderr ? stderr.split('\n').filter(Boolean).slice(-2).join(' | ') : err.message;
new Notice(`[!!] ${c.cmd} failed: ${msg}`, 8000);
return;
}
@ -130,45 +124,25 @@ class PaperForgeStatusView extends ItemView {
static async open(plugin) {
const leaves = plugin.app.workspace.getLeavesOfType(VIEW_TYPE_PAPERFORGE);
if (leaves.length > 0) {
plugin.app.workspace.revealLeaf(leaves[0]);
return;
}
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);
}
if (leaf) { await leaf.setViewState({ type: VIEW_TYPE_PAPERFORGE, active: true }); plugin.app.workspace.revealLeaf(leaf); }
}
}
module.exports = class PaperForgePlugin extends Plugin {
async onload() {
this.registerView(VIEW_TYPE_PAPERFORGE, (leaf) => new PaperForgeStatusView(leaf));
this.addRibbonIcon('book-open', 'PaperForge Status', () => {
PaperForgeStatusView.open(this);
});
this.addCommand({
id: 'paperforge-status-panel',
name: 'PaperForge: 打开状态面板',
callback: () => PaperForgeStatusView.open(this),
});
this.addRibbonIcon('book-open', 'PaperForge Status', () => PaperForgeStatusView.open(this));
this.addCommand({ id: 'paperforge-status-panel', name: 'PaperForge: 打开状态面板', callback: () => PaperForgeStatusView.open(this) });
for (const c of COMMANDS) {
this.addCommand({
id: c.id,
name: c.name,
id: c.id, name: c.name,
callback: () => {
const vaultPath = this.app.vault.adapter.basePath;
const vp = this.app.vault.adapter.basePath;
new Notice(`PaperForge: running ${c.cmd}...`);
exec(`python -m paperforge ${c.cmd}`, { cwd: vaultPath, timeout: 300000 }, (err, stdout, stderr) => {
if (err) {
const msg = stderr ? stderr.split('\n').filter(Boolean).slice(-2).join(' | ') : err.message;
new Notice(`[!!] ${c.cmd} failed: ${msg}`, 8000);
return;
}
exec(`python -m paperforge ${c.cmd}`, { cwd: vp, timeout: 300000 }, (err, stdout, stderr) => {
if (err) { new Notice(`[!!] ${c.cmd} failed: ${(stderr || err.message).slice(0, 120)}`, 8000); return; }
new Notice(`[OK] ${c.okMsg || stdout.trim().split('\n')[0].slice(0, 80)}`);
});
},
@ -176,7 +150,5 @@ module.exports = class PaperForgePlugin extends Plugin {
}
}
onunload() {
this.app.workspace.detachLeavesOfType(VIEW_TYPE_PAPERFORGE);
}
onunload() { this.app.workspace.detachLeavesOfType(VIEW_TYPE_PAPERFORGE); }
};

View file

@ -4,7 +4,7 @@
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
gap: 20px;
}
/* ── Header ── */
@ -43,7 +43,7 @@
.paperforge-metric-card {
flex: 1;
min-width: 100px;
min-width: 90px;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
@ -65,6 +65,67 @@
margin-top: 2px;
}
/* ── OCR Progress Bar ── */
.paperforge-ocr-section h4 {
margin: 0 0 8px;
font-size: var(--font-ui-small);
color: var(--text-muted);
}
.paperforge-progress-bar {
position: relative;
height: 22px;
background: var(--background-modifier-border);
border-radius: var(--radius-s);
overflow: hidden;
}
.paperforge-progress-fill {
height: 100%;
background: var(--interactive-accent);
border-radius: var(--radius-s);
transition: width 0.3s ease;
}
.paperforge-progress-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-ui-smaller);
font-weight: var(--font-medium);
color: var(--text-on-accent);
mix-blend-mode: difference;
}
.paperforge-progress-detail {
margin-top: 6px;
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.paperforge-progress-row {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--font-ui-smaller);
}
.paperforge-progress-row-label {
color: var(--text-muted);
}
.paperforge-progress-row-value {
color: var(--text-normal);
font-weight: var(--font-medium);
}
.paperforge-progress-row-failed {
color: var(--text-error);
}
/* ── Status rows ── */
.paperforge-status-row {
display: flex;
@ -79,22 +140,7 @@
border-bottom: none;
}
.paperforge-status-row-label {
color: var(--text-muted);
}
.paperforge-status-row-value {
color: var(--text-normal);
font-weight: var(--font-medium);
}
/* ── Error / Loading ── */
.paperforge-status-loading {
color: var(--text-muted);
font-size: var(--font-ui-small);
padding: 8px 0;
}
.paperforge-status-error {
color: var(--text-error);
font-size: var(--font-ui-small);

View file

@ -452,8 +452,10 @@ def _is_junction(path: Path) -> bool:
return False
def run_status(vault: Path, verbose: bool = False) -> int:
def run_status(vault: Path, verbose: bool = False, json_output: bool = False) -> int:
"""Print a compact Lite install/runtime status."""
import json as _json
paths = pipeline_paths(vault)
cfg = load_vault_config(vault)
config = load_domain_config(paths)
@ -464,15 +466,23 @@ def run_status(vault: Path, verbose: bool = False) -> int:
base_count = sum(1 for _ in paths["bases"].glob("*.base")) if paths["bases"].exists() else 0
ocr_done = 0
ocr_total = 0
ocr_pending = 0
ocr_failed = 0
if paths["ocr"].exists():
for meta_path in paths["ocr"].glob("*/meta.json"):
ocr_total += 1
try:
meta = read_json(meta_path)
except Exception:
ocr_failed += 1
continue
if str(meta.get("ocr_status", "")).strip().lower() == "done":
status = str(meta.get("ocr_status", "")).strip().lower()
if status == "done":
ocr_done += 1
elif status in ("pending", "processing"):
ocr_pending += 1
else:
ocr_failed += 1
env_paths = [vault / ".env", paths["pipeline"] / ".env"]
env_found = [str(path.relative_to(vault)).replace("\\", "/") for path in env_paths if path.exists()]
@ -487,18 +497,40 @@ def run_status(vault: Path, verbose: bool = False) -> int:
except Exception:
continue
if json_output:
data = {
"vault": str(vault),
"system_dir": cfg["system_dir"],
"resources_dir": cfg["resources_dir"],
"exports": len(export_files),
"domains": len(config.get("domains", [])),
"total_papers": record_count,
"formal_notes": note_count,
"bases": base_count,
"ocr": {
"done": ocr_done,
"pending": ocr_pending,
"failed": ocr_failed,
"total": ocr_total,
},
"path_errors": path_error_count,
"env_configured": len(env_found) > 0,
}
print(_json.dumps(data, indent=2, ensure_ascii=False))
return 0
print("PaperForge status")
print(f"- vault: {vault}")
print(f"- system_dir: {cfg['system_dir']}")
print(f"- resources_dir: {cfg['resources_dir']}")
print(f"- literature_dir: {cfg['literature_dir']}")
print(f"- literature_dir: cfg['literature_dir']")
print(f"- control_dir: {cfg['control_dir']}")
print(f"- exports: {len(export_files)} JSON file(s)")
print(f"- domains: {len(config.get('domains', []))}")
print(f"- library_records: {record_count}")
print(f"- formal_notes: {note_count}")
print(f"- bases: {base_count}")
print(f"- ocr: {ocr_done}/{ocr_total} done")
print(f"- ocr: {ocr_done}/{ocr_total} done (pending: {ocr_pending}, failed: {ocr_failed})")
print(f"- path_errors: {path_error_count}")
if path_error_count > 0:
print(" Tip: Run `paperforge repair --fix-paths` to attempt resolution")

View file

@ -13,7 +13,7 @@ import pytest
CAPTURED_CALLS: list[tuple[str, Path]] = []
def stub_run_status(vault: Path, verbose: bool = False) -> int:
def stub_run_status(vault: Path, verbose: bool = False, json_output: bool = False) -> int:
CAPTURED_CALLS.append(("run_status", vault))
return 0