diff --git a/docs/superpowers/plans/2026-04-15-import-card-two-step.md b/docs/superpowers/plans/2026-04-15-import-card-two-step.md new file mode 100644 index 0000000..d826f08 --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-import-card-two-step.md @@ -0,0 +1,662 @@ +# 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 `