# Zotero Collection Import Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the current left-panel import widgets with a Zotero collection tree importer that supports descendant-aware browsing, remembered checkbox state, and batch import into the active project. **Architecture:** Extend the SQLite-backed `ZoteroReader` with collection-tree and collection-item queries, expose those through dedicated API endpoints, and replace the current left-panel import UI with a tree-plus-item-list importer that keeps selection state in the browser session. Reuse the existing `POST /api/projects/{project_id}/imports/item-keys` endpoint as the import execution path. **Tech Stack:** Python 3.10, FastAPI, sqlite3, vanilla HTML/CSS/JavaScript, pytest, uv --- ## File Structure - Modify: `src/zotero_kb/zotero_reader.py` Purpose: build collection tree metadata and collection-scoped item listings with descendant inclusion. - Modify: `src/zotero_kb/api.py` Purpose: expose collection tree and collection item endpoints. - Modify: `src/zotero_kb/templates/index.html` Purpose: replace the left-panel import UI with a collection tree importer and batch selection workflow. - Modify: `tests/test_zotero_reader.py` Purpose: cover collection tree generation and descendant-aware item listing. - Modify: `tests/test_api.py` Purpose: cover collection tree and collection items endpoints plus batch import path. - Modify: `tests/test_ui.py` Purpose: ensure the index template includes the new collection importer controls. - Modify: `README.md` Purpose: document the new left-panel import workflow. ### Task 1: Add Collection Tree Reader Support **Files:** - Modify: `src/zotero_kb/zotero_reader.py` - Modify: `tests/test_zotero_reader.py` - [ ] **Step 1: Write the failing reader tests** ```python def test_build_collection_tree_returns_descendant_counts(tmp_path: Path) -> None: fixture_dir = tmp_path / "zotero" fixture_dir.mkdir() build_fixture_zotero_dir(fixture_dir) reader = ZoteroReader(fixture_dir) tree = reader.get_collection_tree() assert len(tree) == 1 root = tree[0] assert root["name"] == "Theory" assert root["direct_item_count"] == 0 assert root["descendant_item_count"] == 1 assert root["children"][0]["name"] == "Drafting" def test_get_collection_items_includes_descendants(tmp_path: Path) -> None: fixture_dir = tmp_path / "zotero" fixture_dir.mkdir() build_fixture_zotero_dir(fixture_dir) reader = ZoteroReader(fixture_dir) items = reader.get_collection_items("COLL0001", include_descendants=True) assert [item["item_key"] for item in items] == ["PAPER0001"] assert items[0]["collection_paths"] == [["Theory", "Drafting"]] ``` - [ ] **Step 2: Run test to verify it fails** Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_zotero_reader.py::test_build_collection_tree_returns_descendant_counts tests/test_zotero_reader.py::test_get_collection_items_includes_descendants -q` Expected: FAIL with `AttributeError` because `get_collection_tree` and `get_collection_items` do not exist. - [ ] **Step 3: Write minimal implementation** ```python def get_collection_tree(self) -> list[dict[str, object]]: conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row try: collections = self._read_collection_lookup(conn) direct_counts = self._read_direct_item_counts(conn) children_lookup = self._build_children_lookup(collections) return [ self._build_collection_node(collection_id, collections, children_lookup, direct_counts) for collection_id, row in collections.items() if row["parentCollectionID"] is None ] finally: conn.close() def get_collection_items(self, collection_key: str, include_descendants: bool = True) -> list[dict[str, object]]: conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row try: field_lookup = self._read_field_lookup(conn) collections = self._read_collection_lookup(conn) children_lookup = self._build_children_lookup(collections) collection_ids = self._resolve_collection_ids(collection_key, collections, children_lookup, include_descendants) item_ids = self._read_item_ids_for_collections(conn, collection_ids) return self._read_item_summaries(conn, item_ids, field_lookup, collections) finally: conn.close() ``` ```python def _build_collection_node( self, collection_id: int, collections: dict[int, sqlite3.Row], children_lookup: dict[int, list[int]], direct_counts: dict[int, int], ) -> dict[str, object]: children = [ self._build_collection_node(child_id, collections, children_lookup, direct_counts) for child_id in children_lookup.get(collection_id, []) ] descendant_count = direct_counts.get(collection_id, 0) + sum( int(child["descendant_item_count"]) for child in children ) row = collections[collection_id] return { "collection_key": str(row["key"]), "name": str(row["collectionName"]), "parent_key": self._parent_key(row["parentCollectionID"], collections), "children": children, "direct_item_count": direct_counts.get(collection_id, 0), "descendant_item_count": descendant_count, } ``` - [ ] **Step 4: Run test to verify it passes** Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_zotero_reader.py::test_build_collection_tree_returns_descendant_counts tests/test_zotero_reader.py::test_get_collection_items_includes_descendants -q` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/zotero_kb/zotero_reader.py tests/test_zotero_reader.py git commit -m "feat: add zotero collection tree reader support" ``` ### Task 2: Expose Collection Import API **Files:** - Modify: `src/zotero_kb/api.py` - Modify: `tests/test_api.py` - [ ] **Step 1: Write the failing API tests** ```python def test_collection_tree_endpoint(tmp_path: Path) -> None: app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient()) endpoint = _route(app, "/api/zotero/collections/tree", "GET") payload = endpoint() assert payload["collections"][0]["collection_key"] == "COLL0001" assert payload["collections"][0]["children"][0]["collection_key"] == "COLL0002" def test_collection_items_endpoint_includes_descendants(tmp_path: Path) -> None: app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient()) endpoint = _route(app, "/api/zotero/collections/{collection_key}/items", "GET") payload = endpoint("COLL0001", True) assert payload["items"][0]["item_key"] == "PAPER0001" ``` - [ ] **Step 2: Run test to verify it fails** Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py::test_collection_tree_endpoint tests/test_api.py::test_collection_items_endpoint_includes_descendants -q` Expected: FAIL because the routes are missing. - [ ] **Step 3: Write minimal implementation** ```python @app.get("/api/zotero/collections/tree") def zotero_collection_tree() -> dict[str, object]: return {"collections": reader.get_collection_tree()} @app.get("/api/zotero/collections/{collection_key}/items") def zotero_collection_items(collection_key: str, include_descendants: bool = True) -> dict[str, object]: return { "collection_key": collection_key, "items": reader.get_collection_items(collection_key, include_descendants=include_descendants), } ``` - [ ] **Step 4: Run test to verify it passes** Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py::test_collection_tree_endpoint tests/test_api.py::test_collection_items_endpoint_includes_descendants -q` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/zotero_kb/api.py tests/test_api.py git commit -m "feat: expose zotero collection import api" ``` ### Task 3: Replace Left-Panel Import UI **Files:** - Modify: `src/zotero_kb/templates/index.html` - Modify: `tests/test_ui.py` - [ ] **Step 1: Write the failing UI test** ```python def test_index_contains_collection_import_controls(tmp_path) -> None: app = create_app( AppConfig( workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero", bridge_file=tmp_path / "bridge.json", ) ) endpoint = _route(app, "/", "GET") html = endpoint() assert 'id="collection-tree"' in html assert 'id="collection-items"' in html assert 'id="select-descendants-button"' in html assert 'id="clear-selection-button"' in html assert 'id="import-selected-items-button"' in html ``` - [ ] **Step 2: Run test to verify it fails** Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_collection_import_controls -q` Expected: FAIL because the template still contains `import-selected-button` and `zotero-search-form` instead of the new collection importer ids. - [ ] **Step 3: Write minimal implementation** ```html