fix: harden repair paths, slugify edge case, OCR meta resilience, venv python detection, and repair exit handling

This commit is contained in:
Research Assistant 2026-05-06 22:03:27 +08:00
parent 4fe55e11dc
commit b2e446eb21
10 changed files with 115 additions and 18 deletions

View file

@ -32,11 +32,15 @@ def run(args: argparse.Namespace) -> int:
vault = getattr(args, "vault_path", None)
paths = getattr(args, "paths", None)
if vault is None:
from paperforge.config import load_vault_config, paperforge_paths, resolve_vault
from paperforge.config import resolve_vault
from paperforge.worker._utils import pipeline_paths
vault = resolve_vault(cli_vault=getattr(args, "vault", None))
cfg = load_vault_config(vault)
paths = paperforge_paths(vault, cfg)
paths = pipeline_paths(vault)
elif paths is None or "config" not in paths:
from paperforge.worker._utils import pipeline_paths
paths = pipeline_paths(vault)
run_repair = _get_run_repair()
result = run_repair(

View file

@ -1,5 +1,5 @@
const { Plugin, Notice, ItemView, Modal, Setting, PluginSettingTab, addIcon } = require('obsidian');
const { exec } = require('node:child_process');
const { exec, execFile } = require('node:child_process');
const fs = require('fs');
const path = require('path');
@ -185,6 +185,7 @@ const ACTIONS = [
desc: 'Fix three-way state divergence, path errors, and rebuild index',
icon: '\u21BA', // ↺
cmd: 'repair',
args: ['--fix', '--fix-paths'],
okMsg: 'Repair complete',
},
{
@ -207,6 +208,20 @@ const ACTIONS = [
},
];
function resolvePythonExecutable(vaultPath) {
const candidates = [
path.join(vaultPath, '.paperforge-test-venv', 'Scripts', 'python.exe'),
path.join(vaultPath, '.venv', 'Scripts', 'python.exe'),
path.join(vaultPath, 'venv', 'Scripts', 'python.exe'),
];
for (const candidate of candidates) {
try {
if (fs.existsSync(candidate)) return candidate;
} catch {}
}
return 'python';
}
class PaperForgeStatusView extends ItemView {
constructor(leaf) {
super(leaf);
@ -375,7 +390,8 @@ class PaperForgeStatusView extends ItemView {
if (!quiet && !this._cachedStats) {
this._metricsEl.createEl('div', { cls: 'paperforge-status-loading', text: 'No index \u2014 trying CLI...' });
}
exec('python -m paperforge status --json', { cwd: vp, timeout: 30000 }, (err2, stdout) => {
const pythonExe = resolvePythonExecutable(vp);
execFile(pythonExe, ['-m', 'paperforge', 'status', '--json'], { cwd: vp, timeout: 30000 }, (err2, stdout) => {
if (err2) {
if (this._cachedStats) return;
this._metricsEl.createEl('div', { cls: 'paperforge-status-error', text: 'Cannot reach PaperForge CLI.\nMake sure paperforge is installed and in your PATH.' });
@ -1262,7 +1278,7 @@ class PaperForgeStatusView extends ItemView {
this._showMessage('Processing...', 'running');
// Resolve extra arguments based on action flags
let extraArgs = [];
let extraArgs = Array.isArray(a.args) ? [...a.args] : [];
if (a.needsKey) {
// Resolve zotero_key from active file frontmatter
@ -1289,17 +1305,18 @@ class PaperForgeStatusView extends ItemView {
card.removeClass('running');
return;
}
extraArgs = [key];
extraArgs = [...extraArgs, key];
}
if (a.needsFilter) {
// Default to --all for the initial implementation
extraArgs = ['--all'];
extraArgs = [...extraArgs, '--all'];
}
const { spawn } = require('node:child_process');
const cmdTimeout = a.needsFilter ? 60000 : (a.needsKey ? 30000 : 600000);
const child = spawn('python', ['-m', 'paperforge', a.cmd, ...extraArgs], { cwd: vp, timeout: cmdTimeout });
const pythonExe = resolvePythonExecutable(vp);
const child = spawn(pythonExe, ['-m', 'paperforge', a.cmd, ...extraArgs], { cwd: vp, timeout: cmdTimeout });
const log = [];
const startTime = Date.now();
const pollTimer = setInterval(() => this._fetchStats(true), 4000);
@ -1324,8 +1341,14 @@ class PaperForgeStatusView extends ItemView {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (code !== 0) {
const last = log.slice(-3).join(' | ') || 'exit code ' + code;
this._showMessage('[!!] ' + last, 'error');
new Notice('[!!] ' + a.cmd + ' failed: ' + last, 8000);
if (a.cmd === 'repair' && code === 1) {
this._showMessage('[WARN] ' + last, 'running');
new Notice('[WARN] Repair completed with remaining issues: ' + last, 8000);
this._fetchStats(true);
} else {
this._showMessage('[!!] ' + last, 'error');
new Notice('[!!] ' + a.cmd + ' failed: ' + last, 8000);
}
} else if (a.needsKey || a.needsFilter) {
// Context actions: copy JSON output to clipboard
const output = log.join('\n');

View file

@ -1162,7 +1162,7 @@
}
.paperforge-paper-year::before {
content: " \u00B7 ";
content: " · ";
}
.paperforge-paper-actions {

View file

@ -124,7 +124,8 @@ def yaml_list(key: str, values) -> list[str]:
def slugify_filename(text: str) -> str:
cleaned = re.sub('[<>:"/\\\\|?*]+', "", text).strip()
return cleaned[:120] or "untitled"
cleaned = cleaned[:120].rstrip(" .")
return cleaned or "untitled"
def _extract_year(value: str) -> str:

View file

@ -65,6 +65,13 @@ def ensure_ocr_meta(vault: Path, row: dict) -> dict:
return meta
def _read_meta_or_empty(meta_path: Path) -> dict:
try:
return read_json(meta_path) if meta_path.exists() else {}
except Exception:
return {}
def validate_ocr_meta(paths: dict[str, Path], meta: dict) -> tuple[str, str]:
status = str(meta.get("ocr_status", "pending") or "pending").strip().lower()
if status != "done":
@ -161,7 +168,7 @@ def sync_ocr_queue(paths: dict[str, Path], target_rows: list[dict]) -> list[dict
if not target.get("has_pdf"):
continue
meta_path = paths["ocr"] / key / "meta.json"
meta = read_json(meta_path) if meta_path.exists() else {}
meta = _read_meta_or_empty(meta_path)
status = str(meta.get("ocr_status", "pending") or "pending").strip().lower()
if status in {"done", "blocked"}:
continue
@ -181,7 +188,7 @@ def sync_ocr_queue(paths: dict[str, Path], target_rows: list[dict]) -> list[dict
if not row.get("has_pdf"):
continue
meta_path = paths["ocr"] / key / "meta.json"
meta = read_json(meta_path) if meta_path.exists() else {}
meta = _read_meta_or_empty(meta_path)
status = str(meta.get("ocr_status", "pending") or "pending").strip().lower()
if status in {"done", "blocked"}:
continue

View file

@ -618,8 +618,8 @@ def has_deep_reading_content(text: str) -> bool:
def library_record_markdown(row: dict) -> str:
lines = [
"---",
f"zotero_key: {row.get('zotero_key', '')}",
f"domain: {row.get('domain', '')}",
f"zotero_key: {yaml_quote(row.get('zotero_key', ''))}",
f"domain: {yaml_quote(row.get('domain', ''))}",
f"title: {yaml_quote(row.get('title', ''))}",
f"year: {row.get('year', '')}",
f"doi: {yaml_quote(row.get('doi', ''))}",

View file

@ -18,7 +18,7 @@ import pytest
from paperforge.config import paperforge_paths
from paperforge.worker._utils import scan_library_records
from paperforge.worker.sync import run_index_refresh, run_selection_sync
from paperforge.worker.sync import run_index_refresh, run_selection_sync, update_frontmatter_field
class TestSelectionSyncProducesLibraryRecords:
@ -164,9 +164,16 @@ class TestIndexRefreshProducesFormalNotes:
class TestOcrQueueStates:
"""E2E: OCR queue and state transitions are correct after pipeline run."""
def _set_analyze_true(self, test_vault: Path) -> None:
paths = paperforge_paths(test_vault)
record_path = paths["library_records"] / "骨科" / "TSTONE001.md"
text = record_path.read_text(encoding="utf-8")
record_path.write_text(update_frontmatter_field(text, "analyze", True), encoding="utf-8")
def test_scan_library_records_returns_tstone001(self, test_vault: Path) -> None:
"""Verify TSTONE001 appears in library records scan with analyze=true."""
run_selection_sync(test_vault)
self._set_analyze_true(test_vault)
records = scan_library_records(test_vault)
tstone = [r for r in records if r["zotero_key"] == "TSTONE001"]
assert len(tstone) >= 1, "TSTONE001 should be in library records scan"
@ -176,6 +183,7 @@ class TestOcrQueueStates:
def test_tstone001_ocr_status_done(self, test_vault: Path) -> None:
"""Verify TSTONE001 has OCR status 'done' (fixture has meta.json with ocr_status: done)."""
run_selection_sync(test_vault)
self._set_analyze_true(test_vault)
records = scan_library_records(test_vault)
tstone = [r for r in records if r["zotero_key"] == "TSTONE001"]
assert len(tstone) >= 1
@ -210,6 +218,7 @@ class TestOcrQueueStates:
)
# Stage 4: OCR state is consistent
self._set_analyze_true(test_vault)
records = scan_library_records(test_vault)
tstone = [r for r in records if r["zotero_key"] == "TSTONE001"]
assert len(tstone) >= 1

View file

@ -11,6 +11,7 @@ from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import requests
def _make_vault(tmp_path: Path) -> tuple[Path, Path, Path, Path]:

View file

@ -2,8 +2,10 @@
from __future__ import annotations
import argparse
import json
import re
from unittest.mock import patch
from paperforge.worker.repair import run_repair
from paperforge.worker.sync import pipeline_paths
@ -438,3 +440,43 @@ class TestRunRepairReturnStructure:
assert "formal_note_ocr_status" in item
assert "meta_ocr_status" in item
assert "reason" in item
class TestRepairCommandPaths:
def test_repair_command_uses_pipeline_paths_with_config(self, tmp_path):
vault = _make_vault(tmp_path)
captured = {}
def _fake_run_repair(vault_arg, paths_arg, verbose=False, fix=False, fix_paths=False):
captured["vault"] = vault_arg
captured["paths"] = paths_arg
return {"scanned": 0, "divergent": [], "fixed": 0, "errors": [], "path_errors": {"total": 0}, "rebuilt": 0}
from paperforge.commands import repair as repair_cmd
args = argparse.Namespace(vault_path=None, paths=None, vault=str(vault), verbose=False, fix=False, fix_paths=False)
with patch.object(repair_cmd, "_get_run_repair", return_value=_fake_run_repair):
code = repair_cmd.run(args)
assert code == 0
assert captured["vault"] == vault
assert "config" in captured["paths"]
def test_repair_command_upgrades_legacy_paths_arg(self, tmp_path):
vault = _make_vault(tmp_path)
captured = {}
def _fake_run_repair(vault_arg, paths_arg, verbose=False, fix=False, fix_paths=False):
captured["paths"] = paths_arg
return {"scanned": 0, "divergent": [], "fixed": 0, "errors": [], "path_errors": {"total": 0}, "rebuilt": 0}
from paperforge.commands import repair as repair_cmd
from paperforge.config import paperforge_paths
legacy_paths = paperforge_paths(vault)
args = argparse.Namespace(vault_path=vault, paths=legacy_paths, vault=str(vault), verbose=False, fix=False, fix_paths=False)
with patch.object(repair_cmd, "_get_run_repair", return_value=_fake_run_repair):
code = repair_cmd.run(args)
assert code == 0
assert "config" in captured["paths"]

View file

@ -53,6 +53,16 @@ class TestSlugifyFilename:
def test_leading_trailing_spaces(self) -> None:
assert slugify_filename(" hello ") == "hello"
def test_truncation_does_not_leave_trailing_space(self) -> None:
title = (
"Dynamics and Crosstalk between Gut Microbiota, Metabolome, and Fecal "
"Calprotectin in Very Preterm Infants: Insights into Feeding Intolerance"
)
assert slugify_filename(title) == (
"Dynamics and Crosstalk between Gut Microbiota, Metabolome, and Fecal "
"Calprotectin in Very Preterm Infants Insights into"
)
class TestExtractYear:
"""_extract_year(value: str) -> str"""