mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
fix: harden repair paths, slugify edge case, OCR meta resilience, venv python detection, and repair exit handling
This commit is contained in:
parent
4fe55e11dc
commit
b2e446eb21
10 changed files with 115 additions and 18 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -1162,7 +1162,7 @@
|
|||
}
|
||||
|
||||
.paperforge-paper-year::before {
|
||||
content: " \u00B7 ";
|
||||
content: " · ";
|
||||
}
|
||||
|
||||
.paperforge-paper-actions {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', ''))}",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
Loading…
Reference in a new issue