---
phase: 58-service-extraction
plan: 02
type: execute
wave: 2
depends_on: ["058-01"]
files_modified:
  - paperforge/adapters/bbt.py
  - tests/unit/adapters/test_bbt.py
  - paperforge/worker/sync.py
autonomous: true
requirements: [SYNC-01, SYNC-05]

must_haves:
  truths:
    - "paperforge/adapters/bbt.py can be imported independently of sync.py"
    - "_normalize_attachment_path handles absolute Windows, storage: prefix, and bare relative paths"
    - "_identify_main_pdf selects title=='PDF' over size, largest size over shortest title, first PDF as fallback"
    - "extract_authors correctly parses creator lists and skips non-author types"
    - "resolve_item_collection_paths resolves collection keys to paths using collection_lookup"
    - "load_export_rows loads fixtures (absolute, storage, mixed) and produces correct row counts"
    - "Existing sync.py still exports all BBT functions for backward compatibility"
  artifacts:
    - path: paperforge/adapters/bbt.py
      provides: "BBT JSON parsing functions extracted from sync.py"
      exports: ["load_export_rows", "_normalize_attachment_path", "_identify_main_pdf", "extract_authors", "resolve_item_collection_paths", "collection_fields"]
      min_lines: 160
    - path: tests/unit/adapters/test_bbt.py
      provides: "Unit tests for BBT parsing covering all variants and edge cases"
      min_lines: 200
  key_links:
    - from: paperforge/adapters/bbt.py
      to: paperforge/worker/_utils.py
      via: "import read_json, _extract_year"
      pattern: "from paperforge.worker._utils import"
    - from: paperforge/adapters/bbt.py
      to: paperforge/worker/_domain.py
      via: "import build_collection_lookup"
      pattern: "from paperforge.worker._domain import"
    - from: paperforge/worker/sync.py
      to: paperforge/adapters/bbt.py
      via: "import alias for backward compat"
      pattern: "from paperforge.adapters.bbt import"
---
<objective>
Extract BBT JSON parsing functions from sync.py into a focused `adapters/bbt.py` module with independent unit tests.

**Purpose:** The BBT export parsing logic handles 3 path formats (absolute Windows, storage: prefix, bare relative), main PDF identification with a 3-priority hybrid strategy, author extraction from creator lists, and collection path resolution from Zotero collection trees. Isolating into `bbt.py` makes the parsing logic independently testable from the sync orchestration.

**Output:** adapter module + comprehensive test file. sync.py retains backward-compatible import aliases.

**Extracted functions:**
- `_normalize_attachment_path(path, zotero_dir)` — normalizes 3 BBT path formats to `storage:` prefix
- `_identify_main_pdf(attachments)` — selects main PDF via title="PDF" > largest size > first PDF
- `extract_authors(item)` — parses creator lists for author-type entries
- `resolve_item_collection_paths(item, collection_lookup)` — resolves collection keys to path strings
- `collection_fields(collection_paths)` — builds collection metadata dict (used by asset_index.py and sync.py)
- `load_export_rows(path)` — main BBT JSON parser that builds row dicts with all metadata
</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/worker/_utils.py
@file:///D:/L/Med/Research/99_System/LiteraturePipeline/github-release/paperforge/worker/_domain.py
@file:///D:/L/Med/Research/99_System/LiteraturePipeline/github-release/tests/test_path_normalization.py
@file:///D:/L/Med/Research/99_System/LiteraturePipeline/github-release/tests/fixtures/bbt_export_absolute.json
@file:///D:/L/Med/Research/99_System/LiteraturePipeline/github-release/tests/fixtures/bbt_export_storage.json
@file:///D:/L/Med/Research/99_System/LiteraturePipeline/github-release/tests/fixtures/bbt_export_mixed.json

<interfaces>
Dependencies already imported from worker modules (do NOT duplicate these modules — import from them):

From paperforge/worker/_utils.py:
```python
def read_json(path: Path) -> dict | list: ...
def _extract_year(value: str) -> str: ...
def slugify_filename(text: str) -> str: ...
```

From paperforge/worker/_domain.py:
```python
def build_collection_lookup(collections: dict) -> dict:
    """Returns {'path_by_key': {...}, 'paths_by_item_id': {...}}"""
    ...
```

Test fixture files already exist at tests/fixtures/:
- bbt_export_absolute.json — single item with absolute Windows path
- bbt_export_storage.json — single item with storage: prefix path
- bbt_export_mixed.json — 2 items: mixed formats (absolute + storage + bare) and bare relative only
</interfaces>
</context>

<tasks>

<task type="auto" tdd="false">
  <name>Task 1: Create adapters/bbt.py with extracted BBT parsing functions</name>
  <files>
    paperforge/adapters/bbt.py
  </files>
  <action>
    Step 1.1: Create paperforge/adapters/bbt.py. Extract these functions verbatim from sync.py with NO behavioral changes:

    **Imports:**
    ```python
    from __future__ import annotations

    import logging
    from pathlib import Path

    from paperforge.worker._utils import _extract_year, read_json
    from paperforge.worker._domain import build_collection_lookup

    logger = logging.getLogger(__name__)
    ```

    **Function 1: `_normalize_attachment_path(path: str, zotero_dir: Path | None = None) -> tuple[str, str, str]`**
    - Extract lines 284-337 from sync.py verbatim (3 path formats: absolute Windows, storage: prefix, bare relative)
    - Dependencies: `os.sep` for path normalization (already available via os module, but this function only uses string ops and Path)

    **Function 2: `_identify_main_pdf(attachments: list[dict]) -> tuple[dict | None, list[dict]]`**
    - Extract lines 340-381 from sync.py verbatim
    - Hybrid 3-priority strategy: title=="PDF" > largest size > shortest title > first PDF

    **Function 3: `extract_authors(item: dict) -> list[str]`**
    - Extract lines 269-281 from sync.py verbatim

    **Function 4: `resolve_item_collection_paths(item: dict, collection_lookup: dict) -> list[str]`**
    - Extract lines 183-192 from sync.py verbatim
    - Uses `collection_lookup["path_by_key"]` and `collection_lookup["paths_by_item_id"]`

    **Function 5: `collection_fields(collection_paths: list[str]) -> dict[str, str | list[str]]`**
    - Extract lines 253-266 from sync.py verbatim

    **Function 6: `load_export_rows(path: Path) -> list[dict]`**
    - Extract lines 384-445 from sync.py verbatim
    - Wires together _normalize_attachment_path, _identify_main_pdf, extract_authors, resolve_item_collection_paths
    - Dependencies: `build_collection_lookup` from _domain.py

    Step 1.2: In paperforge/worker/sync.py, add backward-compatible import aliases at the top (after the zotero_paths imports from Plan 01):
    ```python
    # Adapter re-exports (backward compat) — Phase 58 Service Extraction
    from paperforge.adapters.bbt import (
        _normalize_attachment_path,
        _identify_main_pdf,
        extract_authors,
        resolve_item_collection_paths,
        collection_fields,
        load_export_rows,
    )
    ```
    Keep original function definitions in sync.py for now (removed in Plan 04).

    Step 1.3: Verify importability:
    ```
    python -c "from paperforge.adapters import bbt; from paperforge.adapters.bbt import load_export_rows, _normalize_attachment_path, _identify_main_pdf; print('BBT adapter import OK')"
    ```
  </action>
  <verify>
    <automated>
      python -c "from paperforge.adapters.bbt import load_export_rows, _normalize_attachment_path, _identify_main_pdf, extract_authors, resolve_item_collection_paths, collection_fields; print('BBT adapter import OK')"
    </automated>
  </verify>
  <done>
    adapters/bbt.py exists with all 6 functions importable. Backward-compat aliases added to sync.py.
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Migrate + extend BBT unit tests</name>
  <files>
    tests/unit/adapters/test_bbt.py
  </files>
  <behavior>
    Test _normalize_attachment_path with:
    - Absolute Windows path -> storage:KEY/filename.pdf
    - storage: prefix passed through
    - storage: with backslashes -> normalized to forward slashes
    - Bare relative path -> storage: prepended
    - Chinese filename preserved
    - Spaces preserved
    - Empty path -> ("", "", "")
    - Absolute non-storage path -> absolute: prefix

    Test _identify_main_pdf with:
    - title=="PDF" selected over larger file
    - No title=="PDF" -> largest file by size
    - Equal sizes -> first in list
    - No PDFs -> (None, [])
    - Single PDF -> empty supplementary
    - Mixed PDF + non-PDF -> non-PDF ignored

    Test load_export_rows with:
    - Fixture with absolute Windows path -> correct row count, normalized path
    - Fixture with storage: prefix -> correct row count, normalized path
    - Mixed format fixture -> correct row count, path_error detection
    - No-attachment item -> path_error="not_found"
    - Empty JSON -> empty list

    Test extract_authors with:
    - Only author-type creators returned
    - Non-author types skipped
    - Empty creators -> empty list
    - Name-only entry (no first/last)

    Test resolve_item_collection_paths with:
    - Collection keys resolved to paths
    - Empty collections -> []
    - Item ID fallback

    Test collection_fields with:
    - Multiple paths -> primary sorted, tags deduplicated
    - Empty list -> empty fields
  </behavior>
  <action>
    Step 2.1 (RED): Write test file at tests/unit/adapters/test_bbt.py. Import from the adapter module:

    ```python
    from __future__ import annotations

    import json
    from pathlib import Path

    from paperforge.adapters.bbt import (
        _identify_main_pdf,
        _normalize_attachment_path,
        collection_fields,
        extract_authors,
        load_export_rows,
        resolve_item_collection_paths,
    )


    class TestBBTPathNormalization:
        """Tests for _normalize_attachment_path() — adapted from test_path_normalization.py"""

        def test_absolute_windows_path(self) -> None:
            raw = r"D:\L\Med\Research\99_System\Zotero\storage\ABC12345\paper.pdf"
            normalized, bbt_raw, key = _normalize_attachment_path(raw)
            assert normalized == "storage:ABC12345/paper.pdf"
            assert bbt_raw == raw
            assert key == "ABC12345"

        def test_storage_prefix_path(self) -> None:
            raw = "storage:ABC12345/paper.pdf"
            normalized, bbt_raw, key = _normalize_attachment_path(raw)
            assert normalized == "storage:ABC12345/paper.pdf"
            assert key == "ABC12345"

        def test_bare_relative_path(self) -> None:
            raw = "ABC12345/paper.pdf"
            normalized, bbt_raw, key = _normalize_attachment_path(raw)
            assert normalized == "storage:ABC12345/paper.pdf"
            assert key == "ABC12345"

        def test_chinese_characters(self) -> None:
            raw = r"D:\L\Med\Research\99_System\Zotero\storage\ABC12345\中文论文.pdf"
            normalized, _, key = _normalize_attachment_path(raw)
            assert "中文论文" in normalized
            assert key == "ABC12345"

        def test_path_with_spaces(self) -> None:
            raw = r"D:\L\Med\Research\99_System\Zotero\storage\ABC12345\paper with spaces.pdf"
            normalized, _, key = _normalize_attachment_path(raw)
            assert normalized == "storage:ABC12345/paper with spaces.pdf"
            assert key == "ABC12345"

        def test_storage_prefix_with_backslashes(self) -> None:
            raw = r"storage:ABC12345\subdir\paper.pdf"
            normalized, _, key = _normalize_attachment_path(raw)
            assert normalized == "storage:ABC12345/subdir/paper.pdf"
            assert key == "ABC12345"

        def test_empty_path(self) -> None:
            normalized, bbt_raw, key = _normalize_attachment_path("")
            assert normalized == "" and bbt_raw == "" and key == ""

        def test_absolute_non_storage_path(self) -> None:
            raw = r"D:\Downloads\random.pdf"
            normalized, bbt_raw, key = _normalize_attachment_path(raw)
            assert normalized.startswith("absolute:")
            assert key == ""


    class TestMainPdfIdentification:
        """Tests for _identify_main_pdf() — adapted from test_path_normalization.py"""

        def test_title_pdf_primary(self) -> None:
            attachments = [
                {"path": "storage:KEY/paper.pdf", "contentType": "application/pdf", "title": "PDF", "size": 1000},
                {"path": "storage:KEY/supp.pdf", "contentType": "application/pdf", "title": "Supplementary", "size": 2000},
            ]
            main, supplementary = _identify_main_pdf(attachments)
            assert main is not None and main["title"] == "PDF"
            assert len(supplementary) == 1

        def test_fallback_largest_file(self) -> None:
            attachments = [
                {"path": "storage:KEY/small.pdf", "contentType": "application/pdf", "title": "Small", "size": 100},
                {"path": "storage:KEY/large.pdf", "contentType": "application/pdf", "title": "Large", "size": 9999},
            ]
            main, _ = _identify_main_pdf(attachments)
            assert main is not None and main["title"] == "Large"

        def test_fallback_first_pdf_equal_sizes(self) -> None:
            attachments = [
                {"path": "storage:KEY/first.pdf", "contentType": "application/pdf", "title": "First", "size": 100},
                {"path": "storage:KEY/second.pdf", "contentType": "application/pdf", "title": "Second", "size": 100},
            ]
            main, _ = _identify_main_pdf(attachments)
            assert main is not None and main["title"] == "First"

        def test_no_pdf_attachments(self) -> None:
            attachments = [
                {"path": "storage:KEY/data.xlsx", "contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "title": "Data"},
            ]
            main, supplementary = _identify_main_pdf(attachments)
            assert main is None and supplementary == []

        def test_single_pdf_no_supplementary(self) -> None:
            attachments = [
                {"path": "storage:KEY/only.pdf", "contentType": "application/pdf", "title": "Only", "size": 1000},
            ]
            main, supplementary = _identify_main_pdf(attachments)
            assert main is not None and supplementary == []

        def test_non_pdf_ignored(self) -> None:
            attachments = [
                {"path": "storage:KEY/main.pdf", "contentType": "application/pdf", "title": "PDF", "size": 1000},
                {"path": "storage:KEY/supp.pdf", "contentType": "application/pdf", "title": "Supp", "size": 500},
                {"path": "storage:KEY/data.zip", "contentType": "application/zip", "title": "Data", "size": 200},
            ]
            main, supplementary = _identify_main_pdf(attachments)
            assert main is not None and main["title"] == "PDF"
            assert len(supplementary) == 1


    class TestExtractAuthors:
        def test_author_only(self) -> None:
            item = {"creators": [
                {"creatorType": "author", "firstName": "John", "lastName": "Smith"},
                {"creatorType": "author", "firstName": "Jane", "lastName": "Doe"},
            ]}
            assert extract_authors(item) == ["John Smith", "Jane Doe"]

        def test_non_author_skipped(self) -> None:
            item = {"creators": [
                {"creatorType": "author", "firstName": "John", "lastName": "Smith"},
                {"creatorType": "editor", "firstName": "Editor", "lastName": "Person"},
            ]}
            assert extract_authors(item) == ["John Smith"]

        def test_name_only_entry(self) -> None:
            item = {"creators": [
                {"creatorType": "author", "name": "Institutional Author"},
            ]}
            assert extract_authors(item) == ["Institutional Author"]

        def test_empty_creators(self) -> None:
            assert extract_authors({"creators": []}) == []

        def test_no_creators_key(self) -> None:
            assert extract_authors({}) == []


    class TestResolveItemCollectionPaths:
        def test_collection_keys_resolved(self) -> None:
            item = {"collections": ["key1", "key2"]}
            lookup = {"path_by_key": {"key1": "Parent/Sub", "key2": "Parent/Other"}, "paths_by_item_id": {}}
            paths = resolve_item_collection_paths(item, lookup)
            assert "Parent/Sub" in paths
            assert "Parent/Other" in paths

        def test_empty_collections(self) -> None:
            assert resolve_item_collection_paths({"collections": []}, {}) == []

        def test_no_collections_key(self) -> None:
            assert resolve_item_collection_paths({}, {"path_by_key": {}, "paths_by_item_id": {}}) == []

        def test_item_id_fallback(self) -> None:
            item = {"itemID": 42}
            lookup = {"path_by_key": {}, "paths_by_item_id": {42: ["Fallback/Path"]}}
            assert "Fallback/Path" in resolve_item_collection_paths(item, lookup)


    class TestCollectionFields:
        def test_multiple_paths(self) -> None:
            result = collection_fields(["Med/Pediatrics", "Med/Cardiology"])
            assert "Pediatrics" in result["collection_tags"]
            assert "Cardiology" in result["collection_tags"]

        def test_empty_list(self) -> None:
            result = collection_fields([])
            assert result["collections"] == []
            assert result["collection_tags"] == []
            assert result["collection_group"] == []

        def test_empty_paths_filtered(self) -> None:
            result = collection_fields(["", "Med/Valid"])
            assert "" not in result["collections"]
            assert "Valid" in result["collection_tags"]
    ```

    Step 2.2 (RED): Run tests — they should pass since logic is verbatim copy:
    ```
    python -m pytest tests/unit/adapters/test_bbt.py -v --tb=short
    ```

    Step 2.3: Add fixture-based integration tests for load_export_rows:
    ```python
    class TestLoadExportRowsIntegration:
    """Tests for load_export_rows against fixture JSON files."""

        FIXTURES = Path(__file__).resolve().parent.parent.parent / "fixtures"

        def test_load_absolute_fixture(self) -> None:
            path = self.FIXTURES / "bbt_export_absolute.json"
            rows = load_export_rows(path)
            assert len(rows) == 1
            row = rows[0]
            assert row["key"] == "ABC12345"
            assert "storage:" in row["pdf_path"]
            assert row["path_error"] == ""

        def test_load_storage_fixture(self) -> None:
            path = self.FIXTURES / "bbt_export_storage.json"
            rows = load_export_rows(path)
            assert len(rows) == 1
            row = rows[0]
            assert row["key"] == "STORAGE1"
            assert row["pdf_path"] == "storage:STORAGE1/Storage Prefix Test Paper.pdf"

        def test_load_mixed_fixture(self) -> None:
            path = self.FIXTURES / "bbt_export_mixed.json"
            rows = load_export_rows(path)
            assert len(rows) == 2
            # MIXED001 has absolute path main PDF
            row0 = [r for r in rows if r["key"] == "MIXED001"][0]
            assert "storage:" in row0["pdf_path"]
            assert row0["path_error"] == ""
            assert row0["attachment_count"] == 3  # 2 PDFs + 1 docx
            assert len(row0["supplementary"]) >= 1

        def test_mixed_fixture_path_error_none(self) -> None:
            """MIXED001 has 2 PDFs so path_error should be empty."""
            path = self.FIXTURES / "bbt_export_mixed.json"
            rows = load_export_rows(path)
            row = [r for r in rows if r["key"] == "MIXED001"][0]
            assert row["path_error"] == ""

        def test_no_attachments_path_error(self) -> None:
            """Item without PDF attachments gets path_error='not_found'."""
            path = self.FIXTURES / "bbt_export_absolute.json"
            import json
            data = json.loads(path.read_text(encoding="utf-8"))
            data["items"][0]["attachments"] = []
            tmp = path.parent / "_tmp_no_att.json"
            try:
                tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
                rows = load_export_rows(tmp)
                assert len(rows) == 1
                assert rows[0]["path_error"] == "not_found"
            finally:
                if tmp.exists():
                    tmp.unlink()
    ```

    Step 2.4: Run ALL adapter tests:
    ```
    python -m pytest tests/unit/adapters/ -v --tb=short
    ```
  </action>
  <verify>
    <automated>python -m pytest tests/unit/adapters/test_bbt.py -v --tb=short 2>&1</automated>
  </verify>
  <done>
    All BBT adapter tests pass (20+ cases covering path variants, PDF identification, authors, collections, fixture integration).
  </done>
</task>

<task type="auto">
  <name>Task 3: Verify backward compatibility + existing BBT tests</name>
  <files>
    paperforge/worker/sync.py
  </files>
  <action>
    Step 3.1: Run existing path normalization tests that import from sync.py:
    ```
    python -m pytest tests/test_path_normalization.py -v --tb=short
    ```
    All 25 test cases must still pass (backward compat aliases ensure TestBBTPathNormalization, TestMainPdfIdentification, and TestLoadExportRowsIntegration still work).

    Step 3.2: Run the pdf_resolver integration tests that import load_export_rows from sync.py:
    ```
    python -m pytest tests/test_pdf_resolver.py -v --tb=short 2>&1 | tail -20
    ```

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

    Step 3.4: If any failures, check that:
    - The alias is correctly placed BEFORE any code that references the functions
    - The import path resolves (check `python -c "from paperforge.adapters.bbt import ..."`)
    - sync.py's own remaining function bodies still work (they use the local definitions, not the aliases)
  </action>
  <verify>
    <automated>
      python -m pytest tests/test_path_normalization.py tests/test_pdf_resolver.py -v --tb=short 2>&1 | tail -40
    </automated>
  </verify>
  <done>
    All existing tests pass. All worker modules import cleanly using both old and new import paths.
  </done>
</task>

</tasks>

<verification>
1. `python -m pytest tests/unit/adapters/test_bbt.py -v` — all adapter BBT tests pass
2. `python -m pytest tests/test_path_normalization.py -v` — existing path normalization tests still pass
3. `python -c "from paperforge.adapters.bbt import load_export_rows, _normalize_attachment_path, _identify_main_pdf, extract_authors, resolve_item_collection_paths, collection_fields; print('adapter import OK')"`
4. `python -c "from paperforge.worker.sync import _normalize_attachment_path, _identify_main_pdf, load_export_rows, extract_authors, resolve_item_collection_paths, collection_fields; print('backward compat OK')"`
</verification>

<success_criteria>
1. paperforge/adapters/bbt.py exists with all 6 functions importable
2. paperforge/worker/sync.py has backward-compatible aliases for all extracted BBT functions
3. tests/unit/adapters/test_bbt.py has 20+ test cases covering all BBT variants and edge cases
4. All existing test_path_normalization.py and test_pdf_resolver.py tests pass
5. No behavioral changes to any BBT parsing function
</success_criteria>

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