zotero-kb/docs/superpowers/plans/2026-04-15-zotero-collection-import-modal.md
2026-04-16 13:34:39 +08:00

712 lines
22 KiB
Markdown

# Zotero Collection Import Modal Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the crowded inline Zotero importer with a modal-based importer that preserves session selection state, keeps rows title-only by default, and shows abstracts through delayed preview.
**Architecture:** Keep the existing FastAPI backend and current collection/item endpoints unchanged. Refactor the single-page `index.html` template so the main page only renders an import trigger, while a hidden modal owns the collection tree, item list, sticky actions, and preview popover state. Reuse the existing in-page state object and fetch helpers, extending them for modal lifecycle and delayed preview behavior.
**Tech Stack:** FastAPI, inline HTML/CSS/vanilla JavaScript, pytest
---
## File Structure
- Modify: `src/zotero_kb/templates/index.html`
- Replace the inline importer markup with a single trigger button
- Add modal shell, overlay, sticky action bar, and abstract preview popover
- Update the in-page JavaScript state and event handlers for modal lifecycle and delayed preview
- Modify: `tests/test_ui.py`
- Replace assertions for the old inline importer with assertions for the trigger button, modal shell, and preview hooks
- Modify: `README.md`
- Update the UI walkthrough so it describes opening the import modal instead of using the inline importer
### Task 1: Lock The Modal UI Contract In Tests
**Files:**
- Modify: `tests/test_ui.py`
- Read: `src/zotero_kb/templates/index.html`
- [ ] **Step 1: Write the failing test**
Replace the existing inline-import assertions with this test body:
```python
from zotero_kb.api import create_app
from zotero_kb.config import AppConfig
def test_index_contains_import_modal_controls(tmp_path) -> None:
app = create_app(
AppConfig(
workspace_dir=tmp_path / "workspace",
zotero_data_dir=tmp_path / "zotero",
bridge_file=tmp_path / "bridge.json",
)
)
for route in app.routes:
if getattr(route, "path", None) == "/" and "GET" in getattr(route, "methods", set()):
html = route.endpoint()
break
else:
raise AssertionError("GET / route not found")
assert 'id="create-project-form"' in html
assert 'id="open-import-modal-button"' in html
assert 'id="import-modal"' in html
assert 'id="close-import-modal-button"' in html
assert 'id="modal-collection-tree"' in html
assert 'id="modal-collection-items"' in html
assert 'id="modal-selected-count"' in html
assert 'id="modal-select-descendants-button"' in html
assert 'id="modal-clear-selection-button"' in html
assert 'id="modal-import-selected-items-button"' in html
assert 'id="item-preview-popover"' in html
assert 'const ABSTRACT_PREVIEW_DELAY_MS = 3000;' in html
assert 'isImportModalOpen: false' in html
assert 'id="recommend-form"' in html
assert 'id="plan-form"' in html
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v
```
Expected: FAIL because the template still contains `collection-tree`, `collection-items`, and the old inline action ids instead of the modal ids.
- [ ] **Step 3: Update the test file**
Make `tests/test_ui.py` exactly:
```python
from zotero_kb.api import create_app
from zotero_kb.config import AppConfig
def test_index_contains_import_modal_controls(tmp_path) -> None:
app = create_app(
AppConfig(
workspace_dir=tmp_path / "workspace",
zotero_data_dir=tmp_path / "zotero",
bridge_file=tmp_path / "bridge.json",
)
)
for route in app.routes:
if getattr(route, "path", None) == "/" and "GET" in getattr(route, "methods", set()):
html = route.endpoint()
break
else:
raise AssertionError("GET / route not found")
assert 'id="create-project-form"' in html
assert 'id="open-import-modal-button"' in html
assert 'id="import-modal"' in html
assert 'id="close-import-modal-button"' in html
assert 'id="modal-collection-tree"' in html
assert 'id="modal-collection-items"' in html
assert 'id="modal-selected-count"' in html
assert 'id="modal-select-descendants-button"' in html
assert 'id="modal-clear-selection-button"' in html
assert 'id="modal-import-selected-items-button"' in html
assert 'id="item-preview-popover"' in html
assert 'const ABSTRACT_PREVIEW_DELAY_MS = 3000;' in html
assert 'isImportModalOpen: false' in html
assert 'id="recommend-form"' in html
assert 'id="plan-form"' in html
```
- [ ] **Step 4: Run test to verify it still fails for the right reason**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v
```
Expected: FAIL on missing modal ids in `index.html`.
- [ ] **Step 5: Commit**
```bash
git add tests/test_ui.py
git commit -m "test: define modal import ui contract"
```
### Task 2: Replace The Inline Importer Markup With A Modal Shell
**Files:**
- Modify: `src/zotero_kb/templates/index.html`
- Test: `tests/test_ui.py`
- [ ] **Step 1: Write the failing implementation target**
In `src/zotero_kb/templates/index.html`, replace the old inline import section:
```html
<div class="section surface">
<h3>从 Zotero 导入</h3>
<p class="meta">按当前 Zotero collection 结构浏览,选择目录后批量导入该目录及子目录的文献。</p>
<div id="collection-status" class="status"></div>
<div class="import-region">
<div>
<small>Collections</small>
<div id="collection-tree" class="import-scroll tree-list"></div>
</div>
<div>
<small>Collection Items</small>
<div id="collection-items" class="import-scroll collection-items-list"></div>
</div>
<div class="action-bar">
<div id="selected-count" class="meta">已选 0 篇</div>
<button id="select-descendants-button" type="button" class="secondary">全选当前目录及子目录</button>
<button id="clear-selection-button" type="button" class="danger">清空选择</button>
<button id="import-selected-items-button" type="button">导入所选到当前项目</button>
</div>
</div>
</div>
```
with a trigger plus modal shell:
```html
<div class="section surface">
<h3>导入文献</h3>
<p class="meta">打开子界面浏览 Zotero collection 树,再批量导入到当前项目。</p>
<button id="open-import-modal-button" type="button">导入文献</button>
</div>
<div id="import-modal" class="modal-shell" hidden aria-hidden="true">
<button id="import-modal-overlay" class="modal-overlay" type="button" aria-label="关闭导入弹窗"></button>
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="import-modal-title">
<header class="modal-header">
<div>
<h3 id="import-modal-title">Import From Zotero</h3>
<p id="import-modal-project-label" class="meta">未选择项目</p>
</div>
<div class="modal-header-actions">
<div id="modal-selected-count" class="meta">已选 0 篇</div>
<button id="close-import-modal-button" type="button" class="secondary">关闭</button>
</div>
</header>
<div id="collection-status" class="status"></div>
<div class="modal-body">
<div class="modal-pane">
<small>Collections</small>
<div id="modal-collection-tree" class="modal-scroll tree-list"></div>
</div>
<div class="modal-pane">
<small>Collection Items</small>
<div id="modal-collection-items" class="modal-scroll collection-items-list"></div>
</div>
</div>
<footer class="modal-action-bar">
<button id="modal-select-descendants-button" type="button" class="secondary">全选当前目录及子目录</button>
<button id="modal-clear-selection-button" type="button" class="danger">清空选择</button>
<button id="modal-import-selected-items-button" type="button">导入所选到当前项目</button>
</footer>
<div id="item-preview-popover" class="preview-popover" hidden></div>
</section>
</div>
```
- [ ] **Step 2: Run the UI test to verify the markup is still missing**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v
```
Expected: FAIL until the new markup and ids are present.
- [ ] **Step 3: Add the modal CSS and markup**
In the `<style>` block, add these rules and remove `import-region`-specific layout rules that only served the inline importer:
```css
.modal-shell[hidden] {
display: none;
}
.modal-shell {
position: fixed;
inset: 0;
z-index: 40;
}
.modal-overlay {
position: absolute;
inset: 0;
border-radius: 0;
background: rgba(24, 28, 22, 0.42);
}
.modal-card {
position: relative;
z-index: 1;
width: min(1100px, calc(100vw - 2rem));
max-height: calc(100vh - 2rem);
margin: 1rem auto;
display: grid;
grid-template-rows: auto auto 1fr auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: 0 24px 60px rgba(35, 31, 21, 0.2);
overflow: hidden;
}
.modal-header,
.modal-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--line);
}
.modal-action-bar {
border-top: 1px solid var(--line);
border-bottom: 0;
}
.modal-body {
display: grid;
grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.2fr);
min-height: 24rem;
}
.modal-pane {
display: grid;
gap: 0.6rem;
padding: 1rem 1.25rem;
min-height: 0;
}
.modal-scroll {
min-height: 0;
overflow: auto;
}
.preview-popover {
position: absolute;
max-width: 28rem;
padding: 0.85rem 1rem;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255, 253, 248, 0.98);
box-shadow: 0 18px 40px rgba(35, 31, 21, 0.16);
color: var(--ink);
}
@media (max-width: 980px) {
.modal-card {
width: calc(100vw - 1rem);
margin: 0.5rem auto;
max-height: calc(100vh - 1rem);
}
.modal-body {
grid-template-columns: 1fr;
grid-template-rows: minmax(12rem, 1fr) minmax(12rem, 1fr);
}
.modal-header,
.modal-action-bar {
flex-wrap: wrap;
}
}
```
- [ ] **Step 4: Run the targeted test to verify it passes**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/zotero_kb/templates/index.html tests/test_ui.py
git commit -m "feat: add zotero import modal shell"
```
### Task 3: Move Import State And Actions Into The Modal
**Files:**
- Modify: `src/zotero_kb/templates/index.html`
- Test: `tests/test_ui.py`
- Read: `tests/test_api.py`
- [ ] **Step 1: Add a failing assertion for modal state hooks**
Append these assertions to `test_index_contains_import_modal_controls`:
```python
assert "function openImportModal()" in html
assert "function closeImportModal()" in html
assert "function updateImportActionState()" in html
assert 'document.body.classList.toggle("modal-open"' in html
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v
```
Expected: FAIL because the current script does not expose modal lifecycle helpers.
- [ ] **Step 3: Update the state object, element bindings, and modal actions**
In `src/zotero_kb/templates/index.html`, update the script setup to include modal state and new DOM bindings:
```javascript
const ABSTRACT_PREVIEW_DELAY_MS = 3000;
const state = {
projects: [],
currentProjectId: null,
currentCards: [],
collectionTree: [],
isImportModalOpen: false,
selectedCollectionKey: null,
expandedCollectionKeys: new Set(),
selectedItemKeys: new Set(),
visibleCollectionItems: [],
hoveredItemKey: null,
hoverTimerId: null,
previewItemKey: null,
};
const elements = {
openImportModalButton: document.getElementById("open-import-modal-button"),
importModal: document.getElementById("import-modal"),
importModalOverlay: document.getElementById("import-modal-overlay"),
closeImportModalButton: document.getElementById("close-import-modal-button"),
importModalProjectLabel: document.getElementById("import-modal-project-label"),
collectionStatus: document.getElementById("collection-status"),
collectionTree: document.getElementById("modal-collection-tree"),
collectionItems: document.getElementById("modal-collection-items"),
selectedCount: document.getElementById("modal-selected-count"),
selectDescendantsButton: document.getElementById("modal-select-descendants-button"),
clearSelectionButton: document.getElementById("modal-clear-selection-button"),
importSelectedItemsButton: document.getElementById("modal-import-selected-items-button"),
previewPopover: document.getElementById("item-preview-popover"),
// keep existing project, cards, and writing bindings unchanged
};
function openImportModal() {
state.isImportModalOpen = true;
elements.importModal.hidden = false;
elements.importModal.setAttribute("aria-hidden", "false");
document.body.classList.toggle("modal-open", true);
updateImportActionState();
}
function closeImportModal() {
state.isImportModalOpen = false;
elements.importModal.hidden = true;
elements.importModal.setAttribute("aria-hidden", "true");
document.body.classList.toggle("modal-open", false);
cancelAbstractPreview();
}
function updateImportActionState() {
const hasProject = Boolean(state.currentProjectId);
const hasSelection = state.selectedItemKeys.size > 0;
elements.importModalProjectLabel.textContent = hasProject ? `当前项目:${state.currentProjectId}` : "未选择项目";
elements.importSelectedItemsButton.disabled = !hasProject || !hasSelection;
elements.importSelectedItemsButton.title = hasProject ? "" : "请先选择项目";
}
elements.openImportModalButton.addEventListener("click", openImportModal);
elements.closeImportModalButton.addEventListener("click", closeImportModal);
elements.importModalOverlay.addEventListener("click", closeImportModal);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.isImportModalOpen) {
closeImportModal();
}
});
```
Then update the existing import callbacks:
```javascript
async function importSelectedItems() {
if (!state.currentProjectId || !state.selectedItemKeys.size) {
updateImportActionState();
return;
}
setStatus(elements.collectionStatus, "正在导入...");
const payload = await api(`/api/projects/${state.currentProjectId}/imports/item-keys`, {
method: "POST",
body: JSON.stringify({ item_keys: Array.from(state.selectedItemKeys) }),
});
state.selectedItemKeys.clear();
renderSelectedCount();
renderCollectionItems();
renderCards(payload.project_view.cards || []);
setStatus(elements.collectionStatus, "导入完成。");
updateImportActionState();
closeImportModal();
}
```
Finally, make `selectProject()` and `toggleItemSelection()` call `updateImportActionState()` after they mutate project or selection state.
- [ ] **Step 4: Run focused UI and API regression tests**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py tests/test_api.py -q
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/zotero_kb/templates/index.html tests/test_ui.py
git commit -m "feat: wire modal import state"
```
### Task 4: Add Delayed Abstract Preview And Title-Only Rows
**Files:**
- Modify: `src/zotero_kb/templates/index.html`
- Modify: `tests/test_ui.py`
- [ ] **Step 1: Extend the UI test with preview-hook assertions**
Append these assertions to `test_index_contains_import_modal_controls`:
```python
assert "function scheduleAbstractPreview(item, target)" in html
assert "function cancelAbstractPreview()" in html
assert "function showAbstractPreview(item, target)" in html
assert "details-button" in html
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v
```
Expected: FAIL because preview helpers and the touch fallback trigger are not present yet.
- [ ] **Step 3: Update row rendering and preview helpers**
In `renderCollectionItems()`, replace the current row template:
```javascript
row.innerHTML = `
<div class="checkbox-row">
<input type="checkbox" ${state.selectedItemKeys.has(item.item_key) ? "checked" : ""} />
<div>
<strong>${item.title}</strong>
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
<p>${item.abstract || "暂无摘要"}</p>
</div>
</div>
`;
```
with a compact title-only row:
```javascript
row.innerHTML = `
<div class="checkbox-row">
<input type="checkbox" ${state.selectedItemKeys.has(item.item_key) ? "checked" : ""} />
<div class="item-row-body">
<button type="button" class="title-button">${item.title}</button>
<button type="button" class="secondary details-button" aria-label="查看摘要">i</button>
</div>
</div>
`;
const checkbox = row.querySelector('input[type="checkbox"]');
const titleButton = row.querySelector(".title-button");
const detailsButton = row.querySelector(".details-button");
checkbox.addEventListener("change", (event) => {
cancelAbstractPreview();
toggleItemSelection(item.item_key, event.target.checked);
});
titleButton.addEventListener("mouseenter", () => scheduleAbstractPreview(item, titleButton));
titleButton.addEventListener("mouseleave", cancelAbstractPreview);
detailsButton.addEventListener("click", () => showAbstractPreview(item, detailsButton));
```
Add the preview helpers near the other rendering utilities:
```javascript
function scheduleAbstractPreview(item, target) {
cancelAbstractPreview();
if (!item.abstract) {
return;
}
state.hoveredItemKey = item.item_key;
state.hoverTimerId = window.setTimeout(() => {
showAbstractPreview(item, target);
}, ABSTRACT_PREVIEW_DELAY_MS);
}
function cancelAbstractPreview() {
if (state.hoverTimerId) {
window.clearTimeout(state.hoverTimerId);
state.hoverTimerId = null;
}
state.hoveredItemKey = null;
state.previewItemKey = null;
elements.previewPopover.hidden = true;
elements.previewPopover.textContent = "";
}
function showAbstractPreview(item, target) {
cancelAbstractPreview();
if (!item.abstract) {
return;
}
state.previewItemKey = item.item_key;
const rect = target.getBoundingClientRect();
elements.previewPopover.textContent = item.abstract;
elements.previewPopover.style.top = `${Math.round(rect.bottom + window.scrollY + 8)}px`;
elements.previewPopover.style.left = `${Math.round(Math.min(rect.left + window.scrollX, window.innerWidth - 320))}px`;
elements.previewPopover.hidden = false;
}
```
Also add these cleanup hooks:
```javascript
elements.collectionItems.addEventListener("scroll", cancelAbstractPreview);
async function selectCollection(collectionKey) {
cancelAbstractPreview();
state.selectedCollectionKey = collectionKey;
// keep the rest of the existing fetch logic unchanged
}
```
Add small style rules for the compact row:
```css
.item-row-body {
display: flex;
align-items: center;
gap: 0.5rem;
}
.title-button {
flex: 1;
padding: 0;
background: transparent;
color: var(--ink);
text-align: left;
border-radius: 0;
}
.details-button {
width: 2rem;
min-width: 2rem;
padding: 0.45rem 0;
}
```
- [ ] **Step 4: Run the UI test and the full suite**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q
UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q
```
Expected:
- first command: PASS
- second command: all tests PASS
- [ ] **Step 5: Commit**
```bash
git add src/zotero_kb/templates/index.html tests/test_ui.py
git commit -m "feat: add delayed abstract preview in import modal"
```
### Task 5: Update The User-Facing Walkthrough
**Files:**
- Modify: `README.md`
- Test: `tests/test_ui.py`
- [ ] **Step 1: Add the README delta**
In the UI usage section, replace any wording that implies the importer is inline with this paragraph:
```md
创建项目后,左侧点击 `导入文献` 会打开一个弹窗。弹窗左侧显示 Zotero collection 树,右侧显示当前 collection 及子 collection 下的文献标题列表。勾选后点击 `导入所选到当前项目` 即可导入;标题悬停 3 秒会显示摘要预览。
```
- [ ] **Step 2: Run a quick regression check**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py tests/test_api.py -q
```
Expected: PASS
- [ ] **Step 3: Manually verify the modal flow**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run python main.py
```
Then verify in the browser:
1. `http://127.0.0.1:8000` opens the app.
2. Clicking `导入文献` opens the modal.
3. With no project selected, the modal opens but the import button is disabled.
4. After selecting a project, the import button enables once at least one item is checked.
5. Closing and reopening the modal preserves selected items.
6. Hovering a title for less than 3 seconds shows nothing.
7. Hovering a title for at least 3 seconds shows the abstract preview.
8. Successful import closes the modal and refreshes cards.
- [ ] **Step 4: Commit**
```bash
git add README.md
git commit -m "docs: describe modal zotero import flow"
```
## Self-Review Checklist
- Spec coverage:
- modal trigger and shell: Task 2
- session-scoped selection memory and modal lifecycle: Task 3
- title-only rows and delayed abstract preview: Task 4
- user-facing workflow update: Task 5
- Placeholder scan:
- no `TODO`, `TBD`, or “similar to Task N” references remain
- Type consistency:
- modal ids and state names are consistent across tests and implementation:
- `open-import-modal-button`
- `import-modal`
- `modal-collection-tree`
- `modal-collection-items`
- `modal-selected-count`
- `modal-import-selected-items-button`
- `isImportModalOpen`
- `scheduleAbstractPreview`
- `cancelAbstractPreview`