docs: add two-step import+card generation implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5899e44791
commit
cc1960e9a9
662
docs/superpowers/plans/2026-04-15-import-card-two-step.md
Normal file
662
docs/superpowers/plans/2026-04-15-import-card-two-step.md
Normal file
@ -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 <noreply@anthropic.com>
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 <noreply@anthropic.com>
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 <noreply@anthropic.com>
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
<div class="import-split">
|
||||
<div class="import-left">
|
||||
<div class="import-list-header">
|
||||
<span>Pending (<span id="pending-count">0</span>)</span>
|
||||
<button type="button" id="select-all-pending" class="link-btn">Select All</button>
|
||||
</div>
|
||||
<div class="import-list" id="import-items-list">
|
||||
<!-- populated by JS -->
|
||||
</div>
|
||||
<div class="import-list-footer">
|
||||
Done (<span id="done-count">0</span>)
|
||||
</div>
|
||||
</div>
|
||||
<div class="import-right">
|
||||
<div id="item-preview">
|
||||
<p class="hint">Click an item to preview</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="import-actions">
|
||||
<button type="button" id="generate-cards-btn" class="primary" disabled>生成选中卡片 (0)</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**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 = `
|
||||
<input type="checkbox" ${isDone ? 'disabled' : ''} data-key="${item.item_key}">
|
||||
<div>
|
||||
<div class="item-title">${escapeHtml(item.title)}</div>
|
||||
<div class="item-meta">${item.creators.join(', ')} · ${item.year || 'n.d.'}</div>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="preview-title">${escapeHtml(item.title)}</div>
|
||||
<div class="preview-meta">${item.creators.join(', ')} · ${item.year || 'n.d.'} · ${item.item_type}</div>
|
||||
<div class="preview-abstract">${escapeHtml(item.abstract || 'No abstract available.')}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `<style>` block, and add all JS functions to the `<script>` section. Wire up the event listeners.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -v -k "two_column or import_state"`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/zotero_kb/templates/index.html tests/test_ui.py
|
||||
git commit -m "feat: add two-column import window layout with batch generate
|
||||
|
||||
Import window now shows pending/done items in left column, preview in
|
||||
right column. 'Generate Selected Cards' button triggers batch
|
||||
generation with real-time progress.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: End-to-End Verification
|
||||
|
||||
**Files:**
|
||||
- None (manual + test verification)
|
||||
|
||||
- [ ] **Step 1: Start the app and verify the flow**
|
||||
|
||||
```bash
|
||||
UV_CACHE_DIR=/tmp/uv-cache uv run python main.py
|
||||
```
|
||||
|
||||
1. Open http://localhost:8000
|
||||
2. Create/open a project
|
||||
3. Open import window — verify it shows pending items (empty cards list initially)
|
||||
4. Select items → click "生成选中卡片"
|
||||
5. Verify items move from pending to done after generation
|
||||
|
||||
- [ ] **Step 2: Run full test suite**
|
||||
|
||||
```bash
|
||||
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
Expected: All 29+ tests pass
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "test: add UI tests for two-column import layout
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
1. **Spec coverage:** Each requirement from `2026-04-15-import-card-two-step-design.md` is addressed:
|
||||
- `GET /import-state` → Task 1
|
||||
- `POST /cards/generate` → Task 3
|
||||
- `import_item_keys` no longer generates → Task 2
|
||||
- Two-column layout + batch generate → Task 4
|
||||
- Real-time progress → Task 4 (JS progress counter in button)
|
||||
- Deduplication via source_hash → Already exists in `CardBuilder.build_or_update()`
|
||||
|
||||
2. **Placeholder scan:** No "TBD", "TODO", or vague steps found.
|
||||
|
||||
3. **Type consistency:** All method names match across tasks:
|
||||
- `project_service._read_selected_items()` — used in Task 1, defined in `projects.py`
|
||||
- `CardBuilder.build_or_update()` — called in Task 3, already exists in `cards.py`
|
||||
- `ZoteroReader.read_items()` — used in Tasks 2 and 3, already exists
|
||||
- `GenerateCardsRequest` model — defined in Task 3, used in API endpoint
|
||||
Loading…
Reference in New Issue
Block a user