From 798e048a8df7e17140dd76629f24f80b721a5aed Mon Sep 17 00:00:00 2001 From: Research Assistant Date: Tue, 19 May 2026 00:31:19 +0800 Subject: [PATCH] feat(sync): auto-detect orphan papers after sync, show modal in Obsidian - prune.py: _enrich_orphan_preview() reads note frontmatter for metadata - sync_service.py: always run dry-run prune, include in result - sync.py: write sync-orphan-state.json for plugin consumption - main.js: add checkOrphanState() + PaperForgeOrphanModal with select/toggle - styles.css: orphan modal styles (list, tags, dimmed rows) - cli.py: add --keys to prune command for targeted deletion --- paperforge/cli.py | 1 + paperforge/commands/prune.py | 36 ++++- paperforge/commands/sync.py | 53 +++++-- paperforge/plugin/main.js | 139 ++++++++++++++++-- paperforge/plugin/styles.css | 117 +++++++++++++++ paperforge/services/sync_service.py | 19 ++- paperforge/worker/prune.py | 62 ++++++-- .../unit/services/test_sync_service_prune.py | 14 +- 8 files changed, 389 insertions(+), 52 deletions(-) diff --git a/paperforge/cli.py b/paperforge/cli.py index 5a25a37f..ecfe0206 100644 --- a/paperforge/cli.py +++ b/paperforge/cli.py @@ -282,6 +282,7 @@ def build_parser() -> argparse.ArgumentParser: p_prune = sub.add_parser("prune", help="Delete orphan paper artifacts (dry-run by default)") p_prune.add_argument("--force", action="store_true", help="Actually delete (default: dry-run)") p_prune.add_argument("--json", action="store_true", help="Output as JSON") + p_prune.add_argument("keys", nargs="*", metavar="KEY", help="Only process these zotero keys") # Memory Layer commands p_memory = sub.add_parser("memory", help="Manage the Memory Layer") diff --git a/paperforge/commands/prune.py b/paperforge/commands/prune.py index 7a6b737a..4aafb108 100644 --- a/paperforge/commands/prune.py +++ b/paperforge/commands/prune.py @@ -57,6 +57,7 @@ def run(args: argparse.Namespace) -> int: vault = args.vault_path force = getattr(args, "force", False) json_output = getattr(args, "json", False) + keys_filter = getattr(args, "keys", None) or [] try: fresh_index = read_index(vault) @@ -76,20 +77,45 @@ def run(args: argparse.Namespace) -> int: result_data = prune_orphan_papers(vault, fresh_index=fresh_index, dry_run=True) + preview = result_data.get("preview", []) + if keys_filter: + keys_set = set(keys_filter) + preview = [p for p in preview if p["key"] in keys_set] + + if not preview: + if json_output: + result = PFResult(ok=True, command="prune", version=__version__, data={"preview": [], "deleted": [], "counts": {}}) + print(result.to_json()) + else: + msg = "[OK] No orphan papers found." if not keys_filter else "[OK] No matching orphan papers found." + print(msg) + return 0 + + if json_output and force: + candidates = [ + { + "key": p["key"], + "domain": p["domain"], + "workspace_dir": Path(p["workspace"]), + "ocr_dir": Path(p["ocr_dir"]) if p.get("ocr_dir") else None, + } + for p in preview + ] + result_data = prune_orphan_papers(vault, fresh_index=fresh_index, dry_run=False, _candidates=candidates) + result = PFResult(ok=True, command="prune", version=__version__, data=result_data) + print(result.to_json()) + return 0 + if json_output: data_out = dict(result_data) data_out["dry_run"] = not force + data_out["preview"] = preview result = PFResult( ok=True, command="prune", version=__version__, data=data_out, ) print(result.to_json()) return 0 - preview = result_data.get("preview", []) - if not preview: - print("[OK] No orphan papers found.") - return 0 - print(f"[PRUNE] Found {len(preview)} orphan paper(s):") for p in preview: extras = [] diff --git a/paperforge/commands/sync.py b/paperforge/commands/sync.py index 5c2a8099..eeb76b27 100644 --- a/paperforge/commands/sync.py +++ b/paperforge/commands/sync.py @@ -11,16 +11,9 @@ logger = logging.getLogger(__name__) def run(args: argparse.Namespace) -> int: - """Run sync command through SyncService. - - By default runs both selection-sync and index-refresh + cleanup. - Use --selection or --index to run only one phase. - SyncService is the canonical entry point for all sync operations. - """ vault = getattr(args, "vault_path", None) if vault is None: from paperforge.config import resolve_vault - vault = resolve_vault(cli_vault=getattr(args, "vault", None)) verbose = getattr(args, "verbose", False) @@ -64,22 +57,31 @@ def run(args: argparse.Namespace) -> int: result = svc.run(verbose=verbose, json_output=json_output, selection_only=selection_only, index_only=index_only, prune=prune_flag, prune_force=prune_force) + _write_orphan_state(vault, result) + if json_output: print(result.to_json()) if result.ok and not dry_run and not index_only and not selection_only: - import subprocess, sys - subprocess.Popen( - [sys.executable, "-m", "paperforge", "embed", "build", "--resume"], - cwd=str(vault), - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0, - ) + try: + from paperforge.memory.builder import build_from_index + build_from_index(vault) + except Exception: + pass + try: + import subprocess, sys + subprocess.Popen( + [sys.executable, "-m", "paperforge", "embed", "build", "--resume"], + cwd=str(vault), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0, + ) + except Exception: + pass return 0 if not result.ok: return 1 - # best-effort: memory rebuild try: from paperforge.memory.builder import build_from_index counts = build_from_index(vault) @@ -88,7 +90,6 @@ def run(args: argparse.Namespace) -> int: except Exception as e: print(f"memory: deferred ({e})") - # background: trigger vector resume (silent, no output unless embed actually runs) try: import subprocess, sys subprocess.Popen( @@ -101,3 +102,23 @@ def run(args: argparse.Namespace) -> int: pass return 0 + + +def _write_orphan_state(vault, result: PFResult) -> None: + preview = (result.data or {}).get("prune", {}) if result.data else {} + items = preview.get("preview", []) if isinstance(preview, dict) else [] + orphan_path = vault / "System" / "PaperForge" / "indexes" / "sync-orphan-state.json" + if not items: + try: + orphan_path.unlink(missing_ok=True) + except Exception: + pass + return + + import json as _json + + orphan_path.parent.mkdir(parents=True, exist_ok=True) + try: + orphan_path.write_text(_json.dumps({"orphans": items, "count": len(items)}, indent=2), encoding="utf-8") + except Exception: + pass diff --git a/paperforge/plugin/main.js b/paperforge/plugin/main.js index a69876e6..4b0ab879 100644 --- a/paperforge/plugin/main.js +++ b/paperforge/plugin/main.js @@ -52,6 +52,7 @@ const memoryState = (() => { 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'), @@ -178,6 +179,33 @@ const memoryState = (() => { }; })(); +/* ── 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) { @@ -2326,16 +2354,6 @@ class PaperForgeStatusView extends ItemView { /* ── Run Action ── */ _runAction(a, card) { - // DASH-03: OCR privacy warning — once per session - if (a.id === 'paperforge-ocr' && !this._ocrPrivacyShown) { - const modal = new PaperForgeOcrPrivacyModal(this.app, () => { - this._ocrPrivacyShown = true; - this._runAction(a, card); // Re-trigger after acknowledgment - }); - modal.open(); - return; - } - // Guard: disabled actions show coming-soon notice if (a.disabled) { new Notice(`[i] ${a.disabledMsg || 'This action is not yet available.'}`, 6000); @@ -2453,7 +2471,9 @@ class PaperForgeStatusView extends ItemView { new Notice('[OK] ' + a.okMsg); if (this._contentEl) this._contentEl.removeClass('switching'); this._cachedStats = null; - this._fetchStats(); + 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) => { @@ -2982,6 +3002,7 @@ class PaperForgeSettingTab extends PluginSettingTab { } this.display(); this._refreshSnapshots(vp); + checkOrphanState(this.app, this.plugin, vp); } }); } @@ -3854,6 +3875,102 @@ class PaperForgeOcrPrivacyModal extends Modal { } } +/* ── 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('Delete ' + sel.length + ' selected'); + this._selectAllBtn.setText(sel.length === this.orphans.length ? 'Deselect all' : '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: 'Found ' + this.orphans.length + ' orphan paper(s)' }); + contentEl.createEl('p', { + cls: 'paperforge-modal-desc', + text: 'These papers are no longer in your Zotero library. Click a row to toggle whether to delete it.' + }); + + 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: 'Removed from Zotero. ' + (o.has_pdf ? 'Had a PDF synced.' : 'No PDF was attached.') + ' Workspace files remain on disk.' }); + + 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('No papers selected for deletion'); 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 ========================================================================== */ diff --git a/paperforge/plugin/styles.css b/paperforge/plugin/styles.css index 95d1a09f..e4abc643 100644 --- a/paperforge/plugin/styles.css +++ b/paperforge/plugin/styles.css @@ -2472,3 +2472,120 @@ color: var(--text-faint); font-family: var(--font-monospace); } + +/* ── Orphan Modal ── */ +.paperforge-modal-desc { + font-size: 13px; + color: var(--text-muted); + margin: 0 0 16px; +} + +.paperforge-orphan-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 20px; + max-height: 400px; + overflow-y: auto; +} + +.paperforge-orphan-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 10px 12px; + background: var(--background-secondary); + border-radius: 6px; + gap: 12px; + cursor: pointer; + transition: opacity 0.15s; +} +.paperforge-orphan-row:hover { + background: var(--background-modifier-hover); +} +.paperforge-orphan-dimmed { + opacity: 0.45; +} +.paperforge-orphan-dimmed:hover { + opacity: 0.6; +} + +.paperforge-orphan-info { + flex: 1; + min-width: 0; +} + +.paperforge-orphan-key { + font-size: 12px; + font-weight: 600; + font-family: var(--font-monospace); + color: var(--text-normal); +} + +.paperforge-orphan-header { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.paperforge-orphan-explain { + font-size: 10px; + color: var(--text-faint); + margin-top: 4px; +} + +.paperforge-orphan-title { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.paperforge-orphan-meta { + font-size: 11px; + color: var(--text-faint); + margin-top: 4px; +} + +.paperforge-orphan-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + flex-shrink: 0; + align-items: flex-start; + max-width: 160px; +} + +.paperforge-tag { + font-size: 10px; + padding: 1px 6px; + border-radius: 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.tag-pdf { + background: rgba(76, 175, 80, 0.15); + color: #4caf50; +} + +.tag-nopdf { + background: var(--background-modifier-border); + color: var(--text-muted); +} + +.tag-collection { + background: rgba(33, 150, 243, 0.12); + color: #2196f3; +} + +.paperforge-modal-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} diff --git a/paperforge/services/sync_service.py b/paperforge/services/sync_service.py index f3d41f05..71f7e5de 100644 --- a/paperforge/services/sync_service.py +++ b/paperforge/services/sync_service.py @@ -200,7 +200,7 @@ class SyncService: def run( self, verbose: bool = False, json_output: bool = False, selection_only: bool = False, index_only: bool = False, - prune: bool = False, prune_force: bool = False + prune: bool = False, prune_force: bool = False, ) -> PFResult: """Full sync orchestration. Returns PFResult contract. @@ -290,16 +290,16 @@ class SyncService: index_count = asset_index.build_index(self.vault, verbose) # ── Phase 4: Prune orphans ── - prune_result = None + prune_data = self.prune(paths, fresh_index=None, dry_run=True) + if prune_force: + prune_data = self.prune(paths, fresh_index=None, dry_run=False) if prune or prune_force: - dry_run_prune = not prune_force - prune_result = self.prune(paths, fresh_index=None, dry_run=dry_run_prune) if not json_output: - pdata = prune_result or {} + pdata = prune_data or {} preview = pdata.get("preview", []) if preview: print(f"prune: found {len(preview)} orphan paper(s)") - if dry_run_prune: + if not prune_force: print("prune: dry-run (pass --prune-force to delete)") else: counts = pdata.get("counts", {}) @@ -309,6 +309,8 @@ class SyncService: print(msg) else: print("prune: no orphans found") + # always include prune preview in result (for plugin consumption) + _prune_preview = prune_data.get("preview", []) if prune_data else [] if not json_output: print(f"index-refresh: {index_count} entries in index") @@ -317,6 +319,9 @@ class SyncService: is_ok = selection_result.get("failed", 0) == 0 and not selection_result.get("errors") + if not selection_only: + _prune_preview = locals().get("_prune_preview", []) + result = PFResult( ok=is_ok, command="sync", @@ -324,7 +329,7 @@ class SyncService: data={ "selection": selection_result, "index": {"updated": index_count, "orphaned_cleaned": orphaned, "flat_cleaned": flat_cleaned}, - "prune": prune_result, + "prune": {"preview": _prune_preview} if _prune_preview else None, }, ) diff --git a/paperforge/worker/prune.py b/paperforge/worker/prune.py index 38281a1a..8e9848b6 100644 --- a/paperforge/worker/prune.py +++ b/paperforge/worker/prune.py @@ -52,11 +52,52 @@ def _resolve_ocr_dir(vault: Path, key: str) -> Path: return ocr_root / key +def _enrich_orphan_preview(vault: Path, candidates: list[dict]) -> list[dict]: + from paperforge.adapters.obsidian_frontmatter import read_frontmatter_dict + + enriched = [] + for c in candidates: + key = c["key"] + ws = c["workspace_dir"] + + title_from_dir = ws.name.split(" - ", 1)[1] if " - " in ws.name else key + + note_path = ws / f"{key}.md" + fm = {} + if note_path.exists(): + try: + fm = read_frontmatter_dict(note_path.read_text(encoding="utf-8")) + except Exception: + pass + + title = fm.get("title", title_from_dir) or title_from_dir + first_author = fm.get("first_author", "") or "" + coll = fm.get("collection_path", "") + if isinstance(coll, list): + coll = " | ".join(coll) + + enriched.append({ + "key": key, + "citation_key": fm.get("citation_key") or key, + "title": title, + "year": str(fm.get("year", "")), + "authors": first_author, + "has_pdf": fm.get("has_pdf", False) in (True, "true"), + "collection_path": coll, + "domain": c["domain"], + "workspace": str(ws), + "ocr_dir": str(c["ocr_dir"]) if c["ocr_dir"] and c["ocr_dir"].exists() else None, + }) + + return enriched + + def prune_orphan_papers( vault: Path, *, fresh_index: dict, dry_run: bool = True, + enrich: bool = True, _candidates: list[dict] | None = None, ) -> dict: cfg = paperforge_paths(vault) @@ -76,15 +117,18 @@ def prune_orphan_papers( for c in candidates: c["ocr_dir"] = _resolve_ocr_dir(vault, c["key"]) - preview = [ - { - "key": c["key"], - "domain": c["domain"], - "workspace": str(c["workspace_dir"]), - "ocr_dir": str(c["ocr_dir"]) if c["ocr_dir"].exists() else None, - } - for c in candidates - ] + if enrich: + preview = _enrich_orphan_preview(vault, candidates) + else: + preview = [ + { + "key": c["key"], + "domain": c["domain"], + "workspace": str(c["workspace_dir"]), + "ocr_dir": str(c["ocr_dir"]) if c["ocr_dir"].exists() else None, + } + for c in candidates + ] if dry_run: return {"preview": preview, "deleted": [], "counts": {}} diff --git a/tests/unit/services/test_sync_service_prune.py b/tests/unit/services/test_sync_service_prune.py index ddacd939..83d13c8b 100644 --- a/tests/unit/services/test_sync_service_prune.py +++ b/tests/unit/services/test_sync_service_prune.py @@ -60,7 +60,7 @@ class TestSyncServiceRunPrune: (vault / "Resources" / "Literature").mkdir(parents=True) return SyncService(vault) - def test_run_with_prune_calls_prune_method(self, tmp_path: Path) -> None: + def test_run_with_prune_force_calls_prune_twice(self, tmp_path: Path) -> None: svc = self._make_svc(tmp_path) svc.paths = { "literature": tmp_path / "Resources" / "Literature", @@ -82,9 +82,12 @@ class TestSyncServiceRunPrune: stack.enter_context(patch("paperforge.worker.asset_index.read_index", return_value={"schema_version": "3", "items": []})) svc.run(prune=True, prune_force=True) - mock_prune.assert_called_once() + # prune always runs dry-run; prune_force triggers a second call for actual delete + assert mock_prune.call_count == 2 + assert mock_prune.call_args_list[0][1]["dry_run"] is True + assert mock_prune.call_args_list[1][1]["dry_run"] is False - def test_run_without_prune_does_not_call_prune(self, tmp_path: Path) -> None: + def test_run_without_prune_calls_dry_run_only(self, tmp_path: Path) -> None: svc = self._make_svc(tmp_path) svc.paths = { "literature": tmp_path / "Resources" / "Literature", @@ -94,6 +97,7 @@ class TestSyncServiceRunPrune: } with patch.object(svc, "prune") as mock_prune: + mock_prune.return_value = {"preview": [], "deleted": [], "counts": {}} with ExitStack() as stack: stack.enter_context(patch.object(svc, "resolve_paths", return_value=svc.paths)) stack.enter_context(patch("paperforge.worker.sync.load_export_rows", return_value=[])) @@ -105,4 +109,6 @@ class TestSyncServiceRunPrune: stack.enter_context(patch("paperforge.worker.asset_index.read_index", return_value={"schema_version": "3", "items": []})) svc.run() - mock_prune.assert_not_called() + # prune always runs once (dry-run) to collect orphan data for the result + mock_prune.assert_called_once() + assert mock_prune.call_args[1]["dry_run"] is True