diff --git a/paperforge/cli.py b/paperforge/cli.py index 5516d4a5..7a474a09 100644 --- a/paperforge/cli.py +++ b/paperforge/cli.py @@ -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") diff --git a/paperforge/commands/status.py b/paperforge/commands/status.py index 08c506dd..c02b9438 100644 --- a/paperforge/commands/status.py +++ b/paperforge/commands/status.py @@ -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)) diff --git a/paperforge/plugin/main.js b/paperforge/plugin/main.js index 8c73c23f..2ab6bcf1 100644 --- a/paperforge/plugin/main.js +++ b/paperforge/plugin/main.js @@ -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); } }; \ No newline at end of file diff --git a/paperforge/plugin/styles.css b/paperforge/plugin/styles.css index fe4353ba..27bf5236 100644 --- a/paperforge/plugin/styles.css +++ b/paperforge/plugin/styles.css @@ -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); diff --git a/paperforge/worker/status.py b/paperforge/worker/status.py index 053c8a1b..09d18505 100644 --- a/paperforge/worker/status.py +++ b/paperforge/worker/status.py @@ -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") diff --git a/tests/test_cli_worker_dispatch.py b/tests/test_cli_worker_dispatch.py index 75b94eee..ee628468 100644 --- a/tests/test_cli_worker_dispatch.py +++ b/tests/test_cli_worker_dispatch.py @@ -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