From 4d45602c4536b28d874c8c4f7a3df03a2f608d9e Mon Sep 17 00:00:00 2001 From: Saberlve Date: Thu, 16 Apr 2026 13:34:39 +0800 Subject: [PATCH] initiation --- .claude/settings.json | 8 + .codex | 0 .gitignore | 11 + .python-version | 1 + .../31471-1776242964/state/server-stopped | 1 + .../31471-1776242964/state/server.log | 2 + .../31471-1776242964/state/server.pid | 1 + README.md | 76 ++ .../2026-04-15-import-floating-window.md | 705 +++++++++++++++++ ...tero-collection-import-fullscreen-modal.md | 601 +++++++++++++++ ...26-04-15-zotero-collection-import-modal.md | 711 ++++++++++++++++++ .../2026-04-15-zotero-collection-import.md | 378 ++++++++++ .../plans/2026-04-15-zotero-kb-v1.md | 519 +++++++++++++ .../specs/2026-04-15-zotero-kb-design.md | 627 +++++++++++++++ main.py | 5 + pyproject.toml | 25 + skills/zotero-citation-planner/SKILL.md | 25 + skills/zotero-citation-recommender/SKILL.md | 26 + src/zotero_kb/__init__.py | 2 + src/zotero_kb/bridge.py | 10 + src/zotero_kb/cards.py | 176 +++++ src/zotero_kb/config.py | 38 + src/zotero_kb/llm.py | 127 ++++ src/zotero_kb/main.py | 15 + src/zotero_kb/templates/index.html | 337 +++++---- src/zotero_kb/workspace.py | 64 ++ src/zotero_kb/writing.py | 74 ++ src/zotero_kb/zotero_reader.py | 453 +++++++++++ tests/test_cards.py | 68 ++ tests/test_config.py | 11 + tests/test_llm.py | 78 ++ tests/test_projects.py | 76 ++ tests/test_skill_assets.py | 10 + tests/test_ui.py | 116 +-- tests/test_workspace.py | 34 + tests/test_writing.py | 79 ++ tests/test_zotero_reader.py | 74 ++ uv.lock | 561 ++++++++++++++ zotero-bridge/src/bootstrap.js | 33 + zotero-bridge/src/manifest.json | 12 + 40 files changed, 5964 insertions(+), 206 deletions(-) create mode 100644 .claude/settings.json create mode 100644 .codex create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 .superpowers/brainstorm/31471-1776242964/state/server-stopped create mode 100644 .superpowers/brainstorm/31471-1776242964/state/server.log create mode 100644 .superpowers/brainstorm/31471-1776242964/state/server.pid create mode 100644 README.md create mode 100644 docs/superpowers/plans/2026-04-15-import-floating-window.md create mode 100644 docs/superpowers/plans/2026-04-15-zotero-collection-import-fullscreen-modal.md create mode 100644 docs/superpowers/plans/2026-04-15-zotero-collection-import-modal.md create mode 100644 docs/superpowers/plans/2026-04-15-zotero-collection-import.md create mode 100644 docs/superpowers/plans/2026-04-15-zotero-kb-v1.md create mode 100644 docs/superpowers/specs/2026-04-15-zotero-kb-design.md create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 skills/zotero-citation-planner/SKILL.md create mode 100644 skills/zotero-citation-recommender/SKILL.md create mode 100644 src/zotero_kb/__init__.py create mode 100644 src/zotero_kb/bridge.py create mode 100644 src/zotero_kb/cards.py create mode 100644 src/zotero_kb/config.py create mode 100644 src/zotero_kb/llm.py create mode 100644 src/zotero_kb/main.py create mode 100644 src/zotero_kb/workspace.py create mode 100644 src/zotero_kb/writing.py create mode 100644 src/zotero_kb/zotero_reader.py create mode 100644 tests/test_cards.py create mode 100644 tests/test_config.py create mode 100644 tests/test_llm.py create mode 100644 tests/test_projects.py create mode 100644 tests/test_skill_assets.py create mode 100644 tests/test_workspace.py create mode 100644 tests/test_writing.py create mode 100644 tests/test_zotero_reader.py create mode 100644 uv.lock create mode 100644 zotero-bridge/src/bootstrap.js create mode 100644 zotero-bridge/src/manifest.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e7b6b7d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(git add:*)", + "Bash(git check-ignore:*)" + ] + } +} diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe49836 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv +workspace/ \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/.superpowers/brainstorm/31471-1776242964/state/server-stopped b/.superpowers/brainstorm/31471-1776242964/state/server-stopped new file mode 100644 index 0000000..9b78476 --- /dev/null +++ b/.superpowers/brainstorm/31471-1776242964/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1776244824573} diff --git a/.superpowers/brainstorm/31471-1776242964/state/server.log b/.superpowers/brainstorm/31471-1776242964/state/server.log new file mode 100644 index 0000000..573c281 --- /dev/null +++ b/.superpowers/brainstorm/31471-1776242964/state/server.log @@ -0,0 +1,2 @@ +{"type":"server-started","port":56756,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:56756","screen_dir":"/root/code/zotero-kb/.superpowers/brainstorm/31471-1776242964/content","state_dir":"/root/code/zotero-kb/.superpowers/brainstorm/31471-1776242964/state"} +{"type":"server-stopped","reason":"idle timeout"} diff --git a/.superpowers/brainstorm/31471-1776242964/state/server.pid b/.superpowers/brainstorm/31471-1776242964/state/server.pid new file mode 100644 index 0000000..c699daf --- /dev/null +++ b/.superpowers/brainstorm/31471-1776242964/state/server.pid @@ -0,0 +1 @@ +31479 diff --git a/README.md b/README.md new file mode 100644 index 0000000..48163f9 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# Zotero KB + +本地 Zotero 文献卡片工作台。 + +## 当前能力 + +- 从本地 Zotero 数据目录读取条目、标签、笔记、collection 和附件文本 +- 通过项目管理指定文献子集 +- 生成 Markdown 知识卡片和 JSON 索引 +- 提供项目内引用推荐和初版引用方案 API +- 提供最小 Web 控制台 +- 提供 Claude Code / Codex 可直接读取的 skill 文件 +- 支持按 Zotero collection 树浏览并逐篇选择文献导入到当前项目 + +## 本地运行 + +1. 安装依赖 + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv sync --extra dev +``` + +2. 设置环境变量 + +```bash +export DEEPSEEK_API_KEY=你的_deepseek_api_key +export ZOTERO_DATA_DIR=/mnt/c/Users/WSX/Zotero +export ZOTERO_KB_WORKSPACE=workspace +export ZOTERO_BRIDGE_FILE=workspace/bridge/selected-items.json +``` + +如果你已经在 `~/.bashrc` 里配过这些变量,这一步可以跳过。 + +3. 启动服务 + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv run python main.py +``` + +4. 打开浏览器访问 + +```text +http://127.0.0.1:8000 +``` + +5. 在左侧点击 `导入文献` + +- 先选择一个项目 +- 点击 `导入文献` 打开全屏导入界面 +- 左侧点击一个 Zotero collection +- 右侧会显示该 collection 直接包含的文献 +- 逐篇勾选需要导入的文献 +- 点击顶部 `导入所选` +- 文献列表默认只显示标题和年份 +- 鼠标悬停标题 3 秒后会显示摘要预览 +- 在窄屏或不方便 hover 的场景下,可点击 `i` 按钮查看摘要 + +默认环境变量: + +- `ZOTERO_DATA_DIR=/mnt/c/Users/WSX/Zotero` +- `ZOTERO_KB_WORKSPACE=workspace` +- `ZOTERO_BRIDGE_FILE=workspace/bridge/selected-items.json` +- `DEEPSEEK_API_KEY=...` + +如果项目创建时使用: + +- `llm_provider=deepseek` +- `llm_model=deepseek-chat` + +导入文献生成卡片时会自动读取 `DEEPSEEK_API_KEY` 调用 DeepSeek。 + +## 测试 + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q +``` diff --git a/docs/superpowers/plans/2026-04-15-import-floating-window.md b/docs/superpowers/plans/2026-04-15-import-floating-window.md new file mode 100644 index 0000000..df0a905 --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-import-floating-window.md @@ -0,0 +1,705 @@ +# Import Floating Window 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 full-screen import modal with a draggable, resizable, minimizable floating window while preserving all existing import behaviors. + +**Architecture:** All UI code lives in a single `index.html` file (template served by FastAPI). The change modifies CSS classes (modal → window frame), adds window control HTML elements, adds drag/resize/minimize/maximize JavaScript, and persists window state to `sessionStorage`. + +**Tech Stack:** Vanilla JS, CSS custom properties, FastAPI serving as single-page app. + +**Files:** +- Modify: `src/zotero_kb/templates/index.html` +- Modify: `tests/test_ui.py` + +--- + +## Task 1: Replace Modal CSS with Window Frame CSS + +**Files:** +- Modify: `src/zotero_kb/templates/index.html:147-206` + +- [ ] **Step 1: Replace `.modal-shell` CSS with `.window-shell`** + +Old (lines 147–154): +```css +.modal-shell[hidden] { + display: none; +} +.modal-shell { + position: fixed; + inset: 0; + z-index: 40; +} +``` + +New: +```css +.window-shell[hidden] { + display: none; +} +.window-shell { + position: fixed; + inset: 0; + z-index: 40; + pointer-events: none; +} +.window-shell:not([hidden]) { + pointer-events: auto; +} +``` + +- [ ] **Step 2: Replace `.modal-overlay` CSS with `.window-frame` base** + +Old (lines 155–173): +```css +.modal-overlay { + position: absolute; + inset: 0; + border-radius: 0; + background: rgba(24, 28, 22, 0.42); +} +.modal-card { + position: relative; + z-index: 1; + width: 100vw; + height: 100vh; + display: grid; + grid-template-rows: auto auto 1fr; + background: var(--surface); + border: 0; + border-radius: 0; + box-shadow: none; + overflow: hidden; +} +``` + +New: +```css +.window-frame { + position: fixed; + z-index: 40; + width: var(--fw-width, 90vw); + height: var(--fw-height, 75vh); + left: var(--fw-left, calc((100vw - var(--fw-width, 90vw)) / 2)); + top: var(--fw-top, calc((100vh - var(--fw-height, 75vh)) / 2)); + min-width: 40rem; + min-height: 30rem; + border-radius: 16px; + box-shadow: 0 24px 64px rgba(35, 31, 21, 0.22); + display: grid; + grid-template-rows: auto 1fr auto; + overflow: hidden; + background: var(--surface); + border: 1px solid var(--line); +} + +.window-frame.maximized { + width: 100vw; + height: 100vh; + left: 0 !important; + top: 0 !important; + border-radius: 0; + --fw-width: 100vw; + --fw-height: 100vh; +} + +.window-frame:not(.maximized):not(.minimized) { + resize: both; + overflow: auto; +} +``` + +- [ ] **Step 3: Add `.window-minimized-bar` CSS** + +After `.window-frame.maximized` block, add: +```css +.window-minimized-bar { + position: fixed; + bottom: 1rem; + right: 1rem; + z-index: 50; + height: 2.5rem; + width: 14rem; + border-radius: 8px; + background: var(--surface); + border: 1px solid var(--line); + box-shadow: 0 8px 24px rgba(35, 31, 21, 0.14); + display: none; + align-items: center; + padding: 0 0.75rem; + gap: 0.5rem; + cursor: pointer; + font-size: 0.9rem; + font-weight: 600; + color: var(--ink); +} + +.window-minimized-bar:not([hidden]) { + display: flex; +} + +.window-minimized-bar:hover { + background: var(--panel); +} +``` + +- [ ] **Step 4: Update `.modal-toolbar` to `.window-toolbar`** + +Find `.modal-toolbar` (line ~174) and `.modal-toolbar-actions` — rename to `.window-toolbar` and `.window-toolbar-actions`. Find `.modal-body` and `.modal-pane` — rename to `.window-body` and `.window-pane`. Find `.modal-scroll` — rename to `.window-scroll`. + +- [ ] **Step 5: Add drag cursor CSS** + +In the existing `button:hover` block or nearby, add: +```css +.window-toolbar { + cursor: grab; +} +.window-toolbar:active { + cursor: grabbing; +} +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/zotero_kb/templates/index.html +git commit -m "feat(import-window): replace modal CSS with window frame CSS" +``` + +--- + +## Task 2: Replace Modal HTML Structure with Window Frame + +**Files:** +- Modify: `src/zotero_kb/templates/index.html:342–370` + +- [ ] **Step 1: Replace the modal HTML with window shell + window frame + minimized bar** + +Old (lines 342–370): +```html + +``` + +New: +```html + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/zotero_kb/templates/index.html +git commit -m "feat(import-window): replace modal HTML with window frame structure" +``` + +--- + +## Task 3: Rename JS State Keys and Element References + +**Files:** +- Modify: `src/zotero_kb/templates/index.html:419–498` + +- [ ] **Step 1: Rename `IMPORT_SESSION_STORAGE_KEY`** + +Old: +```js +const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal"; +``` + +New: +```js +const WINDOW_SESSION_STORAGE_KEY = "zotero-kb.import-window"; +const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal"; +``` + +- [ ] **Step 2: Add `readWindowSessionState` function** + +After `readImportSessionState()` function (~line 434), add: +```js +function readWindowSessionState() { + try { + const raw = window.sessionStorage.getItem(WINDOW_SESSION_STORAGE_KEY); + if (!raw) return {}; + const payload = JSON.parse(raw); + return typeof payload === "object" && payload ? payload : {}; + } catch (_error) { + return {}; + } +} + +const windowSessionState = readWindowSessionState(); +``` + +- [ ] **Step 3: Add window state to the `state` object** + +In the `state` object (~line 438), add after `isImportModalOpen`: +```js +isImportWindowOpen: false, +windowX: typeof windowSessionState.x === "number" ? windowSessionState.x : null, +windowY: typeof windowSessionState.y === "number" ? windowSessionState.y : null, +windowWidth: typeof windowSessionState.width === "number" ? windowSessionState.width : null, +windowHeight: typeof windowSessionState.height === "number" ? windowSessionState.height : null, +isWindowMinimized: Boolean(windowSessionState.isMinimized), +isWindowMaximized: Boolean(windowSessionState.isMaximized), +isWindowDragging: false, +windowDragStartX: 0, +windowDragStartY: 0, +windowDragStartLeft: 0, +windowDragStartTop: 0, +``` + +- [ ] **Step 4: Rename `elements.importModal` and `importModalOverlay` references** + +Update the `elements` object (lines ~467–498): +```js +importWindowShell: document.getElementById("import-window-shell"), +importWindow: document.getElementById("import-window"), +windowToolbar: document.getElementById("import-window-toolbar"), +windowMinimizeButton: document.getElementById("window-minimize-button"), +windowMaximizeButton: document.getElementById("window-maximize-button"), +windowCloseButton: document.getElementById("window-close-button"), +windowRestoreButton: document.getElementById("window-restore-button"), +importWindowMinimizedBar: document.getElementById("import-window-minimized-bar"), +windowProjectLabel: document.getElementById("import-window-project-label"), +windowSelectedCount: document.getElementById("window-selected-count"), +windowClearSelectionButton: document.getElementById("window-clear-selection-button"), +windowImportSelectedItemsButton: document.getElementById("window-import-selected-items-button"), +windowCollectionStatus: document.getElementById("window-collection-status"), +windowCollectionTree: document.getElementById("window-collection-tree"), +windowCollectionItems: document.getElementById("window-collection-items"), +``` + +Remove: `importModalOverlay`, `openImportModalButton`, `closeImportModalButton`, `importModalProjectLabel`, `collectionStatus`, `collectionTree`, `collectionItems`, `selectedCount`, `clearSelectionButton`, `importSelectedItemsButton`. + +- [ ] **Step 5: Add `persistWindowSessionState` function** + +After `persistImportSessionState()` function (~line 509): +```js +function persistWindowSessionState() { + const fw = elements.importWindow; + if (!fw) return; + const style = window.getComputedStyle(fw); + const width = parseFloat(style.width); + const height = parseFloat(style.height); + const left = parseFloat(style.left); + const top = parseFloat(style.top); + window.sessionStorage.setItem( + WINDOW_SESSION_STORAGE_KEY, + JSON.stringify({ + x: left, + y: top, + width: width, + height: height, + isMinimized: state.isWindowMinimized, + isMaximized: state.isWindowMaximized, + }) + ); +} +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/zotero_kb/templates/index.html +git commit -m "feat(import-window): add window state management to JS" +``` + +--- + +## Task 4: Implement Drag, Minimize, Maximize, and Close Logic + +**Files:** +- Modify: `src/zotero_kb/templates/index.html` + +- [ ] **Step 1: Add window apply position/size helper** + +After `persistWindowSessionState()`, add: +```js +function applyWindowPositionAndSize() { + const fw = elements.importWindow; + if (!fw) return; + fw.style.left = state.windowX !== null ? `${state.windowX}px` : ""; + fw.style.top = state.windowY !== null ? `${state.windowY}px` : ""; + fw.style.width = state.windowWidth !== null ? `${state.windowWidth}px` : ""; + fw.style.height = state.windowHeight !== null ? `${state.windowHeight}px` : ""; +} + +function initWindowFromSession() { + const fw = elements.importWindow; + const bar = elements.importWindowMinimizedBar; + if (state.isWindowMinimized) { + fw.classList.add("minimized"); + fw.classList.remove("maximized"); + bar.hidden = false; + } else if (state.isWindowMaximized) { + fw.classList.add("maximized"); + fw.classList.remove("minimized"); + fw.style.width = "100vw"; + fw.style.height = "100vh"; + fw.style.left = "0"; + fw.style.top = "0"; + bar.hidden = true; + } else { + if (state.windowX !== null) { + applyWindowPositionAndSize(); + } + fw.classList.remove("minimized", "maximized"); + bar.hidden = true; + } +} +``` + +- [ ] **Step 2: Add drag handlers** + +After `initWindowFromSession()`: +```js +function startWindowDrag(event) { + const fw = elements.importWindow; + if (!fw || fw.classList.contains("maximized") || fw.classList.contains("minimized")) return; + event.preventDefault(); + state.isWindowDragging = true; + state.windowDragStartX = event.clientX; + state.windowDragStartY = event.clientY; + state.windowDragStartLeft = parseFloat(fw.style.left) || 0; + state.windowDragStartTop = parseFloat(fw.style.top) || 0; + fw.style.transition = "none"; +} + +function moveWindowDrag(event) { + if (!state.isWindowDragging) return; + event.preventDefault(); + const fw = elements.importWindow; + const dx = event.clientX - state.windowDragStartX; + const dy = event.clientY - state.windowDragStartY; + let newLeft = state.windowDragStartLeft + dx; + let newTop = state.windowDragStartTop + dy; + // boundary clamp + const maxLeft = window.innerWidth - (parseFloat(fw.style.width) || fw.offsetWidth); + const maxTop = window.innerHeight - (parseFloat(fw.style.height) || fw.offsetHeight); + newLeft = Math.max(0, Math.min(newLeft, maxLeft)); + newTop = Math.max(0, Math.min(newTop, maxTop)); + fw.style.left = `${newLeft}px`; + fw.style.top = `${newTop}px`; + state.windowX = newLeft; + state.windowY = newTop; +} + +function endWindowDrag() { + if (!state.isWindowDragging) return; + state.isWindowDragging = false; + persistWindowSessionState(); +} + +function minimizeWindow() { + state.isWindowMinimized = true; + state.isWindowMaximized = false; + elements.importWindow.classList.add("minimized"); + elements.importWindow.classList.remove("maximized"); + elements.importWindowMinimizedBar.hidden = false; + persistWindowSessionState(); +} + +function maximizeWindow() { + state.isWindowMaximized = !state.isWindowMaximized; + if (state.isWindowMaximized) { + state.isWindowMinimized = false; + elements.importWindow.classList.add("maximized"); + elements.importWindow.classList.remove("minimized"); + elements.importWindowMinimizedBar.hidden = true; + elements.importWindow.style.width = "100vw"; + elements.importWindow.style.height = "100vh"; + elements.importWindow.style.left = "0"; + elements.importWindow.style.top = "0"; + } else { + elements.importWindow.classList.remove("maximized"); + applyWindowPositionAndSize(); + } + persistWindowSessionState(); +} + +function restoreWindow() { + state.isWindowMinimized = false; + elements.importWindow.classList.remove("minimized"); + elements.importWindowMinimizedBar.hidden = true; + applyWindowPositionAndSize(); + persistWindowSessionState(); +} +``` + +- [ ] **Step 3: Wire up drag event listeners** + +Find where `elements.projectForm.addEventListener` is set up (~line 1079). Before that block, add: +```js +elements.windowToolbar.addEventListener("pointerdown", startWindowDrag); +document.addEventListener("pointermove", moveWindowDrag); +document.addEventListener("pointerup", endWindowDrag); +elements.windowMinimizeButton.addEventListener("click", minimizeWindow); +elements.windowMaximizeButton.addEventListener("click", maximizeWindow); +elements.windowCloseButton.addEventListener("click", closeImportWindow); +elements.windowRestoreButton.addEventListener("click", restoreWindow); +elements.importWindowMinimizedBar.addEventListener("click", (e) => { + if (e.target === elements.windowRestoreButton) return; + restoreWindow(); +}); +``` + +- [ ] **Step 4: Rename `openImportModal` → `openImportWindow` and `closeImportModal` → `closeImportWindow`** + +In the JS, rename: +- `function openImportModal()` → `function openImportWindow()` +- `function closeImportModal()` → `function closeImportWindow()` +- `openImportModalButton` → (removed, not needed as separate element) +- `closeImportModalButton` → (removed) + +Find all call sites and update them. The `Esc` key handler should call `closeImportWindow`. + +- [ ] **Step 5: Update the open function to use window state** + +The `openImportWindow` function should call `initWindowFromSession()` instead of the modal open logic: +```js +state.isImportWindowOpen = true; +elements.importWindowShell.hidden = false; +elements.importWindowShell.setAttribute("aria-hidden", "false"); +document.body.classList.toggle("modal-open", true); +cancelAbstractPreview(); +updateImportActionState(); +initWindowFromSession(); +``` + +- [ ] **Step 6: Update the close function** + +`closeImportWindow` should: +```js +state.isImportWindowOpen = false; +elements.importWindowShell.hidden = true; +elements.importWindowShell.setAttribute("aria-hidden", "true"); +document.body.classList.toggle("modal-open", false); +cancelAbstractPreview(); +// Note: does NOT clear window position/size/minimized state +``` + +- [ ] **Step 7: Update the button that opens the window** + +The `导入文献` button (`open-import-modal-button`) handler should call `openImportWindow()`: +```js +elements.openImportModalButton.addEventListener("click", () => { + openImportWindow(); +}); +``` + +The element `openImportModalButton` should be kept in `elements` for this reference. + +- [ ] **Step 8: Update Esc key handler** + +Old: +```js +document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && state.isImportModalOpen) { + closeImportModal(); + } +}); +``` + +New: +```js +document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && state.isImportWindowOpen) { + closeImportWindow(); + } +}); +``` + +- [ ] **Step 9: Update `syncCurrentProjectLabels` and `updateImportActionState`** + +Update all references: +- `elements.importModalProjectLabel` → `elements.windowProjectLabel` +- `elements.collectionStatus` → `elements.windowCollectionStatus` +- `elements.collectionTree` → `elements.windowCollectionTree` +- `elements.collectionItems` → `elements.windowCollectionItems` +- `elements.selectedCount` → `elements.windowSelectedCount` +- `elements.clearSelectionButton` → `elements.windowClearSelectionButton` +- `elements.importSelectedItemsButton` → `elements.windowImportSelectedItemsButton` + +- [ ] **Step 10: Update `renderCollectionTree`, `renderCollectionItems`, `renderSelectedCount`, `setStatus` call sites** + +Update all `elements.collectionTree`, `elements.collectionItems`, `elements.collectionStatus`, `elements.selectedCount` references to use the renamed `window*` variants. + +- [ ] **Step 11: Update `closeImportWindow` call inside `importSelectedItems`** + +When import succeeds, call `closeImportWindow()` instead of `closeImportModal()`. + +- [ ] **Step 12: Update `openImportWindow` call inside `loadCollectionTree` and `loadCollectionItems`** + +There are call sites inside `openImportModal()` (now `openImportWindow()`) that call `loadCollectionTree()` and `loadCollectionItems()`. These remain — just the function name changed. + +- [ ] **Step 13: Commit** + +```bash +git add src/zotero_kb/templates/index.html +git commit -m "feat(import-window): implement drag, minimize, maximize, close logic" +``` + +--- + +## Task 5: Update Tests + +**Files:** +- Modify: `tests/test_ui.py` + +- [ ] **Step 1: Update `test_index_contains_import_modal_controls`** + +Replace the old test with: +```python +def test_index_contains_import_window_controls(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert 'id="open-import-modal-button"' in html + assert 'id="import-window-shell"' in html + assert 'id="import-window"' in html + assert 'id="window-minimize-button"' in html + assert 'id="window-maximize-button"' in html + assert 'id="window-close-button"' in html + assert 'id="window-restore-button"' in html + assert 'id="import-window-minimized-bar"' in html + assert 'id="window-project-label"' in html + assert 'id="window-collection-tree"' in html + assert 'id="window-collection-items"' in html + assert 'id="window-selected-count"' in html + assert 'id="window-clear-selection-button"' in html + assert 'id="window-import-selected-items-button"' in html + assert 'id="item-preview-popover"' in html + assert 'const ABSTRACT_PREVIEW_DELAY_MS = 3000;' in html + assert "isImportWindowOpen: false" in html + assert "function openImportWindow()" in html + assert "function closeImportWindow()" in html + assert "function updateImportActionState()" 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 "?include_descendants=false" in html + assert "window.classList.add(\"minimized\")" in html + assert "window.classList.add(\"maximized\")" in html + assert "WINDOW_SESSION_STORAGE_KEY" in html +``` + +- [ ] **Step 2: Add new test for window CSS classes** + +After the above test, add: +```python +def test_index_window_frame_css(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert ".window-frame" in html + assert ".window-shell" in html + assert ".window-toolbar" in html + assert ".window-body" in html + assert ".window-minimized-bar" in html + assert '"zotero-kb.import-window"' in html + assert "startWindowDrag" in html + assert "minimizeWindow" in html + assert "maximizeWindow" in html + assert "restoreWindow" in html + assert "initWindowFromSession" in html + assert "persistWindowSessionState" in html +``` + +- [ ] **Step 3: Run tests to verify** + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -v +``` + +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add tests/test_ui.py +git commit -m "test(import-window): update UI tests for floating window" +``` + +--- + +## Self-Review Checklist + +1. **Spec coverage:** Each requirement in `2026-04-15-import-floating-window-design.md` is implemented: + - Window attributes (90vw×75vh, centered, rounded, shadow) → Task 1 + - Toolbar drag → Task 4 Steps 1–3 + - Minimize/maximize/close buttons → Task 4 Steps 4–5 + - Minimized bar → Task 2 HTML + Task 4 Step 5 + - Session state persistence → Task 3 Steps 2, 5, Task 4 Step 3 + - Esc closes window → Task 4 Step 8 + - All existing behaviors unchanged → Task 4 Steps 9–12 + +2. **Placeholder scan:** No "TBD", "TODO", or vague language. All code is concrete. + +3. **Type consistency:** All renamed JS state keys and element references use consistent `window*` prefix throughout. The `state` object uses `isWindowMinimized`, `isWindowMaximized`, `isWindowDragging` etc. consistently across all tasks. + +4. **Spec completeness check:** `window-minimized-bar` has `hidden` attribute in HTML and `display:none` → `display:flex` in CSS. Maximized removes rounded corners. Drag is bounded to viewport. Import button disabled when no project selected is unchanged. diff --git a/docs/superpowers/plans/2026-04-15-zotero-collection-import-fullscreen-modal.md b/docs/superpowers/plans/2026-04-15-zotero-collection-import-fullscreen-modal.md new file mode 100644 index 0000000..5e9d2ce --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-zotero-collection-import-fullscreen-modal.md @@ -0,0 +1,601 @@ +# 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 + +
+ + +``` + +with a full-screen toolbar plus two-column body: + +```html + +``` + +- [ ] **Step 3: Remove the old collection-level bulk select button from the markup** + +Delete this old button entirely: + +```html + +``` + +- [ ] **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 = ` + +
+ ${item.title} +
${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}
+
+`; +``` + +with: + +```javascript +row.innerHTML = ` + +
+ +
${item.year || "-"}
+ +
+`; +``` + +- [ ] **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` diff --git a/docs/superpowers/plans/2026-04-15-zotero-collection-import-modal.md b/docs/superpowers/plans/2026-04-15-zotero-collection-import-modal.md new file mode 100644 index 0000000..8c20696 --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-zotero-collection-import-modal.md @@ -0,0 +1,711 @@ +# 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 +
+

从 Zotero 导入

+

按当前 Zotero collection 结构浏览,选择目录后批量导入该目录及子目录的文献。

+
+
+
+ Collections +
+
+
+ Collection Items +
+
+
+
已选 0 篇
+ + + +
+
+
+``` + +with a trigger plus modal shell: + +```html +
+

导入文献

+

打开子界面浏览 Zotero collection 树,再批量导入到当前项目。

+ +
+ + +``` + +- [ ] **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 ` @@ -371,36 +422,27 @@
-
已选 0 篇
- - - - - +
+
已选 0 篇
+ + +
+
+ + + +
-
-
-
- Pending (0) - -
-
- -
- -
-
-
-

Click an item to preview

-
-
+
+ Collections +
+
-
- +
+ Collection Items +
@@ -528,10 +570,6 @@ hoveredItemKey: null, hoverTimerId: null, previewItemKey: null, - importItems: [], - selectedPendingKeys: new Set(), - isGenerating: false, - generatingProgress: { current: 0, total: 0 }, }; const elements = { @@ -613,6 +651,61 @@ fw.style.height = state.windowHeight !== null ? `${state.windowHeight}px` : ""; } + function computeResponsiveWindowRect() { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const margin = viewportWidth <= 980 ? 8 : 24; + const preferredWidth = Math.round(viewportWidth * 0.88); + const preferredHeight = Math.round(viewportHeight * 0.82); + const targetMinWidth = viewportWidth <= 980 ? 320 : 640; + const targetMinHeight = viewportHeight <= 980 ? 420 : 480; + const availableWidth = Math.max(260, viewportWidth - margin * 2); + const availableHeight = Math.max(320, viewportHeight - margin * 2); + const minWidth = Math.min(targetMinWidth, availableWidth); + const minHeight = Math.min(targetMinHeight, availableHeight); + const width = Math.max(minWidth, Math.min(preferredWidth, availableWidth)); + const height = Math.max(minHeight, Math.min(preferredHeight, availableHeight)); + const left = Math.max(margin, Math.round((viewportWidth - width) / 2)); + const top = Math.max(margin, Math.round((viewportHeight - height) / 2)); + + return { width, height, left, top, margin }; + } + + function applyResponsiveWindowRect() { + const fw = elements.importWindow; + if (!fw || state.isWindowMinimized || state.isWindowMaximized) return; + const rect = computeResponsiveWindowRect(); + fw.style.width = `${rect.width}px`; + fw.style.height = `${rect.height}px`; + fw.style.left = `${rect.left}px`; + fw.style.top = `${rect.top}px`; + state.windowWidth = rect.width; + state.windowHeight = rect.height; + state.windowX = rect.left; + state.windowY = rect.top; + } + + function syncWindowToViewport() { + const fw = elements.importWindow; + if (!fw || state.isWindowMinimized || state.isWindowMaximized) return; + const rect = computeResponsiveWindowRect(); + const width = Math.min(parseFloat(fw.style.width) || rect.width, rect.width); + const height = Math.min(parseFloat(fw.style.height) || rect.height, rect.height); + const maxLeft = Math.max(rect.margin, window.innerWidth - width - rect.margin); + const maxTop = Math.max(rect.margin, window.innerHeight - height - rect.margin); + const left = Math.min(Math.max(parseFloat(fw.style.left) || rect.left, rect.margin), maxLeft); + const top = Math.min(Math.max(parseFloat(fw.style.top) || rect.top, rect.margin), maxTop); + + fw.style.width = `${width}px`; + fw.style.height = `${height}px`; + fw.style.left = `${left}px`; + fw.style.top = `${top}px`; + state.windowWidth = width; + state.windowHeight = height; + state.windowX = left; + state.windowY = top; + } + function initWindowFromSession() { const fw = elements.importWindow; const bar = elements.importWindowMinimizedBar; @@ -1139,97 +1232,6 @@ closeImportWindow(); } - async function loadImportState() { - const data = await api(`/api/projects/${state.currentProjectId}/import-state`); - 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 = ` - -
-
${escapeHtml(item.title)}
-
${escapeHtml(item.creators.join(', '))} · ${escapeHtml(item.year || 'n.d.')}
-
- `; - 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); }); - list.appendChild(div); - }); - } - - function showItemPreview(item) { - document.getElementById('item-preview').innerHTML = ` -
${escapeHtml(item.title)}
-
${escapeHtml(item.creators.join(', '))} · ${escapeHtml(item.year || 'n.d.')} · ${escapeHtml(item.item_type)}
-
${escapeHtml(item.abstract || 'No abstract available.')}
- `; - } - - 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(); - - try { - const data = await api(`/api/projects/${state.currentProjectId}/cards/generate`, { - method: 'POST', - body: JSON.stringify({ item_keys: keys }), - }); - - 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); - } catch (error) { - setStatus(elements.windowCollectionStatus, error.message, true); - } finally { - state.isGenerating = false; - updateGenerateButton(); - } - } - - function updateImportCounts(pending, done) { - document.getElementById('pending-count').textContent = pending; - document.getElementById('done-count').textContent = done; - } - async function openImportWindow() { state.isImportWindowOpen = true; elements.importWindowShell.hidden = false; @@ -1237,18 +1239,38 @@ document.body.classList.toggle("modal-open", true); cancelAbstractPreview(); updateImportActionState(); + state.isWindowMinimized = false; + state.isWindowMaximized = false; initWindowFromSession(); - await loadImportState(); + applyResponsiveWindowRect(); + if (!state.collectionTree.length) { + try { + await loadCollectionTree(); + } catch (error) { + setStatus(elements.windowCollectionStatus, error.message, true); + } + } else if ( + state.selectedCollectionKey && + (!state.collectionItemsLoaded && state.loadingCollectionKey !== state.selectedCollectionKey) + ) { + try { + await loadCollectionItems(state.selectedCollectionKey); + } catch (error) { + setStatus(elements.windowCollectionStatus, error.message, true); + } + } else { + renderCollectionTree(); + renderCollectionItems(); + } } function closeImportWindow() { state.isImportWindowOpen = false; elements.importWindowShell.hidden = true; elements.importWindowShell.setAttribute("aria-hidden", "true"); + elements.importWindowMinimizedBar.hidden = true; document.body.classList.toggle("modal-open", false); cancelAbstractPreview(); - state.importItems = []; - state.selectedPendingKeys.clear(); } async function loadProjects() { @@ -1328,21 +1350,18 @@ elements.windowToolbar.addEventListener("pointerdown", startWindowDrag); document.addEventListener("pointermove", moveWindowDrag); document.addEventListener("pointerup", endWindowDrag); + window.addEventListener("resize", () => { + if (!state.isImportWindowOpen) { + return; + } + syncWindowToViewport(); + }); elements.windowClearSelectionButton.addEventListener("click", clearSelectedItems); elements.windowImportSelectedItemsButton.addEventListener("click", () => { importSelectedItems().catch((error) => { setStatus(elements.windowCollectionStatus, error.message, true); }); }); - document.getElementById('select-all-pending').addEventListener('click', () => { - document.querySelectorAll('.import-item.pending input[type="checkbox"]').forEach(cb => { - cb.checked = true; - state.selectedPendingKeys.add(cb.dataset.key); - cb.closest('.import-item').classList.add('selected'); - }); - updateGenerateButton(); - }); - document.getElementById('generate-cards-btn').addEventListener('click', generateSelectedCards); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && state.isImportWindowOpen) { closeImportWindow(); diff --git a/src/zotero_kb/workspace.py b/src/zotero_kb/workspace.py new file mode 100644 index 0000000..931eee3 --- /dev/null +++ b/src/zotero_kb/workspace.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +from .config import AppConfig + + +@dataclass(frozen=True) +class ProjectRecord: + project_id: str + name: str + project_dir: Path + + +class Workspace: + def __init__(self, config: AppConfig) -> None: + self.config = config + + def ensure_layout(self) -> None: + for path in ( + self.config.workspace_dir / "library" / "collections", + self.config.workspace_dir / "library" / "index", + self.config.workspace_dir / "library" / "cache" / "source-bundles", + self.config.workspace_dir / "bridge", + self.config.workspace_dir / "projects", + ): + path.mkdir(parents=True, exist_ok=True) + + def create_project( + self, + project_id: str, + name: str, + llm_provider: str, + llm_model: str, + ) -> ProjectRecord: + self.ensure_layout() + project_dir = self.config.workspace_dir / "projects" / project_id + project_dir.mkdir(parents=True, exist_ok=True) + + project_payload = { + "id": project_id, + "name": name, + "zotero_data_dir": str(self.config.zotero_data_dir), + "selection_mode": "zotero-bridge", + "llm": { + "provider": llm_provider, + "model": llm_model, + "base_url": None, + }, + "created_at": datetime.now(timezone.utc).isoformat(), + } + + self._write_json(project_dir / "project.json", project_payload) + self._write_json(project_dir / "selected-items.json", []) + self._write_json(project_dir / "project-index.json", {"items": []}) + + return ProjectRecord(project_id=project_id, name=name, project_dir=project_dir) + + @staticmethod + def _write_json(path: Path, payload: object) -> None: + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") diff --git a/src/zotero_kb/writing.py b/src/zotero_kb/writing.py new file mode 100644 index 0000000..4411c2d --- /dev/null +++ b/src/zotero_kb/writing.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from .projects import ProjectService + + +class WritingService: + def __init__(self, workspace_dir: Path) -> None: + self.project_service = ProjectService(workspace_dir) + + def recommend_citations(self, project_id: str, prompt: str) -> dict[str, object]: + project_view = self.project_service.get_project_view(project_id) + prompt_terms = self._terms(prompt) + scored = [] + for card in project_view.get("cards", []): + text_parts = [ + str(card.get("title", "")), + str(card.get("summary", "")), + " ".join(str(value) for value in card.get("claims", [])), + " ".join(str(value) for value in card.get("writing_hints", [])), + ] + combined = " ".join(text_parts) + score = self._score(prompt_terms, self._terms(combined)) + if score > 0: + scored.append( + { + "item_key": card["item_key"], + "title": card["title"], + "summary": card.get("summary", ""), + "why_relevant": f"Matched {score} prompt terms in the project-scoped card.", + "claims": card.get("claims", []), + "quotable_spans": card.get("quotable_spans", []), + "rhetorical_role": "supporting evidence", + "score": score, + } + ) + scored.sort(key=lambda item: (-item["score"], item["item_key"])) + return {"project_id": project_id, "results": scored} + + def generate_plan(self, project_id: str, prompt: str) -> dict[str, object]: + recommendations = self.recommend_citations(project_id, prompt)["results"] + if not recommendations: + return {"project_id": project_id, "sections": []} + + top = recommendations[0] + return { + "project_id": project_id, + "sections": [ + { + "heading": "Core argument", + "goal": prompt, + "citations": [ + { + "item_key": top["item_key"], + "title": top["title"], + "role": "primary support", + } + ], + "notes": [ + f"Lead with {top['title']} to ground the paragraph's main claim.", + ], + } + ], + } + + @staticmethod + def _terms(value: str) -> set[str]: + return {part for part in re.findall(r"[a-zA-Z0-9]+", value.lower()) if len(part) > 2} + + @staticmethod + def _score(prompt_terms: set[str], card_terms: set[str]) -> int: + return len(prompt_terms & card_terms) diff --git a/src/zotero_kb/zotero_reader.py b/src/zotero_kb/zotero_reader.py new file mode 100644 index 0000000..18b03d2 --- /dev/null +++ b/src/zotero_kb/zotero_reader.py @@ -0,0 +1,453 @@ +from __future__ import annotations + +import html +import re +import sqlite3 +import subprocess +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class ZoteroItemRecord: + item_key: str + title: str + creators: list[str] + year: str | None + item_type: str + abstract: str + tags: list[str] + collection_paths: list[list[str]] + notes: list[str] + attachment_texts: list[str] + + +class ZoteroReader: + def __init__(self, zotero_data_dir: Path) -> None: + self.zotero_data_dir = zotero_data_dir + self.db_path = zotero_data_dir / "zotero.sqlite" + + def read_items(self, item_keys: list[str]) -> list[ZoteroItemRecord]: + if not item_keys: + return [] + + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + try: + field_lookup = self._read_field_lookup(conn) + collection_lookup = self._read_collection_lookup(conn) + items = [] + for item_key in item_keys: + item_row = conn.execute( + """ + select i.itemID, i.key, it.typeName + from items i + join itemTypes it on it.itemTypeID = i.itemTypeID + where i.key = ? + """, + (item_key,), + ).fetchone() + if item_row is None or item_row["typeName"] in {"attachment", "note", "annotation"}: + continue + + item_id = int(item_row["itemID"]) + item_data = self._read_item_data(conn, item_id, field_lookup) + items.append( + ZoteroItemRecord( + item_key=item_row["key"], + title=item_data.get("title", "Untitled"), + creators=self._read_creators(conn, item_id), + year=self._extract_year(item_data.get("date")), + item_type=item_row["typeName"], + abstract=item_data.get("abstractNote", ""), + tags=self._read_tags(conn, item_id), + collection_paths=self._read_collection_paths(conn, item_id, collection_lookup), + notes=self._read_notes(conn, item_id), + attachment_texts=self._read_attachment_texts(conn, item_id), + ) + ) + return items + finally: + conn.close() + + def search_items(self, query: str, limit: int = 20) -> list[dict[str, object]]: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + try: + field_lookup = self._read_field_lookup(conn) + sql = """ + select i.itemID, i.key, it.typeName + from items i + join itemTypes it on it.itemTypeID = i.itemTypeID + where it.typeName not in ('attachment', 'note', 'annotation') + order by i.dateModified desc, i.itemID desc + limit ? + """ + rows = conn.execute(sql, (limit * 5,)).fetchall() + lowered = query.strip().lower() + results: list[dict[str, object]] = [] + for row in rows: + item_id = int(row["itemID"]) + item_data = self._read_item_data(conn, item_id, field_lookup) + title = item_data.get("title", "Untitled") + abstract = item_data.get("abstractNote", "") + haystack = " ".join([title, abstract]).lower() + if lowered and lowered not in haystack: + continue + results.append( + { + "item_key": str(row["key"]), + "title": title, + "item_type": str(row["typeName"]), + "year": self._extract_year(item_data.get("date")), + "abstract": abstract, + } + ) + if len(results) >= limit: + break + return results + finally: + conn.close() + + def get_collection_tree(self) -> list[dict[str, object]]: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + try: + collections = self._read_collection_lookup(conn) + children_lookup = self._build_children_lookup(collections) + direct_counts = self._read_direct_item_counts(conn) + root_ids = [collection_id for collection_id, row in collections.items() if row["parentCollectionID"] is None] + return [ + self._build_collection_node(collection_id, collections, children_lookup, direct_counts) + for collection_id in sorted(root_ids) + ] + finally: + conn.close() + + def get_collection_items( + self, + collection_key: str, + include_descendants: bool = True, + ) -> list[dict[str, object]]: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + try: + field_lookup = self._read_field_lookup(conn) + collections = self._read_collection_lookup(conn) + children_lookup = self._build_children_lookup(collections) + collection_ids = self._resolve_collection_ids( + collection_key, + collections, + children_lookup, + include_descendants=include_descendants, + ) + item_ids = self._read_item_ids_for_collections(conn, collection_ids) + return self._read_item_summaries(conn, item_ids, field_lookup, collections) + finally: + conn.close() + + @staticmethod + def _read_field_lookup(conn: sqlite3.Connection) -> dict[int, str]: + return { + int(row["fieldID"]): str(row["fieldName"]) + for row in conn.execute("select fieldID, fieldName from fields") + } + + @staticmethod + def _read_collection_lookup(conn: sqlite3.Connection) -> dict[int, sqlite3.Row]: + return { + int(row["collectionID"]): row + for row in conn.execute( + "select collectionID, collectionName, parentCollectionID, key from collections" + ) + } + + @staticmethod + def _build_children_lookup(collections: dict[int, sqlite3.Row]) -> dict[int, list[int]]: + children_lookup: dict[int, list[int]] = {} + for collection_id, row in collections.items(): + parent_id = row["parentCollectionID"] + if parent_id is None: + continue + children_lookup.setdefault(int(parent_id), []).append(collection_id) + for children in children_lookup.values(): + children.sort() + return children_lookup + + @staticmethod + def _read_direct_item_counts(conn: sqlite3.Connection) -> dict[int, int]: + rows = conn.execute( + """ + select ci.collectionID, count(*) as item_count + from collectionItems ci + join items i on i.itemID = ci.itemID + join itemTypes it on it.itemTypeID = i.itemTypeID + where it.typeName not in ('attachment', 'note', 'annotation') + group by ci.collectionID + """ + ) + return {int(row["collectionID"]): int(row["item_count"]) for row in rows} + + def _build_collection_node( + self, + collection_id: int, + collections: dict[int, sqlite3.Row], + children_lookup: dict[int, list[int]], + direct_counts: dict[int, int], + ) -> dict[str, object]: + row = collections[collection_id] + children = [ + self._build_collection_node(child_id, collections, children_lookup, direct_counts) + for child_id in children_lookup.get(collection_id, []) + ] + descendant_count = direct_counts.get(collection_id, 0) + sum( + int(child["descendant_item_count"]) for child in children + ) + return { + "collection_key": str(row["key"]), + "name": str(row["collectionName"]), + "parent_key": self._parent_key(row["parentCollectionID"], collections), + "children": children, + "direct_item_count": direct_counts.get(collection_id, 0), + "descendant_item_count": descendant_count, + } + + @staticmethod + def _parent_key(parent_collection_id: object, collections: dict[int, sqlite3.Row]) -> str | None: + if parent_collection_id is None: + return None + parent_row = collections.get(int(parent_collection_id)) + if parent_row is None: + return None + return str(parent_row["key"]) + + @staticmethod + def _resolve_collection_ids( + collection_key: str, + collections: dict[int, sqlite3.Row], + children_lookup: dict[int, list[int]], + include_descendants: bool, + ) -> list[int]: + collection_id = next( + (current_id for current_id, row in collections.items() if str(row["key"]) == collection_key), + None, + ) + if collection_id is None: + return [] + if not include_descendants: + return [collection_id] + + ordered: list[int] = [] + stack = [collection_id] + while stack: + current_id = stack.pop(0) + ordered.append(current_id) + stack[0:0] = children_lookup.get(current_id, []) + return ordered + + @staticmethod + def _read_item_ids_for_collections(conn: sqlite3.Connection, collection_ids: list[int]) -> list[int]: + if not collection_ids: + return [] + placeholders = ", ".join("?" for _ in collection_ids) + rows = conn.execute( + f""" + select distinct ci.itemID + from collectionItems ci + join items i on i.itemID = ci.itemID + join itemTypes it on it.itemTypeID = i.itemTypeID + where ci.collectionID in ({placeholders}) + and it.typeName not in ('attachment', 'note', 'annotation') + order by ci.itemID asc + """, + tuple(collection_ids), + ) + return [int(row["itemID"]) for row in rows] + + def _read_item_summaries( + self, + conn: sqlite3.Connection, + item_ids: list[int], + field_lookup: dict[int, str], + collections: dict[int, sqlite3.Row], + ) -> list[dict[str, object]]: + summaries: list[dict[str, object]] = [] + for item_id in item_ids: + row = conn.execute( + """ + select i.key, it.typeName + from items i + join itemTypes it on it.itemTypeID = i.itemTypeID + where i.itemID = ? + """, + (item_id,), + ).fetchone() + if row is None: + continue + item_data = self._read_item_data(conn, item_id, field_lookup) + summaries.append( + { + "item_key": str(row["key"]), + "title": item_data.get("title", "Untitled"), + "year": self._extract_year(item_data.get("date")), + "item_type": str(row["typeName"]), + "abstract": item_data.get("abstractNote", ""), + "collection_paths": self._read_collection_paths(conn, item_id, collections), + } + ) + return summaries + + @staticmethod + def _read_item_data( + conn: sqlite3.Connection, + item_id: int, + field_lookup: dict[int, str], + ) -> dict[str, str]: + data: dict[str, str] = {} + rows = conn.execute( + """ + select fieldID, value + from itemData + join itemDataValues using (valueID) + where itemID = ? + """, + (item_id,), + ) + for row in rows: + field_name = field_lookup.get(int(row["fieldID"])) + if field_name: + data[field_name] = str(row["value"]) + return data + + @staticmethod + def _read_creators(conn: sqlite3.Connection, item_id: int) -> list[str]: + rows = conn.execute( + """ + select firstName, lastName, fieldMode + from itemCreators + join creators using (creatorID) + where itemID = ? + order by orderIndex asc + """, + (item_id,), + ) + creators = [] + for row in rows: + if int(row["fieldMode"] or 0) == 1: + creators.append(str(row["lastName"])) + else: + first = str(row["firstName"] or "").strip() + last = str(row["lastName"] or "").strip() + creators.append(" ".join(part for part in (first, last) if part)) + return creators + + @staticmethod + def _read_tags(conn: sqlite3.Connection, item_id: int) -> list[str]: + rows = conn.execute( + """ + select tags.name + from itemTags + join tags using (tagID) + where itemTags.itemID = ? + order by lower(tags.name) asc + """, + (item_id,), + ) + return [str(row["name"]) for row in rows] + + def _read_collection_paths( + self, + conn: sqlite3.Connection, + item_id: int, + collection_lookup: dict[int, sqlite3.Row], + ) -> list[list[str]]: + rows = conn.execute( + "select collectionID from collectionItems where itemID = ? order by orderIndex asc", + (item_id,), + ) + return [self._build_collection_path(int(row["collectionID"]), collection_lookup) for row in rows] + + @staticmethod + def _build_collection_path( + collection_id: int, + collection_lookup: dict[int, sqlite3.Row], + ) -> list[str]: + path: list[str] = [] + current = collection_id + while current in collection_lookup: + row = collection_lookup[current] + path.append(str(row["collectionName"])) + parent = row["parentCollectionID"] + current = int(parent) if parent is not None else -1 + path.reverse() + return path + + def _read_notes(self, conn: sqlite3.Connection, item_id: int) -> list[str]: + rows = conn.execute( + "select note from itemNotes where parentItemID = ? order by itemID asc", + (item_id,), + ) + return [self._strip_markup(str(row["note"])) for row in rows] + + def _read_attachment_texts(self, conn: sqlite3.Connection, item_id: int) -> list[str]: + rows = conn.execute( + """ + select contentType, path + from itemAttachments + where parentItemID = ? + order by itemID asc + """, + (item_id,), + ) + texts: list[str] = [] + for row in rows: + text = self._extract_attachment_text(str(row["path"] or ""), str(row["contentType"] or "")) + if text: + texts.append(text) + return texts + + def _extract_attachment_text(self, raw_path: str, content_type: str) -> str: + attachment_path = self._resolve_attachment_path(raw_path) + if attachment_path is None or not attachment_path.exists(): + return "" + + if content_type == "application/pdf": + return self._extract_pdf_text(attachment_path) + + return attachment_path.read_text(encoding="utf-8", errors="ignore").strip() + + def _resolve_attachment_path(self, raw_path: str) -> Path | None: + if raw_path.startswith("storage:"): + relative_path = raw_path.removeprefix("storage:") + return self.zotero_data_dir / "storage" / relative_path + path = Path(raw_path) + if path.is_absolute(): + return path + return self.zotero_data_dir / raw_path + + @staticmethod + def _extract_pdf_text(path: Path) -> str: + completed = subprocess.run( + ["pdftotext", str(path), "-"], + check=False, + capture_output=True, + text=True, + ) + if completed.returncode != 0: + return "" + return completed.stdout.strip() + + @staticmethod + def _strip_markup(value: str) -> str: + without_tags = re.sub(r"<[^>]+>", " ", value) + normalized = html.unescape(without_tags) + return " ".join(normalized.split()) + + @staticmethod + def _extract_year(value: str | None) -> str | None: + if not value: + return None + match = re.search(r"(19|20)\d{2}", value) + if match: + return match.group(0) + return value[:4] if len(value) >= 4 else value diff --git a/tests/test_cards.py b/tests/test_cards.py new file mode 100644 index 0000000..778c769 --- /dev/null +++ b/tests/test_cards.py @@ -0,0 +1,68 @@ +import json +from pathlib import Path + +from zotero_kb.cards import CardBuilder +from zotero_kb.zotero_reader import ZoteroItemRecord + + +class FakeLlmClient: + def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]: + return { + "summary": "Merged metadata and attachment text improve drafting support.", + "core_claims": [ + "Project-scoped cards reduce irrelevant retrieval.", + ], + "methods": [ + "Combines notes, metadata, and full text.", + ], + "evidence": [ + "The merged pipeline improved citation precision.", + ], + "quotable_passages": [ + "Project-scoped card retrieval improves citation precision during drafting.", + ], + "writing_hints": [ + "Use when arguing for scoped retrieval during drafting.", + ], + "keywords": ["retrieval", "writing"], + } + + +def test_build_card_writes_markdown_and_indexes(tmp_path: Path) -> None: + item = ZoteroItemRecord( + item_key="PAPER0001", + title="Card Pipelines for Research Writing", + creators=["Alice Smith", "Bob Li"], + year="2024", + item_type="journalArticle", + abstract="Merged metadata and notes improve drafting support.", + tags=["llm", "writing"], + collection_paths=[["Theory", "Drafting"]], + notes=["Merged notes matter."], + attachment_texts=["This paper studies card pipelines for research writing."], + ) + builder = CardBuilder(workspace_dir=tmp_path, llm_client=FakeLlmClient()) + + result = builder.build_or_update(item) + + expected_path = ( + tmp_path + / "library" + / "collections" + / "Theory" + / "Drafting" + / "Card Pipelines for Research Writing [PAPER0001].md" + ) + assert result.card_path == expected_path + assert result.card_path.read_text(encoding="utf-8").startswith("---") + + cards_index = json.loads((tmp_path / "library" / "index" / "cards.json").read_text(encoding="utf-8")) + assert cards_index["PAPER0001"]["title"] == "Card Pipelines for Research Writing" + + items_index = json.loads((tmp_path / "library" / "index" / "items.json").read_text(encoding="utf-8")) + assert items_index["PAPER0001"]["card_path"] == str(expected_path) + + source_bundle = json.loads( + (tmp_path / "library" / "cache" / "source-bundles" / "PAPER0001.json").read_text(encoding="utf-8") + ) + assert source_bundle["item_key"] == "PAPER0001" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..34bc324 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,11 @@ +from zotero_kb.config import AppConfig + + +def test_from_env_normalizes_bridge_directory_path(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("ZOTERO_KB_WORKSPACE", str(tmp_path / "workspace")) + monkeypatch.setenv("ZOTERO_DATA_DIR", str(tmp_path / "zotero")) + monkeypatch.setenv("ZOTERO_BRIDGE_FILE", "workspace/bridge/") + + config = AppConfig.from_env() + + assert config.bridge_file == config.workspace_dir / "bridge" / "selected-items.json" diff --git a/tests/test_llm.py b/tests/test_llm.py new file mode 100644 index 0000000..f2d1240 --- /dev/null +++ b/tests/test_llm.py @@ -0,0 +1,78 @@ +import json + +import pytest + +from zotero_kb.llm import DeepSeekCardGenerationClient, create_card_generation_client + + +def test_create_card_generation_client_requires_deepseek_api_key() -> None: + with pytest.raises(ValueError, match="DEEPSEEK_API_KEY"): + create_card_generation_client("deepseek", "deepseek-chat", env={}) + + +def test_deepseek_client_uses_env_api_key_and_parses_json() -> None: + captured = {} + + def fake_request(url: str, payload: dict[str, object], headers: dict[str, str]) -> dict[str, object]: + captured["url"] = url + captured["payload"] = payload + captured["headers"] = headers + return { + "choices": [ + { + "message": { + "content": json.dumps( + { + "summary": "DeepSeek summary", + "core_claims": ["Claim A"], + "methods": ["Method A"], + "evidence": ["Evidence A"], + "quotable_passages": ["Quote A"], + "writing_hints": ["Hint A"], + "keywords": ["kw-a"], + } + ) + } + } + ] + } + + client = create_card_generation_client( + "deepseek", + "deepseek-chat", + env={"DEEPSEEK_API_KEY": "sk-test"}, + request_fn=fake_request, + ) + result = client.generate_card( + { + "title": "Scoped Retrieval for Drafting", + "abstract": "Merged metadata improves drafting.", + "notes": ["Project scoping helps."], + "attachment_texts": ["Attachment evidence."], + "tags": ["retrieval"], + } + ) + + assert result["summary"] == "DeepSeek summary" + assert captured["url"] == "https://api.deepseek.com/v1/chat/completions" + assert captured["payload"]["model"] == "deepseek-chat" + assert captured["headers"]["Authorization"] == "Bearer sk-test" + + +def test_deepseek_client_strips_markdown_fences() -> None: + client = DeepSeekCardGenerationClient( + api_key="sk-test", + request_fn=lambda *_args, **_kwargs: { + "choices": [ + { + "message": { + "content": "```json\n{\"summary\":\"Fence summary\",\"core_claims\":[],\"methods\":[],\"evidence\":[],\"quotable_passages\":[],\"writing_hints\":[],\"keywords\":[]}\n```" + } + } + ] + }, + ) + + result = client.generate_card({"title": "Paper"}) + + assert result["summary"] == "Fence summary" diff --git a/tests/test_projects.py b/tests/test_projects.py new file mode 100644 index 0000000..f73e90e --- /dev/null +++ b/tests/test_projects.py @@ -0,0 +1,76 @@ +import json +from pathlib import Path + +from zotero_kb.cards import CardBuilder +from zotero_kb.config import AppConfig +from zotero_kb.projects import ProjectService +from zotero_kb.workspace import Workspace +from zotero_kb.zotero_reader import ZoteroItemRecord + + +class FakeLlmClient: + def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]: + title = str(source_bundle["title"]) + if "Scoped" in title: + summary = "Project-scoped retrieval improves drafting." + claims = ["Project scoping improves citation precision."] + else: + summary = "Unrelated retrieval baseline." + claims = ["Baseline retrieval is broad."] + return { + "summary": summary, + "core_claims": claims, + "methods": ["Method details."], + "evidence": ["Evidence details."], + "quotable_passages": claims, + "writing_hints": [summary], + "keywords": ["retrieval"], + } + + +def _build_item(item_key: str, title: str) -> ZoteroItemRecord: + return ZoteroItemRecord( + item_key=item_key, + title=title, + creators=["Alice Smith"], + year="2024", + item_type="journalArticle", + abstract=title, + tags=["retrieval"], + collection_paths=[["Theory", "Drafting"]], + notes=[title], + attachment_texts=[title], + ) + + +def test_add_item_to_project_updates_selected_items_and_project_index(tmp_path: Path) -> None: + config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero") + workspace = Workspace(config) + workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini") + builder = CardBuilder(config.workspace_dir, FakeLlmClient()) + builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting")) + + service = ProjectService(config.workspace_dir) + payload = service.add_items("thesis-ch2", ["PAPER0001"]) + + assert payload["selected_items"] == ["PAPER0001"] + assert payload["cards"][0]["item_key"] == "PAPER0001" + + selected_items = json.loads( + (config.workspace_dir / "projects" / "thesis-ch2" / "selected-items.json").read_text(encoding="utf-8") + ) + assert selected_items == ["PAPER0001"] + + +def test_remove_item_from_project_updates_selected_items(tmp_path: Path) -> None: + config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero") + workspace = Workspace(config) + workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini") + builder = CardBuilder(config.workspace_dir, FakeLlmClient()) + builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting")) + + service = ProjectService(config.workspace_dir) + service.add_items("thesis-ch2", ["PAPER0001"]) + payload = service.remove_item("thesis-ch2", "PAPER0001") + + assert payload["selected_items"] == [] diff --git a/tests/test_skill_assets.py b/tests/test_skill_assets.py new file mode 100644 index 0000000..e28637d --- /dev/null +++ b/tests/test_skill_assets.py @@ -0,0 +1,10 @@ +from pathlib import Path + + +def test_skill_files_exist() -> None: + assert Path("skills/zotero-citation-recommender/SKILL.md").is_file() + assert Path("skills/zotero-citation-planner/SKILL.md").is_file() + + +def test_bridge_manifest_exists() -> None: + assert Path("zotero-bridge/src/manifest.json").is_file() diff --git a/tests/test_ui.py b/tests/test_ui.py index 77b5a22..a10008f 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -1,3 +1,5 @@ +import subprocess + from zotero_kb.api import create_app from zotero_kb.config import AppConfig @@ -37,13 +39,14 @@ def test_index_contains_import_window_controls(tmp_path) -> None: assert 'id="window-restore-button"' in html assert 'id="import-window-minimized-bar"' in html assert 'id="import-window-project-label"' in html - # New two-column import layout - assert 'id="import-items-list"' in html - assert 'id="item-preview"' in html - assert 'id="pending-count"' in html - assert 'id="done-count"' in html - assert 'id="select-all-pending"' in html - assert 'id="generate-cards-btn"' in html + assert 'id="window-clear-selection-button"' in html + assert 'id="window-import-selected-items-button"' in html + assert 'id="window-collection-status"' in html + assert 'id="window-collection-tree"' in html + assert 'id="window-collection-items"' in html + assert 'class="window-toolbar-actions"' in html + assert 'class="window-action-cluster"' in html + assert 'class="window-control-cluster"' in html assert 'isImportWindowOpen: false' in html assert "function openImportWindow()" in html assert "function closeImportWindow()" in html @@ -68,54 +71,73 @@ def test_index_window_frame_css(tmp_path) -> None: assert "persistWindowSessionState" in html -def test_index_import_window_has_two_column_layout(tmp_path) -> None: +def test_index_import_window_has_collection_import_layout(tmp_path) -> None: html = _get_index_html(tmp_path) - # Two-column split layout structure - assert 'class="import-split"' in html - assert 'class="import-left"' in html - assert 'class="import-right"' in html + assert 'class="window-pane collections"' in html + assert 'class="window-pane items"' in html - # Left column: list header, list, footer - assert 'class="import-list-header"' in html - assert 'id="import-items-list"' in html - assert 'class="import-list-footer"' in html - assert 'id="pending-count"' in html - assert 'id="done-count"' in html - assert 'id="select-all-pending"' in html - - # Right column: preview - assert 'id="item-preview"' in html - - # Action bar - assert 'class="import-actions"' in html - assert 'id="generate-cards-btn"' in html - - # CSS: import-split grid layout - assert ".import-split" in html - # CSS: import-item classes - assert ".import-item" in html - assert ".import-item.pending" in html - assert ".import-item.done" in html + assert 'id="window-collection-status"' in html + assert 'id="window-collection-tree"' in html + assert 'id="window-collection-items"' in html + assert ".window-pane.collections" in html + assert ".window-pane.items" in html + assert ".tree-list" in html + assert ".collection-items-list" in html -def test_index_has_import_state_and_cards_generate_api_calls(tmp_path) -> None: +def test_index_has_collection_import_api_calls(tmp_path) -> None: html = _get_index_html(tmp_path) - # loadImportState function fetches import-state - assert "function loadImportState()" in html + assert "function loadCollectionTree()" in html + assert "function loadCollectionItems(collectionKey)" in html + assert "/api/zotero/collections/tree" in html + assert "/api/zotero/collections/${collectionKey}/items" in html + assert "/api/projects/" in html - assert "import-state" in html + assert "imports/item-keys" in html + assert "function importSelectedItems()" in html - # generateSelectedCards function calls cards/generate - assert "function generateSelectedCards()" in html - assert "/cards/generate" in html - - # openImportWindow calls loadImportState + # openImportWindow calls loadCollectionTree assert "openImportWindow()" in html - # Check loadImportState is called inside openImportWindow (await statement appears) - assert "await loadImportState()" in html + assert "await loadCollectionTree()" in html - # generate-cards-btn has click listener for generateSelectedCards - assert 'generate-cards-btn' in html - assert 'generateSelectedCards' in html + +def test_index_has_responsive_import_window_geometry_helpers(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert "function computeResponsiveWindowRect()" in html + assert "function applyResponsiveWindowRect()" in html + assert "function syncWindowToViewport()" in html + assert 'window.addEventListener("resize"' in html + + +def test_index_has_responsive_import_window_layout_rules(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert "@media (max-width: 980px)" in html + assert ".window-pane.items" in html + assert ".window-body {" in html + assert "grid-template-columns: 1fr;" in html + assert "border-bottom: 1px solid var(--line);" in html + + +def test_index_inline_script_is_valid_javascript(tmp_path) -> None: + html = _get_index_html(tmp_path) + script_start = html.rfind("") + + assert script_start != -1 + assert script_end != -1 + + script_path = tmp_path / "index-inline.js" + script_path.write_text(html[script_start + len("