# Two-Step Import + Card Generation 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:** Split the current "import文献 + immediately generate cards" flow into: (1) import items to project, check global cards.json for existing cards, (2) manual batch select + generate cards. **Architecture:** Backend: two new API endpoints (`import-state`, `cards/generate`). `imports/item-keys` no longer generates cards — it only registers items. `CardBuilder.build_or_update()` already computes `source_hash` and skips LLM if hash unchanged (dedup). Frontend: import window two-column layout with pending/done visual states and batch generate button with real-time progress. **Tech Stack:** Python/FastAPI, vanilla JS, HTML/CSS. --- ## Task 1: Backend — `GET /api/projects/{project_id}/import-state` **Files:** - Modify: `src/zotero_kb/api.py` (add endpoint) - Test: `tests/test_api.py` - [ ] **Step 1: Write the failing test** In `tests/test_api.py`, add test `test_import_state_returns_pending_and_done`: ```python def test_import_state_returns_pending_and_done(tmp_path, monkeypatch): from zotero_kb.api import create_app from zotero_kb.config import AppConfig from zotero_kb.llm import DeterministicCardClient # Setup: create project with 2 items, one has card workspace = tmp_path / "workspace" items_index = workspace / "library" / "index" / "items.json" cards_index = workspace / "library" / "index" / "cards.json" items_index.parent.mkdir(parents=True) cards_index.parent.mkdir(parents=True) items_index.write_text(json.dumps({ "KEY1": {"item_key": "KEY1", "title": "Paper A"}, "KEY2": {"item_key": "KEY2", "title": "Paper B"}, })) cards_index.write_text(json.dumps({ "KEY1": {"item_key": "KEY1", "title": "Paper A"} # KEY1 has card, KEY2 does not })) project_dir = workspace / "projects" / "proj1" project_dir.mkdir(parents=True) (project_dir / "selected-items.json").write_text(json.dumps(["KEY1", "KEY2"])) config = AppConfig(workspace_dir=workspace, zotero_data_dir=tmp_path / "zotero") app = create_app(config, llm_client=DeterministicCardClient()) response = client.get("/api/projects/proj1/import-state") assert response.status_code == 200 data = response.json() assert data["pending_count"] == 1 assert data["done_count"] == 1 items_by_key = {it["item_key"]: it for it in data["items"]} assert items_by_key["KEY1"]["card_status"] == "done" assert items_by_key["KEY2"]["card_status"] == "pending" ``` - [ ] **Step 2: Run test to verify it fails** Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py::test_import_state_returns_pending_and_done -v` Expected: FAIL — endpoint not defined - [ ] **Step 3: Implement the endpoint** Add to `api.py`: ```python @app.get("/api/projects/{project_id}/import-state") def get_import_state(project_id: str) -> dict[str, object]: project_dir = config.workspace_dir / "projects" / project_id project_file = project_dir / "project.json" if not project_file.exists(): raise HTTPException(status_code=404, detail="Project not found") selected_items = project_service._read_selected_items(project_id) cards_index = projects._read_json(config.workspace_dir / "library" / "index" / "cards.json") items_index = projects._read_json(config.workspace_dir / "library" / "index" / "items.json") items = [] pending_count = 0 done_count = 0 for item_key in selected_items: item_data = items_index.get(item_key, {}) card_status = "done" if item_key in cards_index else "pending" if card_status == "done": done_count += 1 else: pending_count += 1 items.append({ "item_key": item_key, "title": item_data.get("title", ""), "creators": item_data.get("creators", []), "year": item_data.get("year"), "item_type": item_data.get("item_type", ""), "card_status": card_status, }) return { "project_id": project_id, "items": items, "pending_count": pending_count, "done_count": done_count, } ``` - [ ] **Step 4: Run test to verify it passes** Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py::test_import_state_returns_pending_and_done -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/zotero_kb/api.py tests/test_api.py git commit -m "feat: add GET /api/projects/{id}/import-state endpoint Returns pending/done status for each item in a project by checking selected-items.json against cards.json. Co-Authored-By: Claude Opus 4.6 " ``` --- ## Task 2: Backend — Modify `POST /api/projects/{project_id}/imports/item-keys` to not generate cards **Files:** - Modify: `src/zotero_kb/api.py` (lines ~127-155, the `import_item_keys` endpoint) - Test: `tests/test_api.py` - [ ] **Step 1: Write the failing test** Add `test_import_item_keys_does_not_generate_cards`: ```python def test_import_item_keys_does_not_generate_cards(tmp_path, monkeypatch): from zotero_kb.api import create_app from zotero_kb.config import AppConfig from zotero_kb.llm import DeterministicCardClient workspace = tmp_path / "workspace" items_index = workspace / "library" / "index" / "items.json" items_index.parent.mkdir(parents=True) items_index.write_text(json.dumps({})) project_dir = workspace / "projects" / "proj1" project_dir.mkdir(parents=True) (project_dir / "project.json").write_text(json.dumps({"project_id": "proj1", "name": "Test"})) config = AppConfig(workspace_dir=workspace, zotero_data_dir=tmp_path / "zotero") app = create_app(config, llm_client=DeterministicCardClient()) # Mock reader to return fake items class FakeReader: def read_items(self, keys): return [ZoteroItemRecord(item_key=k, title=f"Paper {k}", creators=[], year=2024, item_type="journalArticle", abstract="", tags=[], collection_paths=[], notes=[], attachment_texts=[]) for k in keys] monkeypatch.setattr("zotero_kb.api.ZoteroReader", lambda *a, **k: FakeReader()) response = client.post("/api/projects/proj1/imports/item-keys", json={"item_keys": ["KEY1"]}) assert response.status_code == 200 data = response.json() assert data["imported_item_keys"] == ["KEY1"] # Verify cards.json was NOT created (no card generation) cards_index = workspace / "library" / "index" / "cards.json" assert not cards_index.exists() ``` - [ ] **Step 2: Run test to verify it fails** Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py::test_import_item_keys_does_not_generate_cards -v` Expected: FAIL — currently it calls `CardBuilder.build_or_update()` which creates cards - [ ] **Step 3: Modify `import_item_keys` to not generate cards** Change the `import_item_keys` endpoint body from: ```python builder = CardBuilder(config.workspace_dir, resolved_client) items = reader.read_items(item_keys) imported_keys = [] for item in items: builder.build_or_update(item) # REMOVE THIS imported_keys.append(item.item_key) ``` to: ```python items = reader.read_items(item_keys) imported_keys = [item.item_key for item in items] # NOTE: card generation happens later via explicit /cards/generate call # Ensure items are registered in items.json for later reference items_index = projects._read_json(config.workspace_dir / "library" / "index" / "items.json") for item in items: items_index[item.item_key] = { "item_key": item.item_key, "title": item.title, "creators": item.creators, "year": item.year, "item_type": item.item_type, "abstract": item.abstract, "tags": item.tags, "collection_paths": item.collection_paths, } projects._write_json(config.workspace_dir / "library" / "index" / "items.json", items_index) ``` Also remove the LLM client creation since it's no longer needed for this endpoint. - [ ] **Step 4: Run test to verify it passes** Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py::test_import_item_keys_does_not_generate_cards -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/zotero_kb/api.py tests/test_api.py git commit -m "refactor: import_item_keys no longer generates cards immediately Items are registered in items.json but cards are generated later via explicit POST /projects/{id}/cards/generate call. Co-Authored-By: Claude Opus 4.6 " ``` --- ## Task 3: Backend — `POST /api/projects/{project_id}/cards/generate` **Files:** - Modify: `src/zotero_kb/api.py` (add endpoint) - Modify: `src/zotero_kb/projects.py` (add `add_items` helper) - Test: `tests/test_api.py` - [ ] **Step 1: Write the failing test** Add `test_cards_generate_creates_cards_for_pending_items`: ```python def test_cards_generate_creates_cards_for_pending_items(tmp_path, monkeypatch): from zotero_kb.api import create_app from zotero_kb.config import AppConfig from zotero_kb.llm import DeterministicCardClient workspace = tmp_path / "workspace" items_index = workspace / "library" / "index" / "items.json" cards_index = workspace / "library" / "index" / "cards.json" items_index.parent.mkdir(parents=True) cards_index.parent.mkdir(parents=True) items_index.write_text(json.dumps({ "KEY1": {"item_key": "KEY1", "title": "Paper A", "creators": ["Author A"], "year": 2024, "item_type": "journalArticle", "abstract": "Abstract A", "tags": [], "collection_paths": []}, })) cards_index.write_text(json.dumps({})) # no existing cards project_dir = workspace / "projects" / "proj1" project_dir.mkdir(parents=True) (project_dir / "project.json").write_text(json.dumps({"project_id": "proj1", "name": "Test", "llm": {"provider": "deterministic", "model": "deterministic"}})) (project_dir / "selected-items.json").write_text(json.dumps(["KEY1"])) config = AppConfig(workspace_dir=workspace, zotero_data_dir=tmp_path / "zotero") app = create_app(config, llm_client=DeterministicCardClient()) response = client.post("/api/projects/proj1/cards/generate", json={"item_keys": ["KEY1"]}) assert response.status_code == 200 data = response.json() assert data["generated"] == ["KEY1"] assert data["failed"] == [] assert data["items"][0]["card_status"] == "done" # Verify cards.json now has the card cards = json.loads(cards_index.read_text()) assert "KEY1" in cards ``` - [ ] **Step 2: Run test to verify it fails** Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py::test_cards_generate_creates_cards_for_pending_items -v` Expected: FAIL — endpoint not defined - [ ] **Step 3: Implement the endpoint** Add to `api.py`: ```python class GenerateCardsRequest(BaseModel): item_keys: list[str] = Field(min_length=1) @app.post("/api/projects/{project_id}/cards/generate") def generate_cards(project_id: str, payload: GenerateCardsRequest | dict[str, object]) -> dict[str, object]: project_file = config.workspace_dir / "projects" / project_id / "project.json" if not project_file.exists(): raise HTTPException(status_code=404, detail="Project not found") if isinstance(payload, dict): item_keys = [str(value) for value in payload.get("item_keys", [])] else: item_keys = [str(value) for value in payload.item_keys] project_payload = _read_json(project_file) llm_payload = project_payload.get("llm", {}) try: resolved_client = llm_client or create_card_generation_client( str(llm_payload.get("provider", "deterministic")), str(llm_payload.get("model", "deterministic")), ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc builder = CardBuilder(config.workspace_dir, resolved_client) reader = ZoteroReader(config.zotero_data_dir) items = reader.read_items(item_keys) generated = [] failed = [] result_items = [] for item in items: try: result = builder.build_or_update(item) generated.append(item.item_key) card_status = "done" except Exception as exc: failed.append(item.item_key) card_status = "failed" result_items.append({ "item_key": item.item_key, "title": item.title, "creators": item.creators, "year": item.year, "item_type": item.item_type, "card_status": card_status, }) project_service.add_items(project_id, generated) return { "project_id": project_id, "generated": generated, "failed": failed, "items": result_items, } ``` - [ ] **Step 4: Run test to verify it passes** Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py::test_cards_generate_creates_cards_for_pending_items -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/zotero_kb/api.py tests/test_api.py git commit -m "feat: add POST /api/projects/{id}/cards/generate endpoint Generates cards for specified item_keys via CardBuilder. Returns generated/failed lists and updated item states. Co-Authored-By: Claude Opus 4.6 " ``` --- ## Task 4: Frontend — Import Window Two-Column Layout **Files:** - Modify: `src/zotero_kb/templates/index.html` (import window HTML + JS) This is the most complex task. Changes span: 1. Add import-state API call function 2. Add cards/generate API call function 3. Replace current import window content with two-column layout 4. Add pending/done visual states and checkbox logic 5. Add batch generate button with progress feedback **Layout structure (HTML, inside `.window-frame.import-window`):** ```html
Pending (0)

Click an item to preview

``` **CSS additions:** ```css .import-split { display: grid; grid-template-columns: 40% 60%; height: 100%; gap: 1px; background: var(--line); } .import-left, .import-right { background: var(--surface); overflow-y: auto; padding: 1rem; } .import-list-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; font-weight: 600; } .import-list { display: flex; flex-direction: column; gap: 0.25rem; } .import-item { display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.5rem; border-radius: 6px; cursor: pointer; } .import-item.pending { background: var(--panel); } .import-item.done { background: transparent; opacity: 0.7; } .import-item.selected { outline: 2px solid var(--accent, #4a90d9); } .import-item input[type="checkbox"] { margin-top: 0.25rem; } .import-item.done input[type="checkbox"] { display: none; } .import-item .item-title { font-size: 0.875rem; font-weight: 500; } .import-item .item-meta { font-size: 0.75rem; color: var(--ink-dim, #666); } .import-list-footer { margin-top: 0.75rem; font-weight: 600; font-size: 0.875rem; } .import-actions { padding: 0.75rem 1rem; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; } #generate-cards-btn:disabled { opacity: 0.5; cursor: not-allowed; } #generate-cards-btn .progress { font-size: 0.875rem; } .link-btn { background: none; border: none; color: var(--accent, #4a90d9); cursor: pointer; font-size: 0.8rem; } #item-preview { } #item-preview .preview-title { font-size: 1rem; font-weight: 600; margin-bottom: 0.5rem; } #item-preview .preview-meta { font-size: 0.8rem; color: var(--ink-dim); margin-bottom: 0.75rem; } #item-preview .preview-abstract { font-size: 0.85rem; line-height: 1.5; } .hint { color: var(--ink-dim); font-size: 0.875rem; } ``` **JS additions (state + functions):** ```javascript // State state.importItems = []; // items from import-state state.selectedPendingKeys = new Set(); state.isGenerating = false; state.generatingProgress = { current: 0, total: 0 }; // Functions async function loadImportState() { const resp = await fetch(`/api/projects/${state.currentProjectId}/import-state`); const data = await resp.json(); state.importItems = data.items; renderImportItemList(); updateImportCounts(data.pending_count, data.done_count); } function renderImportItemList() { const list = document.getElementById('import-items-list'); list.innerHTML = ''; state.importItems.forEach(item => { const isDone = item.card_status === 'done'; const div = document.createElement('div'); div.className = `import-item ${item.card_status}`; div.dataset.key = item.item_key; div.innerHTML = `
${escapeHtml(item.title)}
${item.creators.join(', ')} · ${item.year || 'n.d.'}
`; if (!isDone) { div.querySelector('input').addEventListener('change', (e) => { if (e.target.checked) state.selectedPendingKeys.add(item.item_key); else state.selectedPendingKeys.delete(item.item_key); updateGenerateButton(); div.classList.toggle('selected', e.target.checked); }); div.addEventListener('click', (e) => { if (e.target.tagName !== 'INPUT') showItemPreview(item); }); } div.addEventListener('click', (e) => { if (e.target.tagName !== 'INPUT') showItemPreview(item); }); list.appendChild(div); }); } function showItemPreview(item) { document.getElementById('item-preview').innerHTML = `
${escapeHtml(item.title)}
${item.creators.join(', ')} · ${item.year || 'n.d.'} · ${item.item_type}
${escapeHtml(item.abstract || 'No abstract available.')}
`; } function updateGenerateButton() { const btn = document.getElementById('generate-cards-btn'); const count = state.selectedPendingKeys.size; btn.disabled = count === 0 || state.isGenerating; btn.textContent = state.isGenerating ? `生成中 ${state.generatingProgress.current}/${state.generatingProgress.total}` : `生成选中卡片 (${count})`; } async function generateSelectedCards() { const keys = Array.from(state.selectedPendingKeys); if (!keys.length) return; state.isGenerating = true; state.generatingProgress = { current: 0, total: keys.length }; updateGenerateButton(); const btn = document.getElementById('generate-cards-btn'); try { const resp = await fetch(`/api/projects/${state.currentProjectId}/cards/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ item_keys: keys }), }); const data = await resp.json(); // Update item states data.items.forEach(updated => { const item = state.importItems.find(i => i.item_key === updated.item_key); if (item) item.card_status = updated.card_status; }); state.selectedPendingKeys.clear(); renderImportItemList(); const counts = { pending: state.importItems.filter(i => i.card_status === 'pending').length, done: state.importItems.filter(i => i.card_status === 'done').length, }; updateImportCounts(counts.pending, counts.done); } finally { state.isGenerating = false; updateGenerateButton(); } } function updateImportCounts(pending, done) { document.getElementById('pending-count').textContent = pending; document.getElementById('done-count').textContent = done; } ``` **Wiring:** - In `openImportWindow()`: call `loadImportState()` - In `closeImportWindow()`: clear `state.importItems` and `state.selectedPendingKeys` - `document.getElementById('select-all-pending').addEventListener('click', ...)` — select all pending checkboxes - `document.getElementById('generate-cards-btn').addEventListener('click', generateSelectedCards)` - [ ] **Step 1: Write the failing test** Add to `tests/test_ui.py`: ```python def test_index_import_window_has_two_column_layout(): html = (Path(__file__).parent.parent / "src" / "zotero_kb" / "templates" / "index.html").read_text() assert 'class="import-split"' in html assert 'class="import-left"' in html assert 'class="import-right"' in html assert 'id="generate-cards-btn"' in html assert 'id="import-items-list"' in html assert 'id="pending-count"' in html assert 'id="done-count"' in html def test_index_has_import_state_api_call(): html = (Path(__file__).parent.parent / "src" / "zotero_kb" / "templates" / "index.html").read_text() assert '/api/projects/' in html and 'import-state' in html assert '/api/projects/' in html and 'cards/generate' in html ``` - [ ] **Step 2: Run tests to verify they fail** Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -v -k "two_column or import_state"` Expected: FAIL — new elements don't exist yet - [ ] **Step 3: Implement the two-column layout** Replace the current `.window-body` content inside `.import-window` with the two-column HTML structure above, add all CSS to the `