602 lines
17 KiB
Markdown
602 lines
17 KiB
Markdown
# Zotero Collection Import Fullscreen 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:** Convert the current centered import modal into a full-screen modal with left-side collection browsing, right-side direct-item selection, top-bar actions, and delayed abstract preview.
|
|
|
|
**Architecture:** Keep the existing FastAPI backend and reuse the current collection tree and item import endpoints. Refactor the single-page `index.html` template so the import flow becomes a full-screen modal with a fixed top bar, a left collection tree, and a right item list that only loads direct collection items (`include_descendants=false`). Preserve session-scoped selection state across collection switches and modal reopen, but remove collection-level bulk-select behavior.
|
|
|
|
**Tech Stack:** FastAPI, inline HTML/CSS/vanilla JavaScript, pytest
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
- Modify: `src/zotero_kb/templates/index.html`
|
|
- Convert the current centered modal into a full-screen modal shell
|
|
- Remove collection-level bulk-select behavior
|
|
- Change collection item loading to direct-only
|
|
- Update item rows to `title + year`
|
|
- Add delayed preview behavior and mobile details-button fallback
|
|
- Modify: `tests/test_ui.py`
|
|
- Update UI contract assertions for the new full-screen modal structure and preview hooks
|
|
- Modify: `README.md`
|
|
- Update the user walkthrough to describe the full-screen modal and one-by-one item selection
|
|
|
|
### Task 1: Update The UI Contract For The Fullscreen Modal
|
|
|
|
**Files:**
|
|
- Modify: `tests/test_ui.py`
|
|
- Read: `src/zotero_kb/templates/index.html`
|
|
|
|
- [ ] **Step 1: Write the failing assertions**
|
|
|
|
Extend `test_index_contains_import_modal_controls` so it asserts the new full-screen modal hooks and removes the old collection-bulk-select assumption.
|
|
|
|
Use this function body:
|
|
|
|
```python
|
|
def test_index_contains_import_modal_controls(tmp_path) -> None:
|
|
html = _get_index_html(tmp_path)
|
|
|
|
assert 'id="open-import-modal-button"' in html
|
|
assert 'id="import-modal"' in html
|
|
assert 'id="close-import-modal-button"' in html
|
|
assert 'id="import-modal-project-label"' in html
|
|
assert 'id="modal-selected-count"' in html
|
|
assert 'id="modal-clear-selection-button"' in html
|
|
assert 'id="modal-import-selected-items-button"' in html
|
|
assert 'id="modal-collection-tree"' in html
|
|
assert 'id="modal-collection-items"' in html
|
|
assert 'id="item-preview-popover"' in html
|
|
assert 'const ABSTRACT_PREVIEW_DELAY_MS = 3000;' in html
|
|
assert 'function scheduleAbstractPreview(item, target)' in html
|
|
assert 'function cancelAbstractPreview()' in html
|
|
assert 'function showAbstractPreview(item, target)' in html
|
|
assert 'details-button' in html
|
|
assert 'isImportModalOpen: false' in html
|
|
assert 'document.body.classList.toggle("modal-open"' in html
|
|
assert 'id="modal-select-descendants-button"' not in html
|
|
```
|
|
|
|
- [ ] **Step 2: Run the targeted UI test and confirm 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 template still contains the old top-bar/selection contract and may still include `modal-select-descendants-button`.
|
|
|
|
- [ ] **Step 3: Update the test file**
|
|
|
|
Edit `tests/test_ui.py` so the full file becomes:
|
|
|
|
```python
|
|
from zotero_kb.api import create_app
|
|
from zotero_kb.config import AppConfig
|
|
|
|
|
|
def _get_index_html(tmp_path) -> str:
|
|
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()):
|
|
return route.endpoint()
|
|
|
|
raise AssertionError("GET / route not found")
|
|
|
|
|
|
def test_index_contains_base_page_forms(tmp_path) -> None:
|
|
html = _get_index_html(tmp_path)
|
|
|
|
assert 'id="create-project-form"' in html
|
|
assert 'id="recommend-form"' in html
|
|
assert 'id="plan-form"' in html
|
|
|
|
|
|
def test_index_contains_import_modal_controls(tmp_path) -> None:
|
|
html = _get_index_html(tmp_path)
|
|
|
|
assert 'id="open-import-modal-button"' in html
|
|
assert 'id="import-modal"' in html
|
|
assert 'id="close-import-modal-button"' in html
|
|
assert 'id="import-modal-project-label"' in html
|
|
assert 'id="modal-selected-count"' in html
|
|
assert 'id="modal-clear-selection-button"' in html
|
|
assert 'id="modal-import-selected-items-button"' in html
|
|
assert 'id="modal-collection-tree"' in html
|
|
assert 'id="modal-collection-items"' in html
|
|
assert 'id="item-preview-popover"' in html
|
|
assert 'const ABSTRACT_PREVIEW_DELAY_MS = 3000;' in html
|
|
assert 'function scheduleAbstractPreview(item, target)' in html
|
|
assert 'function cancelAbstractPreview()' in html
|
|
assert 'function showAbstractPreview(item, target)' in html
|
|
assert 'details-button' in html
|
|
assert 'isImportModalOpen: false' in html
|
|
assert 'document.body.classList.toggle("modal-open"' in html
|
|
assert 'id="modal-select-descendants-button"' not in html
|
|
```
|
|
|
|
- [ ] **Step 4: Run the targeted UI test again**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v
|
|
```
|
|
|
|
Expected: still FAIL, now because the current template does not yet match the new full-screen modal contract.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add tests/test_ui.py
|
|
git commit -m "test: define fullscreen import modal contract"
|
|
```
|
|
|
|
### Task 2: Convert The Centered Modal To A Fullscreen Modal Shell
|
|
|
|
**Files:**
|
|
- Modify: `src/zotero_kb/templates/index.html`
|
|
- Test: `tests/test_ui.py`
|
|
|
|
- [ ] **Step 1: Replace the current modal container styles**
|
|
|
|
In `src/zotero_kb/templates/index.html`, replace the current centered-card modal CSS:
|
|
|
|
```css
|
|
.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;
|
|
}
|
|
```
|
|
|
|
with a full-screen shell:
|
|
|
|
```css
|
|
.modal-card {
|
|
position: relative;
|
|
z-index: 1;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
display: grid;
|
|
grid-template-rows: auto 1fr;
|
|
background: var(--surface);
|
|
border: 0;
|
|
border-radius: 0;
|
|
box-shadow: none;
|
|
overflow: hidden;
|
|
}
|
|
.modal-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
padding: 1rem 1.25rem;
|
|
border-bottom: 1px solid var(--line);
|
|
background: rgba(255, 253, 248, 0.98);
|
|
}
|
|
.modal-toolbar-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.modal-body {
|
|
display: grid;
|
|
grid-template-columns: 22rem minmax(0, 1fr);
|
|
min-height: 0;
|
|
}
|
|
.modal-pane {
|
|
min-height: 0;
|
|
padding: 1rem 1.25rem;
|
|
}
|
|
.modal-pane.collections {
|
|
border-right: 1px solid var(--line);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Replace the modal markup**
|
|
|
|
Replace the current modal header/footer structure:
|
|
|
|
```html
|
|
<header class="modal-header">...</header>
|
|
<div id="collection-status" class="status"></div>
|
|
<div class="modal-body">...</div>
|
|
<footer class="modal-action-bar">...</footer>
|
|
```
|
|
|
|
with a full-screen toolbar plus two-column body:
|
|
|
|
```html
|
|
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="import-modal-title">
|
|
<div class="modal-toolbar">
|
|
<button id="close-import-modal-button" type="button" class="secondary">返回主页面</button>
|
|
<div>
|
|
<h3 id="import-modal-title">Import From Zotero</h3>
|
|
<p id="import-modal-project-label" class="meta">未选择项目</p>
|
|
</div>
|
|
<div class="modal-toolbar-actions">
|
|
<div id="modal-selected-count" class="meta">已选 0 篇</div>
|
|
<button id="modal-clear-selection-button" type="button" class="danger">清空选择</button>
|
|
<button id="modal-import-selected-items-button" type="button">导入所选</button>
|
|
</div>
|
|
</div>
|
|
<div id="collection-status" class="status"></div>
|
|
<div class="modal-body">
|
|
<div class="modal-pane collections">
|
|
<small>Collections</small>
|
|
<div id="modal-collection-tree" class="modal-scroll tree-list"></div>
|
|
</div>
|
|
<div class="modal-pane items">
|
|
<small>Collection Items</small>
|
|
<div id="modal-collection-items" class="modal-scroll collection-items-list"></div>
|
|
</div>
|
|
</div>
|
|
<div id="item-preview-popover" class="preview-popover" hidden></div>
|
|
</section>
|
|
```
|
|
|
|
- [ ] **Step 3: Remove the old collection-level bulk select button from the markup**
|
|
|
|
Delete this old button entirely:
|
|
|
|
```html
|
|
<button id="modal-select-descendants-button" type="button" class="secondary">全选当前目录及子目录</button>
|
|
```
|
|
|
|
- [ ] **Step 4: Run the targeted UI test**
|
|
|
|
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: convert import modal to fullscreen layout"
|
|
```
|
|
|
|
### Task 3: Change Modal Selection Semantics To Direct-Item, One-By-One Selection
|
|
|
|
**Files:**
|
|
- Modify: `src/zotero_kb/templates/index.html`
|
|
- Test: `tests/test_api.py`
|
|
|
|
- [ ] **Step 1: Update collection item loading to direct-only**
|
|
|
|
In `loadCollectionItems(collectionKey)`, replace:
|
|
|
|
```javascript
|
|
const payload = await api(`/api/zotero/collections/${collectionKey}/items`);
|
|
```
|
|
|
|
with:
|
|
|
|
```javascript
|
|
const payload = await api(`/api/zotero/collections/${collectionKey}/items?include_descendants=false`);
|
|
```
|
|
|
|
- [ ] **Step 2: Remove collection-level bulk-select logic**
|
|
|
|
Delete the old helper and event binding:
|
|
|
|
```javascript
|
|
function selectVisibleItems() {
|
|
for (const item of state.visibleCollectionItems) {
|
|
state.selectedItemKeys.add(item.item_key);
|
|
}
|
|
persistImportSessionState();
|
|
renderCollectionItems();
|
|
updateImportActionState();
|
|
}
|
|
|
|
elements.selectDescendantsButton.addEventListener("click", selectVisibleItems);
|
|
```
|
|
|
|
Do not replace it with another collection-level bulk action.
|
|
|
|
- [ ] **Step 3: Keep only clear/import actions in the top bar**
|
|
|
|
Update `updateImportActionState()` so it no longer references `elements.selectDescendantsButton`.
|
|
|
|
Use:
|
|
|
|
```javascript
|
|
function updateImportActionState() {
|
|
const hasProject = Boolean(state.currentProjectId);
|
|
const hasSelection = state.selectedItemKeys.size > 0;
|
|
syncCurrentProjectLabels();
|
|
elements.importSelectedItemsButton.disabled = !hasProject || !hasSelection;
|
|
elements.importSelectedItemsButton.title = !hasProject ? "请先选择项目" : (hasSelection ? "" : "请先选择文献");
|
|
elements.clearSelectionButton.disabled = !state.selectedItemKeys.size;
|
|
renderSelectedCount();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Make the item row default render title + year only**
|
|
|
|
Replace the current item row template:
|
|
|
|
```javascript
|
|
row.innerHTML = `
|
|
<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>
|
|
</div>
|
|
`;
|
|
```
|
|
|
|
with:
|
|
|
|
```javascript
|
|
row.innerHTML = `
|
|
<input type="checkbox" ${state.selectedItemKeys.has(item.item_key) ? "checked" : ""} />
|
|
<div class="item-row-body">
|
|
<button type="button" class="title-button">${item.title}</button>
|
|
<div class="meta">${item.year || "-"}</div>
|
|
<button type="button" class="secondary details-button" aria-label="查看摘要">i</button>
|
|
</div>
|
|
`;
|
|
```
|
|
|
|
- [ ] **Step 5: Run focused tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py tests/test_api.py -q
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/zotero_kb/templates/index.html
|
|
git commit -m "feat: switch import modal to direct-item selection"
|
|
```
|
|
|
|
### Task 4: Add Delayed Abstract Preview And Touch Fallback
|
|
|
|
**Files:**
|
|
- Modify: `src/zotero_kb/templates/index.html`
|
|
- Test: `tests/test_ui.py`
|
|
|
|
- [ ] **Step 1: Add preview helpers**
|
|
|
|
Add these helpers near the modal item rendering code:
|
|
|
|
```javascript
|
|
function clearAbstractPreviewTimer() {
|
|
if (state.hoverTimerId !== null) {
|
|
window.clearTimeout(state.hoverTimerId);
|
|
state.hoverTimerId = null;
|
|
}
|
|
}
|
|
|
|
function cancelAbstractPreview() {
|
|
clearAbstractPreviewTimer();
|
|
state.hoveredItemKey = null;
|
|
state.previewItemKey = null;
|
|
elements.previewPopover.hidden = true;
|
|
elements.previewPopover.textContent = "";
|
|
}
|
|
|
|
function showAbstractPreview(item, target) {
|
|
clearAbstractPreviewTimer();
|
|
const abstract = typeof item.abstract === "string" ? item.abstract.trim() : "";
|
|
if (!abstract) {
|
|
cancelAbstractPreview();
|
|
return;
|
|
}
|
|
state.previewItemKey = item.item_key;
|
|
elements.previewPopover.textContent = abstract;
|
|
elements.previewPopover.hidden = false;
|
|
}
|
|
|
|
function scheduleAbstractPreview(item, target) {
|
|
const abstract = typeof item.abstract === "string" ? item.abstract.trim() : "";
|
|
if (!abstract) {
|
|
return;
|
|
}
|
|
cancelAbstractPreview();
|
|
state.hoveredItemKey = item.item_key;
|
|
state.hoverTimerId = window.setTimeout(() => {
|
|
if (state.hoveredItemKey === item.item_key) {
|
|
showAbstractPreview(item, target);
|
|
}
|
|
}, ABSTRACT_PREVIEW_DELAY_MS);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Wire preview behavior into item rows**
|
|
|
|
Bind these events in `renderCollectionItems()`:
|
|
|
|
```javascript
|
|
checkbox.addEventListener("change", () => {
|
|
cancelAbstractPreview();
|
|
toggleItemSelection(item.item_key);
|
|
});
|
|
|
|
titleButton.addEventListener("mouseenter", () => scheduleAbstractPreview(item, titleButton));
|
|
titleButton.addEventListener("mouseleave", cancelAbstractPreview);
|
|
|
|
detailsButton.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
if (state.previewItemKey === item.item_key && !elements.previewPopover.hidden) {
|
|
cancelAbstractPreview();
|
|
return;
|
|
}
|
|
showAbstractPreview(item, detailsButton);
|
|
});
|
|
```
|
|
|
|
Also add:
|
|
|
|
```javascript
|
|
elements.collectionItems.addEventListener("scroll", cancelAbstractPreview);
|
|
```
|
|
|
|
and call `cancelAbstractPreview()` inside:
|
|
|
|
- `loadCollectionItems()`
|
|
- `toggleItemSelection()`
|
|
- `clearSelectedItems()`
|
|
- `importSelectedItems()`
|
|
- `openImportModal()`
|
|
- `closeImportModal()`
|
|
|
|
- [ ] **Step 3: Add compact-row and preview styles**
|
|
|
|
Add:
|
|
|
|
```css
|
|
.collection-item {
|
|
display: grid;
|
|
grid-template-columns: auto minmax(0, 1fr);
|
|
gap: 0.75rem;
|
|
align-items: center;
|
|
padding: 0.75rem 0.9rem;
|
|
border: 1px solid var(--line);
|
|
border-radius: 14px;
|
|
background: rgba(255, 255, 255, 0.72);
|
|
}
|
|
.item-row-body {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.55rem;
|
|
min-width: 0;
|
|
}
|
|
.title-button {
|
|
flex: 1;
|
|
min-width: 0;
|
|
padding: 0;
|
|
border: 0;
|
|
border-radius: 0;
|
|
background: transparent;
|
|
color: var(--ink);
|
|
text-align: left;
|
|
font-weight: 600;
|
|
}
|
|
.details-button {
|
|
width: 2.2rem;
|
|
min-width: 2.2rem;
|
|
padding: 0.45rem 0;
|
|
}
|
|
.preview-popover {
|
|
position: fixed;
|
|
z-index: 60;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the UI test and full suite**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v
|
|
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 preview to fullscreen import modal"
|
|
```
|
|
|
|
### Task 5: Update The User Walkthrough
|
|
|
|
**Files:**
|
|
- Modify: `README.md`
|
|
|
|
- [ ] **Step 1: Replace the old inline-import instructions**
|
|
|
|
Replace the current import walkthrough bullets with:
|
|
|
|
```md
|
|
5. 在左侧点击 `导入文献`
|
|
|
|
- 先选择一个项目
|
|
- 点击 `导入文献` 打开全屏导入界面
|
|
- 左侧点击一个 Zotero collection
|
|
- 右侧会显示该 collection 直接包含的文献
|
|
- 逐篇勾选需要导入的文献
|
|
- 点击顶部 `导入所选`
|
|
- 鼠标悬停标题 3 秒会显示摘要预览
|
|
- 在窄屏或不方便 hover 的场景下,可点击 `i` 按钮查看摘要
|
|
```
|
|
|
|
- [ ] **Step 2: Run the full test suite**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add README.md
|
|
git commit -m "docs: describe fullscreen import modal flow"
|
|
```
|
|
|
|
## Self-Review Checklist
|
|
|
|
- Spec coverage:
|
|
- full-screen modal shell: Task 2
|
|
- direct-item-only collection loading: Task 3
|
|
- one-by-one selection semantics: Task 3
|
|
- delayed abstract preview and details-button fallback: Task 4
|
|
- updated user walkthrough: Task 5
|
|
- Placeholder scan:
|
|
- no `TODO`, `TBD`, or vague “handle later” instructions remain
|
|
- Type consistency:
|
|
- ids and helpers remain consistent across tasks:
|
|
- `open-import-modal-button`
|
|
- `import-modal`
|
|
- `import-modal-project-label`
|
|
- `modal-selected-count`
|
|
- `modal-clear-selection-button`
|
|
- `modal-import-selected-items-button`
|
|
- `modal-collection-tree`
|
|
- `modal-collection-items`
|
|
- `scheduleAbstractPreview`
|
|
- `cancelAbstractPreview`
|
|
- `showAbstractPreview`
|