---
phase: 58-service-extraction
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - paperforge/adapters/__init__.py
  - paperforge/adapters/zotero_paths.py
  - tests/unit/adapters/__init__.py
  - tests/unit/adapters/test_zotero_paths.py
  - paperforge/worker/sync.py
autonomous: true
requirements: [SYNC-02, SYNC-05]

must_haves:
  truths:
    - "paperforge/adapters/zotero_paths.py can be imported independently of sync.py"
    - "obsidian_wikilink_for_pdf() produces correct wikilinks for storage: paths, absolute paths, CJK filenames, and paths with spaces"
    - "absolutize_vault_path() resolves vault-relative and absolute paths correctly"
    - "obsidian_wikilink_for_path() handles vault-internal and vault-external paths"
    - "Existing sync.py still exports all path functions for backward compatibility"
    - "All existing path normalization tests pass against the extracted module"
  artifacts:
    - path: paperforge/adapters/zotero_paths.py
      provides: "Path resolution functions extracted from sync.py"
      exports: ["obsidian_wikilink_for_pdf", "absolutize_vault_path", "obsidian_wikilink_for_path", "resolve_junction"]
      min_lines: 60
    - path: tests/unit/adapters/test_zotero_paths.py
      provides: "Unit tests for path resolution covering all edge cases"
      min_lines: 120
    - path: paperforge/adapters/__init__.py
      provides: "Package marker for adapters module"
    - path: tests/unit/adapters/__init__.py
      provides: "Package marker for adapter test module"
  key_links:
    - from: paperforge/adapters/zotero_paths.py
      to: paperforge/pdf_resolver.py
      via: "import resolve_junction"
      pattern: "from paperforge.pdf_resolver import"
    - from: paperforge/worker/sync.py
      to: paperforge/adapters/zotero_paths.py
      via: "import alias for backward compat"
      pattern: "from paperforge.adapters.zotero_paths import"
---
<objective>
Extract path resolution functions from sync.py into a focused `adapters/zotero_paths.py` module with independent unit tests.

**Purpose:** Monolithic sync.py (1844 lines) contains path resolution logic tangled with BBT parsing, frontmatter ops, and orchestration. Isolating the 3 path functions into their own module makes them independently testable and importable without pulling in the entire sync module.

**Output:** Four files — package init + adapter + tests + test package init. sync.py retains backward-compatible import aliases.

**Extracted functions:**
- `obsidian_wikilink_for_pdf(pdf_path, vault_dir, zotero_dir)` — converts storage:/absolute paths to `[[wikilink]]` format
- `absolutize_vault_path(vault, path, resolve_junction)` — normalizes vault-relative or absolute paths
- `obsidian_wikilink_for_path(vault, path)` — simpler wikilink from any path
</objective>

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

<context>
@file:///D:/L/Med/Research/99_System/LiteraturePipeline/github-release/paperforge/worker/sync.py
@file:///D:/L/Med/Research/99_System/LiteraturePipeline/github-release/paperforge/pdf_resolver.py
@file:///D:/L/Med/Research/99_System/LiteraturePipeline/github-release/tests/test_path_normalization.py

<interfaces>
Key types used by the extracted functions:

From paperforge/pdf_resolver.py (already exists — import unchanged):
```python
def resolve_junction(path: Path) -> Path: ...
```

File ownership note: `resolve_junction` stays in pdf_resolver.py — do NOT duplicate it. Import and use from there.
</interfaces>
</context>

<tasks>

<task type="auto" tdd="false">
  <name>Task 1: Create package structure + adapters/zotero_paths.py</name>
  <files>
    paperforge/adapters/__init__.py
    paperforge/adapters/zotero_paths.py
  </files>
  <action>
    Step 1.1: Create paperforge/adapters/__init__.py as a minimal package init. Re-export the 3 main path functions for convenience imports:
    ```python
    """PaperForge adapter modules — extracted single-responsibility interfaces."""
    from paperforge.adapters.zotero_paths import (
        absolutize_vault_path,
        obsidian_wikilink_for_pdf,
        obsidian_wikilink_for_path,
    )

    __all__ = [
        "absolutize_vault_path",
        "obsidian_wikilink_for_pdf",
        "obsidian_wikilink_for_path",
    ]
    ```

    Step 1.2: Create paperforge/adapters/zotero_paths.py. Extract these 3 functions verbatim from sync.py (lines 195-250) with NO behavioral changes:

    **Function 1: `obsidian_wikilink_for_pdf(pdf_path: str, vault_dir: Path, zotero_dir: Path | None = None) -> str`**
    - Copy lines 195-225 from sync.py exactly
    - Dependencies: `from paperforge.pdf_resolver import resolve_junction` (lazy import inside function, preserved from original)
    - Keeps the junction resolution backup logic

    **Function 2: `absolutize_vault_path(vault: Path, path: str, resolve_junction: bool = False) -> str`**
    - Copy lines 228-238 from sync.py exactly
    - Dependencies: `from paperforge.pdf_resolver import resolve_junction` (lazy import inside function)

    **Function 3: `obsidian_wikilink_for_path(vault: Path, path: str) -> str`**
    - Copy lines 241-250 from sync.py exactly

    Import structure for the new module:
    ```python
    from __future__ import annotations

    import os
    from pathlib import Path
    ```

    Step 1.3: In paperforge/worker/sync.py, add backward-compatible import aliases at the top of the file (after existing `_utils` imports, before logger):
    ```python
    # Adapter re-exports (backward compat) — Phase 58 Service Extraction
    from paperforge.adapters.zotero_paths import (
        absolutize_vault_path,
        obsidian_wikilink_for_pdf,
        obsidian_wikilink_for_path,
    )
    ```
    This ensures all existing `from paperforge.worker.sync import obsidian_wikilink_for_pdf` imports continue to work. Do NOT remove the original function definitions yet — they become dead code for this wave and will be cleaned up in Plan 04.

    Step 1.4: Create tests/unit/adapters/ and tests/unit/adapters/__init__.py:
    ```python
    # tests/unit/adapters/__init__.py
    ```

    Step 1.5: Verify importability:
    ```
    python -c "from paperforge.adapters.zotero_paths import obsidian_wikilink_for_pdf, absolutize_vault_path, obsidian_wikilink_for_path; print('OK')"
    ```
  </action>
  <verify>
    <automated>
      python -c "from paperforge.adapters import zotero_paths; from paperforge.adapters.zotero_paths import obsidian_wikilink_for_pdf, absolutize_vault_path, obsidian_wikilink_for_path; print('Adapter import OK')"
    </automated>
  </verify>
  <done>
    adapters/__init__.py and adapters/zotero_paths.py exist. All 3 path functions importable from both paperforge.adapters and paperforge.worker.sync (backward compat). Original function bodies remain in sync.py.
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Create comprehensive unit tests for adapters/zotero_paths.py</name>
  <files>
    tests/unit/adapters/test_zotero_paths.py
  </files>
  <behavior>
    Test that obsidian_wikilink_for_pdf handles:
    - storage:KEY/file.pdf resolves to [[system/Zotero/storage/KEY/file.pdf]]
    - Absolute Windows path resolves through vault-relative
    - Chinese filename preserved in wikilink
    - Paths with spaces handled correctly
    - Empty path returns empty string
    - Forward slashes in output, never backslashes
    - Junction resolution path works via mock

    Test that absolutize_vault_path handles:
    - Absolute path passed through unchanged
    - Vault-relative path resolved
    - Empty path returns empty string
    - Junction resolution flag

    Test that obsidian_wikilink_for_path handles:
    - Vault-internal path produces relative wikilink
    - Vault-external path produces absolute wikilink
    - Empty path returns empty string
  </behavior>
  <action>
    Step 2.1 (RED): Write test file at tests/unit/adapters/test_zotero_paths.py. Import from the adapter, NOT from sync.py. Structure as one class per function:

    ```python
    from __future__ import annotations

    from pathlib import Path

    from paperforge.adapters.zotero_paths import (
        absolutize_vault_path,
        obsidian_wikilink_for_pdf,
        obsidian_wikilink_for_path,
    )


    class TestObsidianWikilinkForPdf:
        """Tests for obsidian_wikilink_for_pdf()."""

        def test_storage_prefix_wikilink(self, tmp_path: Path) -> None:
            """storage:KEY/file.pdf -> [[system/Zotero/storage/KEY/file.pdf]]"""
            vault_dir = tmp_path / "vault"
            zotero_dir = vault_dir / "system" / "Zotero"
            storage_dir = zotero_dir / "storage" / "KEY"
            storage_dir.mkdir(parents=True)
            pdf = storage_dir / "file.pdf"
            pdf.write_text("PDF content")
            result = obsidian_wikilink_for_pdf("storage:KEY/file.pdf", vault_dir, zotero_dir)
            assert result == "[[system/Zotero/storage/KEY/file.pdf]]"

        def test_chinese_filename(self, tmp_path: Path) -> None:
            vault_dir = tmp_path / "vault"
            zotero_dir = vault_dir / "system" / "Zotero"
            storage_dir = zotero_dir / "storage" / "KEY"
            storage_dir.mkdir(parents=True)
            pdf = storage_dir / "中文论文.pdf"
            pdf.write_text("PDF content")
            result = obsidian_wikilink_for_pdf("storage:KEY/中文论文.pdf", vault_dir, zotero_dir)
            assert "中文论文" in result
            assert "\\" not in result

        def test_path_with_spaces(self, tmp_path: Path) -> None:
            vault_dir = tmp_path / "vault"
            zotero_dir = vault_dir / "system" / "Zotero"
            storage_dir = zotero_dir / "storage" / "KEY"
            storage_dir.mkdir(parents=True)
            pdf = storage_dir / "paper with spaces.pdf"
            pdf.write_text("PDF content")
            result = obsidian_wikilink_for_pdf("storage:KEY/paper with spaces.pdf", vault_dir, zotero_dir)
            assert "paper with spaces" in result

        def test_forward_slashes(self, tmp_path: Path) -> None:
            vault_dir = tmp_path / "vault"
            zotero_dir = vault_dir / "system" / "Zotero"
            storage_dir = zotero_dir / "storage" / "KEY"
            storage_dir.mkdir(parents=True)
            pdf = storage_dir / "file.pdf"
            pdf.write_text("PDF content")
            result = obsidian_wikilink_for_pdf("storage:KEY/file.pdf", vault_dir, zotero_dir)
            assert "\\" not in result

        def test_empty_path_returns_empty(self) -> None:
            result = obsidian_wikilink_for_pdf("", Path("/vault"), None)
            assert result == ""

        def test_junction_resolution_path(self, tmp_path: Path, monkeypatch) -> None:
            vault_dir = tmp_path / "vault"
            zotero_dir = vault_dir / "system" / "Zotero"
            storage_dir = zotero_dir / "storage" / "KEY"
            storage_dir.mkdir(parents=True)
            pdf = storage_dir / "file.pdf"
            pdf.write_text("PDF content")
            def mock_resolve(path):
                return path
            monkeypatch.setattr("paperforge.pdf_resolver.resolve_junction", mock_resolve)
            result = obsidian_wikilink_for_pdf("storage:KEY/file.pdf", vault_dir, zotero_dir)
            assert result.startswith("[[")

        def test_nonexistent_storage_path_returns_absolute_wikilink(self, tmp_path: Path) -> None:
            vault_dir = tmp_path / "vault"
            zotero_dir = vault_dir / "system" / "Zotero"
            (zotero_dir / "storage" / "KEY").mkdir(parents=True)
            # Path does not exist — function should still produce a wikilink
            result = obsidian_wikilink_for_pdf("storage:KEY/nonexistent.pdf", vault_dir, zotero_dir)
            assert result.startswith("[[")
            assert "nonexistent" in result

        def test_absolute_path_outside_vault(self, tmp_path: Path) -> None:
            vault_dir = tmp_path / "vault"
            vault_dir.mkdir()
            outside = tmp_path / "outside" / "file.pdf"
            outside.parent.mkdir(parents=True)
            outside.write_text("PDF content")
            result = obsidian_wikilink_for_pdf(str(outside), vault_dir, None)
            assert result.startswith("[[")


    class TestAbsolutizeVaultPath:
        def test_absolute_path_passthrough(self) -> None:
            result = absolutize_vault_path(Path("/vault"), "/absolute/path.pdf")
            assert result == "/absolute/path.pdf"

        def test_empty_path_returns_empty(self) -> None:
            result = absolutize_vault_path(Path("/vault"), "")
            assert result == ""

        def test_vault_relative_path_resolved(self, tmp_path: Path) -> None:
            vault = tmp_path / "vault"
            vault.mkdir()
            (vault / "subdir").mkdir()
            (vault / "subdir" / "file.pdf").write_text("content")
            result = absolutize_vault_path(vault, "subdir/file.pdf")
            assert result == str((vault / "subdir" / "file.pdf").resolve())


    class TestObsidianWikilinkForPath:
        def test_vault_internal_path(self, tmp_path: Path) -> None:
            vault = tmp_path / "vault"
            vault.mkdir()
            (vault / "note.md").write_text("note")
            result = obsidian_wikilink_for_path(vault, "note.md")
            assert result == "[[note.md]]"

        def test_empty_path_returns_empty(self) -> None:
            result = obsidian_wikilink_for_path(Path("/vault"), "")
            assert result == ""
    ```

    Step 2.2 (RED): Run the tests — they will fail because... no they should pass since we extracted the exact same code. But the adapter module is new so we need to verify. Run:
    ```
    python -m pytest tests/unit/adapters/test_zotero_paths.py -v --tb=short
    ```

    Step 2.3 (GREEN if needed): Fix any import or path issues. The tests should pass since we preserved the exact function logic.
  </action>
  <verify>
    <automated>python -m pytest tests/unit/adapters/test_zotero_paths.py -v --tb=short 2>&1</automated>
  </verify>
  <done>
    At least 10 test cases pass covering: storage: paths, Chinese filenames, spaces, forward slashes, empty paths, junction paths, absolute paths, vault-relative paths. All assertions pass with no errors.
  </done>
</task>

<task type="auto">
  <name>Task 3: Verify backward compatibility and existing tests</name>
  <files>
    paperforge/worker/sync.py
  </files>
  <action>
    Step 3.1: Verify that existing test_path_normalization.py can be re-pointed to import from the adapter module (but still imports from sync.py for now). Run existing tests:
    ```
    python -m pytest tests/test_path_normalization.py -v --tb=short
    ```
    All existing TestWikilinkGeneration tests must still pass.

    Step 3.2: Verify all worker modules still import cleanly:
    ```
    python -c "
    import paperforge.worker.sync
    import paperforge.worker.ocr
    import paperforge.worker.status
    import paperforge.worker.repair
    import paperforge.worker.deep_reading
    import paperforge.worker.base_views
    import paperforge.worker.asset_index
    print('All worker modules importable')
    "
    ```

    Step 3.3: If any import fails, it means the backward compat aliases in sync.py are incomplete or the lazy imports within the extracted functions broke something. Fix by ensuring all function references in sync.py still resolve (sync.py should NOT call the adapter module's functions from within sync.py's own remaining functions — they should use the local definitions that still exist. But the import alias means both names resolve. Remove the original definitions only in Plan 04.)

    Actually, correction: The original function definitions STILL EXIST in sync.py at this point. The backward-compat import aliases are just ADDITIONAL names. So all existing code calling sync.py's own functions still works. The alias is there so that external code that does `from paperforge.worker.sync import obsidian_wikilink_for_pdf` also finds it. When we delete the original definitions in Plan 04, the alias takes over.
  </action>
  <verify>
    <automated>
      python -c "import paperforge.worker.sync; from paperforge.worker.sync import obsidian_wikilink_for_pdf, absolutize_vault_path, obsidian_wikilink_for_path; print('Backward compat OK')" && python -m pytest tests/test_path_normalization.py::TestWikilinkGeneration -v --tb=short
    </automated>
  </verify>
  <done>
    All existing tests pass. All worker modules import cleanly. Path functions available from both new and old import paths.
  </done>
</task>

</tasks>

<verification>
1. `python -m pytest tests/unit/adapters/test_zotero_paths.py -v` — all adapter tests pass
2. `python -m pytest tests/test_path_normalization.py::TestWikilinkGeneration -v` — existing wikilink tests still pass
3. `python -c "from paperforge.adapters.zotero_paths import absolutize_vault_path, obsidian_wikilink_for_pdf, obsidian_wikilink_for_path; print('adapter import OK')"` — adapter importable independently
4. `python -c "from paperforge.worker.sync import absolutize_vault_path, obsidian_wikilink_for_pdf, obsidian_wikilink_for_path; print('backward compat OK')"` — sync.py re-exports work
</verification>

<success_criteria>
1. paperforge/adapters/zotero_paths.py exists with 3 path functions importable from the adapter module
2. paperforge/worker/sync.py has backward-compatible import aliases for all extracted functions
3. tests/unit/adapters/test_zotero_paths.py has 10+ test cases covering all path format variants
4. All existing tests pass (especially test_path_normalization.py::TestWikilinkGeneration)
5. No behavioral changes to any path function
</success_criteria>

<output>
After completion, create `.planning/phases/058-service-extraction/058-01-SUMMARY.md`
</output>
