---
phase: 02-paddleocr-and-pdf-path-hardening
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - paperforge/pdf_resolver.py
  - tests/test_pdf_resolver.py
  - tests/test_ocr_preflight.py
  - pipeline/worker/scripts/literature_pipeline.py
autonomous: true
requirements:
  - OCR-02
  - ZOT-01
  - ZOT-02

must_haves:
  truths:
    - "OCR worker checks PDF exists and resolves paths before submitting to API"
    - "Missing PDF attachments result in ocr_status: nopdf instead of failed/blocked"
    - "Junction and relative paths are resolved to actual file paths"
  artifacts:
    - path: "paperforge/pdf_resolver.py"
      provides: "PDF path resolution with junction support"
      exports: ["resolve_pdf_path", "resolve_junction", "is_valid_pdf"]
    - path: "tests/test_pdf_resolver.py"
      provides: "Unit tests for all path resolution cases"
    - path: "tests/test_ocr_preflight.py"
      provides: "Integration tests for OCR preflight in run_ocr"
    - path: "pipeline/worker/scripts/literature_pipeline.py"
      provides: "Modified run_ocr with preflight checks"
      contains: "resolve_pdf_path"
  key_links:
    - from: "paperforge/pdf_resolver.py"
      to: "pipeline/worker/scripts/literature_pipeline.py"
      via: "import and call in run_ocr()"
      pattern: "resolve_pdf_path.*queue_row"
---

<objective>
Implement PDF path resolution with junction support and wire preflight checks into the OCR worker.

Purpose: Eliminate silent OCR failures caused by missing or unresolved PDF paths.
Output: pdf_resolver.py module + tests + preflight integration in run_ocr().
</objective>

<execution_context>
@C:/Users/Lin/.opencode/get-shit-done/workflows/execute-plan.md
@C:/Users/Lin/.opencode/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/phases/02-paddleocr-and-pdf-path-hardening/CONTEXT.md
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/STATE.md

<interfaces>
<!-- Key types and contracts the executor needs. -->

From paperforge/config.py:
```python
def paperforge_paths(vault: Path, cfg: dict[str, str] | None = None) -> dict[str, Path]:
    """Returns dict with keys: vault, system, paperforge, exports, ocr, resources,
    literature, control, library_records, bases, worker_script, skill_dir, ld_deep_script"""
```

From pipeline/worker/scripts/literature_pipeline.py (existing run_ocr flow):
```python
def run_ocr(vault: Path) -> int:
    # Lines 2294-2429
    # Current PDF check at lines 2390-2396:
    #   if not queue_row.get('has_pdf'):
    #       meta['ocr_status'] = 'blocked'
    #       meta['error'] = 'PDF not found in Zotero attachments'
    # Current file open at line 2405:
    #   with open(queue_row['pdf_path'], 'rb') as file_handle:
```

From pipeline/worker/scripts/literature_pipeline.py (existing path utils):
```python
def absolutize_vault_path(vault: Path, path: str) -> str:
    """Converts relative paths to absolute under vault."""

def obsidian_wikilink_for_pdf(vault: Path, path: str) -> str:
    """Returns Obsidian wikilink for PDF path."""
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Implement pdf_resolver.py with junction support</name>
  <files>paperforge/pdf_resolver.py, tests/test_pdf_resolver.py</files>
  <behavior>
    - Test: resolve_pdf_path with absolute existing path → returns that path
    - Test: resolve_pdf_path with vault-relative existing path → returns resolved absolute path
    - Test: resolve_pdf_path with junction path (mocked) → returns resolved target path
    - Test: resolve_pdf_path with zotero storage-relative path → returns resolved absolute path
    - Test: resolve_pdf_path with missing file → returns ""
    - Test: resolve_pdf_path with has_pdf=False → returns ""
    - Test: is_valid_pdf with existing non-empty file → True
    - Test: is_valid_pdf with missing file → False
  </behavior>
  <action>
    Create `paperforge/pdf_resolver.py` with these exact functions:

    ```python
    """PDF path resolution for Zotero attachments.

    Supports absolute, vault-relative, junction/symlink, and Zotero storage-relative paths.
    """
    from __future__ import annotations

    import logging
    import os
    from pathlib import Path

    logger = logging.getLogger(__name__)

    def resolve_pdf_path(
        pdf_path: str,
        has_pdf: bool,
        vault_root: Path,
        zotero_dir: Path | None = None,
    ) -> str:
        """Resolve a Zotero PDF path to an absolute, readable file path.

        Args:
            pdf_path: Raw path from Zotero attachment (may be absolute, relative, junction, or storage-relative).
            has_pdf: Whether Zotero reports a PDF attachment exists.
            vault_root: Absolute path to the Obsidian vault root.
            zotero_dir: Optional absolute path to the Zotero data directory for storage-relative resolution.

        Returns:
            Absolute path string if the PDF is readable, empty string otherwise.
        """
        if not has_pdf or not pdf_path:
            return ""

        raw = pdf_path.strip()
        if not raw:
            return ""

        # Try absolute first
        candidate = Path(raw)
        if candidate.is_absolute():
            resolved = resolve_junction(candidate)
            if is_valid_pdf(resolved):
                return str(resolved)
            return ""

        # Try vault-relative
        vault_candidate = (vault_root / raw.replace("/", os.sep)).resolve()
        if is_valid_pdf(vault_candidate):
            return str(vault_candidate)

        # Try junction resolution on vault-relative
        vault_resolved = resolve_junction(vault_candidate)
        if is_valid_pdf(vault_resolved):
            return str(vault_resolved)

        # Try Zotero storage-relative (format: "storage:XXXX/item.pdf")
        if raw.startswith("storage:") and zotero_dir is not None:
            storage_rel = raw[len("storage:"):].lstrip("/")
            storage_candidate = (zotero_dir / storage_rel.replace("/", os.sep)).resolve()
            if is_valid_pdf(storage_candidate):
                return str(storage_candidate)

        logger.error(f"PDF path could not be resolved: {raw}")
        return ""

    def resolve_junction(path: Path) -> Path:
        """Resolve Windows junctions and symlinks to their target.

        Uses os.path.realpath as primary resolver. Falls back to ctypes-based
        reparse point resolution on Windows if realpath does not follow the junction.
        """
        if not path.exists():
            return path

        resolved = Path(os.path.realpath(path))
        if resolved != path and resolved.exists():
            return resolved

        # Windows-specific: resolve directory junction reparse points
        if os.name == "nt" and path.is_dir():
            try:
                import ctypes
                from ctypes import wintypes

                kernel32 = ctypes.windll.kernel32
                buf = ctypes.create_unicode_buffer(1024)
                handle = kernel32.CreateFileW(
                    str(path), 0, 0, None, 3, 0x02000000, None
                )
                if handle != -1:
                    try:
                        res = kernel32.GetFinalPathNameByHandleW(handle, buf, 1024, 0)
                        if res > 0:
                            target = buf[:res]
                            if target.startswith("\\\\?\\"):
                                target = target[4:]
                            target_path = Path(target)
                            if target_path.exists():
                                return target_path
                    finally:
                        kernel32.CloseHandle(handle)
            except Exception:
                pass

        return path

    def is_valid_pdf(path: Path) -> bool:
        """Check if path points to an existing, non-empty, readable file."""
        try:
            return path.is_file() and path.stat().st_size > 0
        except (OSError, PermissionError):
            return False
    ```

    Create `tests/test_pdf_resolver.py` with pytest tests covering all behavior cases listed above. Use `tmp_path` fixtures for filesystem setup and `monkeypatch` for junction mocking on non-Windows platforms.
  </action>
  <verify>
    <automated>pytest tests/test_pdf_resolver.py -v</automated>
  </verify>
  <done>All 8 test cases pass; paperforge/pdf_resolver.py exists with the 3 exported functions.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Wire PDF preflight into run_ocr()</name>
  <files>pipeline/worker/scripts/literature_pipeline.py, tests/test_ocr_preflight.py</files>
  <behavior>
    - Test: run_ocr with has_pdf=False → sets meta['ocr_status'] = 'nopdf', does not call API
    - Test: run_ocr with has_pdf=True but missing file → sets meta['ocr_status'] = 'nopdf', does not call API
    - Test: run_ocr with valid resolved PDF → proceeds to API submission with resolved path
    - Test: run_ocr with junction path → resolves junction before open() call
  </behavior>
  <action>
    Modify `pipeline/worker/scripts/literature_pipeline.py`:

    1. Add import near the top of the file (after existing paperforge imports):
       ```python
       from paperforge.pdf_resolver import resolve_pdf_path
       ```

    2. In `run_ocr()`, replace the existing `if not queue_row.get('has_pdf'):` block (lines 2390-2396) with:
       ```python
       resolved_pdf = resolve_pdf_path(
           queue_row.get('pdf_path', ''),
           queue_row.get('has_pdf', False),
           vault,
           paths.get('zotero_dir') if 'zotero_dir' in paths else None,
       )
       if not resolved_pdf:
           meta['ocr_status'] = 'nopdf'
           meta['error'] = 'PDF not found or not readable'
           queue_row['queue_status'] = 'nopdf'
           write_json(paths['ocr'] / key / 'meta.json', meta)
           changed += 1
           continue
       ```

    3. Update the file open call at line ~2405 from:
       ```python
       with open(queue_row['pdf_path'], 'rb') as file_handle:
       ```
       to:
       ```python
       with open(resolved_pdf, 'rb') as file_handle:
       ```

    4. Create `tests/test_ocr_preflight.py` with mocked run_ocr tests:
       - Mock `pipeline_paths`, `load_control_actions`, `sync_ocr_queue`, `ensure_ocr_meta`, `write_json`
       - Verify that when `has_pdf=False`, the function writes `ocr_status: nopdf` and skips API call
       - Verify that when `has_pdf=True` but `resolve_pdf_path` returns "", writes `ocr_status: nopdf`
       - Verify that when `resolve_pdf_path` returns a valid path, the `open()` call receives that resolved path
       - Use `unittest.mock.patch` for `resolve_pdf_path` to control return values
  </action>
  <verify>
    <automated>pytest tests/test_ocr_preflight.py -v</automated>
  </verify>
  <done>run_ocr() calls resolve_pdf_path before API submission; missing PDFs produce ocr_status: nopdf; all 4 preflight tests pass.</done>
</task>

</tasks>

<verification>
- `grep -n "from paperforge.pdf_resolver import resolve_pdf_path" pipeline/worker/scripts/literature_pipeline.py` returns a match.
- `grep -n "ocr_status.*=.*'nopdf'" pipeline/worker/scripts/literature_pipeline.py` returns at least 2 matches (one in run_ocr preflight, one from existing or new logic).
- `pytest tests/test_pdf_resolver.py -v` exits 0 with all tests passed.
- `pytest tests/test_ocr_preflight.py -v` exits 0 with all tests passed.
</verification>

<success_criteria>
- pdf_resolver.py exists with resolve_pdf_path, resolve_junction, is_valid_pdf exported.
- run_ocr() preflights PDFs via resolve_pdf_path and sets ocr_status: nopdf for missing/unreadable PDFs.
- All new tests pass (test_pdf_resolver.py, test_ocr_preflight.py).
</success_criteria>

<output>
After completion, create `.planning/phases/02-paddleocr-and-pdf-path-hardening/02-01-SUMMARY.md`
</output>
