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