mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
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:
parent
a407520961
commit
798e048a8d
8 changed files with 389 additions and 52 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
========================================================================== */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {}}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue