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
This commit is contained in:
Research Assistant 2026-05-19 00:31:19 +08:00
parent a407520961
commit 798e048a8d
8 changed files with 389 additions and 52 deletions

View file

@ -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")

View file

@ -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 = []

View file

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

View file

@ -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
========================================================================== */

View file

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

View file

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

View file

@ -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": {}}

View file

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