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

22 KiB

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:

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:

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:

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:

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
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:

<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:

<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:

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:

.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:

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
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:

    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:

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:

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:

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:

UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py tests/test_api.py -q

Expected: PASS

  • Step 5: Commit
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:

    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:

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:

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:

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:

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:

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:

.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:

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

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:

创建项目后,左侧点击 `导入文献` 会打开一个弹窗。弹窗左侧显示 Zotero collection 树,右侧显示当前 collection 及子 collection 下的文献标题列表。勾选后点击 `导入所选到当前项目` 即可导入;标题悬停 3 秒会显示摘要预览。
  • Step 2: Run a quick regression check

Run:

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:

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
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