From cf7e2feb1981bece9451915297047c3c593de00f Mon Sep 17 00:00:00 2001 From: Saberlve Date: Tue, 21 Apr 2026 16:55:42 +0800 Subject: [PATCH] feat: Implement project management features including rename and delete functionality - Added project renaming capability in the workspace with appropriate API endpoints. - Implemented project deletion functionality, ensuring project directories are removed. - Updated UI to support project renaming and deletion, including inline editing and confirmation dialogs. - Enhanced batch generation feature for project items with selection and progress tracking. - Added tests for project renaming and deletion to ensure functionality and integrity. --- .../2026-04-16-import-window-responsive.md | 254 +++++++ ...16-project-items-and-inline-card-detail.md | 406 +++++++++++ ...6-04-16-import-window-responsive-design.md | 129 ++++ ...ect-items-and-inline-card-detail-design.md | 138 ++++ src/zotero_kb/api.py | 24 + src/zotero_kb/projects.py | 23 +- src/zotero_kb/templates/index.html | 689 ++++++++++++++++-- src/zotero_kb/workspace.py | 18 + tests/test_api.py | 92 ++- tests/test_projects.py | 32 + tests/test_ui.py | 70 ++ tests/test_workspace.py | 39 + 12 files changed, 1860 insertions(+), 54 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-16-import-window-responsive.md create mode 100644 docs/superpowers/plans/2026-04-16-project-items-and-inline-card-detail.md create mode 100644 docs/superpowers/specs/2026-04-16-import-window-responsive-design.md create mode 100644 docs/superpowers/specs/2026-04-16-project-items-and-inline-card-detail-design.md diff --git a/docs/superpowers/plans/2026-04-16-import-window-responsive.md b/docs/superpowers/plans/2026-04-16-import-window-responsive.md new file mode 100644 index 0000000..85297e7 --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-import-window-responsive.md @@ -0,0 +1,254 @@ +# Import Window Responsive 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:** Make the floating import window recompute a viewport-appropriate size and position every time it opens, and stack collection/item panes vertically on narrower viewports. + +**Architecture:** Keep the existing floating-window shell and collection importer flow, but replace reopen-time geometry reuse with viewport-derived geometry helpers. Add a narrow-layout breakpoint in the template CSS and a resize handler that constrains normal-mode geometry without overriding minimized or maximized state. + +**Tech Stack:** FastAPI template rendering, inline HTML/CSS/JavaScript, pytest, Node syntax checking + +--- + +### Task 1: Update UI Contract Tests For Responsive Window Behavior + +**Files:** +- Modify: `tests/test_ui.py` +- Test: `tests/test_ui.py` + +- [ ] **Step 1: Write the failing test** + +```python +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 +``` + +```python +def test_index_has_responsive_import_window_layout_rules(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert ".window-pane.items" in html + assert "@media (max-width: 980px)" in html + assert ".window-body {" in html + assert "grid-template-columns: 1fr;" in html + assert "border-bottom: 1px solid var(--line);" in html +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q` +Expected: FAIL because the new responsive geometry helper names are not present yet. + +- [ ] **Step 3: Keep the existing syntax guard test** + +Do not remove `test_index_inline_script_is_valid_javascript`; it remains the regression guard for the inline script. + +- [ ] **Step 4: Run test to verify the contract file still parses** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_inline_script_is_valid_javascript -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_ui.py +git commit -m "test: define responsive import window contract" +``` + +### Task 2: Implement Viewport-Derived Window Geometry + +**Files:** +- Modify: `src/zotero_kb/templates/index.html` +- Test: `tests/test_ui.py` + +- [ ] **Step 1: Write the failing test** + +Use the Task 1 tests as the failing contract. Do not add production code first. + +- [ ] **Step 2: Run the failing test** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q` +Expected: FAIL with missing responsive helper function names or missing layout assertions. + +- [ ] **Step 3: Write minimal implementation** + +Add responsive geometry helpers to the inline script in `src/zotero_kb/templates/index.html`: + +```javascript + function computeResponsiveWindowRect() { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const margin = viewportWidth <= 980 ? 8 : 24; + const minWidth = viewportWidth <= 980 ? 320 : 640; + const minHeight = viewportHeight <= 980 ? 420 : 480; + const maxWidth = Math.max(minWidth, viewportWidth - margin * 2); + const maxHeight = Math.max(minHeight, viewportHeight - margin * 2); + const width = Math.min(maxWidth, Math.max(minWidth, Math.round(viewportWidth * 0.88))); + const height = Math.min(maxHeight, Math.max(minHeight, Math.round(viewportHeight * 0.82))); + 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 }; + } + + 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 margin = window.innerWidth <= 980 ? 8 : 24; + const rect = computeResponsiveWindowRect(); + const currentWidth = parseFloat(fw.style.width) || rect.width; + const currentHeight = parseFloat(fw.style.height) || rect.height; + const width = Math.min(currentWidth, rect.width); + const height = Math.min(currentHeight, rect.height); + const maxLeft = Math.max(margin, window.innerWidth - width - margin); + const maxTop = Math.max(margin, window.innerHeight - height - margin); + const left = Math.min(Math.max(parseFloat(fw.style.left) || rect.left, margin), maxLeft); + const top = Math.min(Math.max(parseFloat(fw.style.top) || rect.top, 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; + } +``` + +Update `openImportWindow()` to call `applyResponsiveWindowRect()` after `initWindowFromSession()` when the window is not minimized or maximized. + +- [ ] **Step 4: Run the focused tests** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/zotero_kb/templates/index.html tests/test_ui.py +git commit -m "feat: derive import window geometry from viewport" +``` + +### Task 3: Add Responsive Layout Switching And Resize Wiring + +**Files:** +- Modify: `src/zotero_kb/templates/index.html` +- Test: `tests/test_ui.py` + +- [ ] **Step 1: Write the failing test** + +Use the layout assertions from Task 1 as the failing contract. No new production code before running them. + +- [ ] **Step 2: Run the failing test** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q` +Expected: FAIL if the resize listener or stacked layout rules are still missing. + +- [ ] **Step 3: Write minimal implementation** + +Extend the existing media query and event wiring in `src/zotero_kb/templates/index.html`: + +```css + @media (max-width: 980px) { + .window-frame { + min-width: 0; + width: calc(100vw - 1rem); + height: calc(100vh - 1rem); + left: 0.5rem; + top: 0.5rem; + } + .window-toolbar, + .window-toolbar-actions { + align-items: stretch; + } + .window-body { + grid-template-columns: 1fr; + } + .window-pane.collections { + border-right: 0; + border-bottom: 1px solid var(--line); + } + } +``` + +Wire the resize handler: + +```javascript + window.addEventListener("resize", () => { + syncWindowToViewport(); + }); +``` + +- [ ] **Step 4: Run the focused tests** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/zotero_kb/templates/index.html tests/test_ui.py +git commit -m "feat: make import window layout responsive" +``` + +### Task 4: End-To-End Verification + +**Files:** +- Modify: none +- Test: `tests/test_ui.py`, `tests/test_api.py` + +- [ ] **Step 1: Run the UI test suite** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q` +Expected: PASS with all UI contract tests green. + +- [ ] **Step 2: Run the API regression suite** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py -q` +Expected: PASS with all API tests green. + +- [ ] **Step 3: Run fresh JS syntax verification through pytest** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_inline_script_is_valid_javascript -q` +Expected: PASS + +- [ ] **Step 4: Manual verification** + +Run the app: + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv run python main.py +``` + +Manual checks: + +- open the import window at one browser zoom level and note the size +- close it, change browser zoom, reopen it, confirm it recenters and resizes +- shrink the viewport below the narrow breakpoint, reopen it, confirm the panes stack vertically +- maximize and minimize the window, confirm those modes still behave correctly + +- [ ] **Step 5: Commit** + +```bash +git add src/zotero_kb/templates/index.html tests/test_ui.py docs/superpowers/specs/2026-04-16-import-window-responsive-design.md docs/superpowers/plans/2026-04-16-import-window-responsive.md +git commit -m "feat: make import window adapt to viewport changes" +``` diff --git a/docs/superpowers/plans/2026-04-16-project-items-and-inline-card-detail.md b/docs/superpowers/plans/2026-04-16-project-items-and-inline-card-detail.md new file mode 100644 index 0000000..8fb889c --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-project-items-and-inline-card-detail.md @@ -0,0 +1,406 @@ +# Project Items And Inline Card Detail 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:** Show imported project items immediately after import, improve collection row readability, and render item detail inline under the active entry instead of in a fixed detail area. + +**Architecture:** Normalize project view data around a single `items` list in `ProjectService`, with each item carrying `card_status` plus card fields when available. Update the center panel renderer in the inline template script to render both pending and done items, and expand one item’s detail inline at a time. + +**Tech Stack:** Python, FastAPI template rendering, inline HTML/CSS/JavaScript, pytest + +--- + +### Task 1: Define Pending-Item Project View Contract + +**Files:** +- Modify: `tests/test_projects.py` +- Modify: `tests/test_api.py` +- Test: `tests/test_projects.py` +- Test: `tests/test_api.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_projects.py`: + +```python +def test_add_item_to_project_returns_pending_item_when_card_not_generated(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") + + items_index_path = config.workspace_dir / "library" / "index" / "items.json" + items_index_path.parent.mkdir(parents=True, exist_ok=True) + items_index_path.write_text( + json.dumps( + { + "PAPER0001": { + "item_key": "PAPER0001", + "title": "Pending Item", + "creators": ["Alice Smith"], + "year": "2024", + "item_type": "journalArticle", + } + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + service = ProjectService(config.workspace_dir) + payload = service.add_items("thesis-ch2", ["PAPER0001"]) + + assert payload["selected_items"] == ["PAPER0001"] + assert len(payload["items"]) == 1 + assert payload["items"][0]["item_key"] == "PAPER0001" + assert payload["items"][0]["card_status"] == "pending" +``` + +Add to `tests/test_api.py`: + +```python +def test_import_item_keys_endpoint_returns_pending_item_in_project_view(tmp_path: Path) -> None: + config = make_test_config(tmp_path) + config.bridge_file.unlink() + app = create_app(config, llm_client=FakeLlmClient()) + create_project = _route(app, "/api/projects", "POST") + import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST") + + create_project( + CreateProjectRequest( + project_id="thesis-ch2", + name="Thesis Chapter 2", + llm_provider="openai", + llm_model="gpt-5-mini", + ) + ) + + payload = import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]}) + + assert payload["project_view"]["selected_items"] == ["PAPER0001"] + assert payload["project_view"]["items"][0]["item_key"] == "PAPER0001" + assert payload["project_view"]["items"][0]["card_status"] == "pending" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py -q` +Expected: FAIL because project view currently only returns `cards`, not normalized `items`. + +- [ ] **Step 3: Write minimal implementation** + +Modify `src/zotero_kb/projects.py` so `_rebuild_project_index()` returns normalized items for both pending and done entries: + +```python + def _rebuild_project_index(self, project_id: str, selected_items: list[str]) -> dict[str, object]: + cards_index = self._read_json(self.index_dir / "cards.json") + items_index = self._read_json(self.index_dir / "items.json") + collections_index = self._read_json(self.index_dir / "collections.json") + + project_items: list[dict[str, object]] = [] + project_cards: list[dict[str, object]] = [] + for item_key in selected_items: + item_data = items_index.get(item_key, {}) + card_data = cards_index.get(item_key) + item_payload = { + "item_key": item_key, + "title": item_data.get("title", "Untitled"), + "creators": item_data.get("creators", []), + "year": item_data.get("year"), + "item_type": item_data.get("item_type", "unknown"), + "card_status": "done" if card_data else "pending", + "summary": card_data.get("summary") if card_data else None, + "claims": card_data.get("claims", []) if card_data else [], + "quotable_spans": card_data.get("quotable_spans", []) if card_data else [], + } + project_items.append(item_payload) + if card_data: + project_cards.append(card_data) + + project_collections = [ + payload + for payload in collections_index.values() + if set(payload.get("item_keys", [])) & set(selected_items) + ] + payload = { + "project_id": project_id, + "selected_items": selected_items, + "items": project_items, + "cards": project_cards, + "collections": project_collections, + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_projects.py tests/test_api.py src/zotero_kb/projects.py +git commit -m "feat: add pending project items to project view" +``` + +### Task 2: Define UI Contract For Inline Project Item Detail + +**Files:** +- Modify: `tests/test_ui.py` +- Test: `tests/test_ui.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_ui.py`: + +```python +def test_index_has_project_item_list_and_inline_detail_hooks(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert 'id="project-item-list"' in html + assert "expandedProjectItemKey" in html + assert "function renderProjectItems(items)" in html + assert "function toggleProjectItemDetail(itemKey)" in html + assert 'class="project-item-detail"' in html +``` + +```python +def test_index_collection_rows_use_readable_unselected_text(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert ".project-item strong" in html + assert ".project-item .meta" in html + assert ".collection-row-toggle" in html +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q` +Expected: FAIL because the page still renders only cards and uses the fixed detail region. + +- [ ] **Step 3: Write minimal implementation** + +Update `src/zotero_kb/templates/index.html`: + +- replace the center list container with: + +```html +
+``` + +- replace the fixed detail surface with a passive note: + +```html +
+

项目状态

+
导入后,项目条目会立即出现在上方列表;点击条目可就地展开详情。
+
+``` + +- add state: + +```javascript + expandedProjectItemKey: null, +``` + +- add renderers: + +```javascript + function toggleProjectItemDetail(itemKey) { + state.expandedProjectItemKey = state.expandedProjectItemKey === itemKey ? null : itemKey; + renderProjectItems(state.currentProjectItems); + } +``` + +```javascript + function renderProjectItems(items) { + state.currentProjectItems = items || []; + if (!state.currentProjectItems.length) { + elements.projectItemList.innerHTML = '
当前项目还没有条目。先导入 Zotero 文献。
'; + return; + } + + elements.projectItemList.innerHTML = ""; + for (const item of state.currentProjectItems) { + const card = document.createElement("div"); + card.className = "card-item"; + const statusLabel = item.card_status === "done" ? "已生成卡片" : "未生成卡片"; + card.innerHTML = ` + ${escapeHtml(item.title)} +
${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}
+

${item.card_status === "done" ? (item.summary || "暂无摘要") : "已导入项目,尚未生成卡片。"}

+
+ + + +
+ `; + const [detailButton, statusButton, removeButton] = card.querySelectorAll("button"); + detailButton.addEventListener("click", () => toggleProjectItemDetail(item.item_key)); + statusButton.disabled = true; + removeButton.addEventListener("click", () => removeCard(item.item_key)); + + if (state.expandedProjectItemKey === item.item_key) { + const detail = document.createElement("div"); + detail.className = "project-item-detail"; + detail.innerHTML = item.card_status === "done" + ? ` +
${item.item_key}
+

${item.summary || "暂无摘要"}

+

Claims

+ +

Quotable

+ + ` + : ` +
${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}
+

这篇文献已经导入到当前项目,但还没有生成卡片内容。

+ `; + card.appendChild(detail); + } + + elements.projectItemList.appendChild(card); + } + } +``` + +- add CSS: + +```css + .project-item strong, + .project-item .meta { + color: var(--ink); + } + .project-item.active strong, + .project-item.active .meta { + color: #f5f7ef; + } + .collection-row-toggle { + color: var(--accent-strong); + font-weight: 700; + } + .project-item-detail { + margin-top: 0.85rem; + padding-top: 0.85rem; + border-top: 1px solid var(--line); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_ui.py src/zotero_kb/templates/index.html +git commit -m "feat: render project items with inline detail" +``` + +### Task 3: Wire Project Item Rendering Into Import And Project Selection Flows + +**Files:** +- Modify: `src/zotero_kb/templates/index.html` +- Test: `tests/test_api.py` +- Test: `tests/test_ui.py` + +- [ ] **Step 1: Write the failing test** + +Reuse the UI/API failures from Tasks 1 and 2. No new production code first. + +- [ ] **Step 2: Run tests to verify the current behavior fails** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py tests/test_ui.py -q` +Expected: FAIL until the project list renderer uses `items`. + +- [ ] **Step 3: Write minimal implementation** + +Update the inline script in `src/zotero_kb/templates/index.html`: + +- state: + +```javascript + currentProjectItems: [], +``` + +- elements: + +```javascript + projectItemList: document.getElementById("project-item-list"), +``` + +- remove `showCardDetail()` and stop using `elements.cardDetail` + +- change empty-state and load paths: + +```javascript + async function selectProject(projectId, rerender = true) { + state.currentProjectId = projectId; + updateImportActionState(); + if (rerender) { + renderProjects(); + } + const payload = await api(`/api/projects/${projectId}/cards`); + renderProjectItems(payload.items || []); + } +``` + +```javascript + renderProjectItems(payload.project_view?.items || []); +``` + +```javascript + renderProjectItems([]); +``` + +- [ ] **Step 4: Run full targeted tests** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py tests/test_ui.py -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/zotero_kb/templates/index.html tests/test_projects.py tests/test_api.py tests/test_ui.py +git commit -m "feat: show imported project items before card generation" +``` + +### Task 4: Final Verification + +**Files:** +- Modify: none +- Test: `tests/test_projects.py` +- Test: `tests/test_api.py` +- Test: `tests/test_ui.py` + +- [ ] **Step 1: Run full test suite** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q` +Expected: PASS + +- [ ] **Step 2: Run JS syntax guard** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_inline_script_is_valid_javascript -q` +Expected: PASS + +- [ ] **Step 3: Manual verification** + +Run: + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv run python main.py +``` + +Manual checks: + +- unselected collections remain readable in the import window +- importing an item into a project makes it appear immediately in the center panel +- pending item detail expands inline below the clicked item +- generated item detail expands inline below the clicked item + +- [ ] **Step 4: Commit** + +```bash +git add src/zotero_kb/projects.py src/zotero_kb/templates/index.html tests/test_projects.py tests/test_api.py tests/test_ui.py docs/superpowers/specs/2026-04-16-project-items-and-inline-card-detail-design.md docs/superpowers/plans/2026-04-16-project-items-and-inline-card-detail.md +git commit -m "feat: show project items inline before card generation" +``` diff --git a/docs/superpowers/specs/2026-04-16-import-window-responsive-design.md b/docs/superpowers/specs/2026-04-16-import-window-responsive-design.md new file mode 100644 index 0000000..332572d --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-import-window-responsive-design.md @@ -0,0 +1,129 @@ +# 2026-04-16 Import Window Responsive Design + +## Goal + +Make the floating import window adapt to the current browser viewport automatically so the user does not need to manually resize it after changing browser zoom or window size. + +The floating window shell remains: + +- draggable +- minimizable +- maximizable +- closable + +The import content remains the restored collection importer: + +- left pane: Zotero collection tree +- right pane: collection items + +## User-Approved Behavior + +### Window sizing policy + +- Each time the import window opens, it recalculates its size and position from the current viewport. +- Previously saved manual width, height, and position are not reused on reopen. +- The window opens centered in the viewport. +- The default size is viewport-relative, using a large but bounded footprint. + +Recommended sizing rule: + +- width: about `88vw` +- height: about `82vh` +- clamp width and height to safe min/max values so content remains usable on smaller screens + +### Resize and zoom behavior + +- Browser zoom changes are treated the same as viewport changes. +- On `resize`, if the window is in normal mode, its dimensions and position are adjusted to remain visible within the viewport. +- If the window is maximized, existing maximize behavior remains authoritative. +- If the window is minimized, existing minimize behavior remains authoritative. + +### Layout adaptation + +- Wide viewport: keep the current two-pane horizontal layout. +- Narrow viewport: switch the import window body to a vertical stack. +- In stacked mode: + - top pane: collections + - bottom pane: collection items + +This avoids crushed side-by-side panes and avoids requiring horizontal scrolling. + +## Implementation Design + +### State model + +Keep the current floating-window state structure, but change how normal-mode geometry is derived: + +- persisted maximize/minimize flags can remain +- persisted normal-mode width/height/left/top are no longer the source of truth on reopen +- on open, recompute width, height, left, and top from the current viewport + +### New geometry helpers + +Add small helpers in the template script: + +- `computeResponsiveWindowRect()` + - derives width/height/left/top from `window.innerWidth` and `window.innerHeight` + - clamps to minimum and maximum bounds + - returns a centered rect +- `applyResponsiveWindowRect()` + - applies the computed rect to the floating window when in normal mode +- `syncWindowToViewport()` + - runs on resize + - keeps the window inside the visible viewport + - does nothing destructive when minimized or maximized + +### Layout switching + +Use CSS plus a narrow-width breakpoint for `.window-body`: + +- default: `grid-template-columns: 22rem minmax(0, 1fr)` +- narrow mode: `grid-template-columns: 1fr` + +The existing `.window-pane.collections` separator changes from right border to bottom border in stacked mode. + +### Interaction rules + +- Opening the window always recomputes the normal-mode rect. +- Manual dragging still works during the current open session. +- If the viewport changes while the window is open, normal mode is re-constrained to the viewport. +- Closing and reopening discards the session’s manual geometry and recomputes from the viewport again. + +## Testing + +Add or update UI tests to verify: + +- the responsive helper logic is present in the inline script +- opening the window uses viewport-based sizing instead of reopening from stale manual geometry +- the template contains the narrow-layout CSS for stacked panes +- the inline script remains valid JavaScript + +Manual verification target: + +- open import window at normal zoom +- change browser zoom or viewport size +- close and reopen +- confirm the window opens centered and proportionate to the new viewport +- confirm narrow viewport stacks collections above items + +## Risks and Mitigations + +### Risk: resize fights user drag + +Mitigation: + +- only recompute automatically on open +- on live resize, constrain only enough to keep the window visible + +### Risk: minimized/maximized modes get overwritten + +Mitigation: + +- gate responsive normal-mode logic behind checks for non-minimized and non-maximized state + +### Risk: small screens become unusable + +Mitigation: + +- stack panes vertically below the chosen breakpoint +- clamp dimensions and leave a small viewport margin diff --git a/docs/superpowers/specs/2026-04-16-project-items-and-inline-card-detail-design.md b/docs/superpowers/specs/2026-04-16-project-items-and-inline-card-detail-design.md new file mode 100644 index 0000000..058d6c3 --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-project-items-and-inline-card-detail-design.md @@ -0,0 +1,138 @@ +# 2026-04-16 Project Items And Inline Card Detail Design + +## Goal + +Fix three related UI/data issues: + +- collection 列表里未选中项文字太浅,保证未选中时也清晰可读 +- 文献导入项目后,项目主区要立刻显示这些条目,而不是等生成卡片后才出现 +- 卡片/条目详情改成在当前条目下方展开,不再集中显示在固定详情区域 + +## Approved Direction + +Use a single project-scoped item list as the main source of truth in the center panel. + +Each project item carries a `card_status`: + +- `pending`: 已导入项目,但还没生成卡片 +- `done`: 已生成卡片 + +The center panel renders all project items, not only completed cards. + +## Data Model + +### Backend project view + +`ProjectService.get_project_view()` should return: + +- `project_id` +- `selected_items` +- `items` +- `collections` + +Each `items[]` element should contain: + +- `item_key` +- `title` +- `creators` +- `year` +- `item_type` +- `card_status` +- card fields when available: + - `summary` + - `claims` + - `quotable_spans` + +This makes imported-but-not-generated items visible immediately after import. + +### Status semantics + +- imported via `imports/item-keys` and not yet generated => `pending` +- present in `cards.json` => `done` + +## UI Behavior + +### Collection tree readability + +Unselected collection cards keep the current light theme, but their text contrast must be increased: + +- title remains readable on light background +- meta line remains readable without relying on hover or selection state + +### Center panel item list + +Replace the current “only cards” rendering with a project item list: + +- `pending` items render as project entries with a clear “未生成卡片” state +- `done` items render as completed card entries +- remove the assumption that invisible means “not imported” + +### Inline detail expansion + +Clicking “查看详情” or the item itself expands detail directly below that same entry. + +Rules: + +- only one item detail is expanded at a time +- clicking the same item again collapses it +- clicking another item moves the expanded detail to that item + +### Expanded detail content + +For `pending`: + +- title +- item key / year / type +- a short message that the item is already in the project but card generation has not happened yet + +For `done`: + +- existing summary +- claims +- quotable spans + +## Frontend Structure + +### State + +Add a current expanded item key in the page script, e.g.: + +- `expandedProjectItemKey` + +### Rendering + +Replace the current `renderCards()` + fixed `showCardDetail()` pattern with: + +- a renderer for all project items +- inline expansion markup inserted under the active item + +The fixed side detail area can be removed or converted into a passive empty state, because detail no longer lives there. + +## Testing + +Add or update tests to cover: + +- project view includes imported pending items +- index template still contains the main page and valid inline script +- center panel contract no longer depends on a fixed detail-only area for current item detail +- inline project item detail hooks exist in the template script + +## Risks + +### Risk: center panel mixes two incompatible data shapes + +Mitigation: + +- make backend project view emit a single normalized `items` list + +### Risk: pending items have no detail data + +Mitigation: + +- pending inline detail shows metadata plus explicit status messaging, not empty summary sections + +### Risk: old fixed detail panel becomes misleading + +Mitigation: + +- remove dependence on it from the item interaction flow diff --git a/src/zotero_kb/api.py b/src/zotero_kb/api.py index 3999e1c..a2805b5 100644 --- a/src/zotero_kb/api.py +++ b/src/zotero_kb/api.py @@ -23,6 +23,10 @@ class CreateProjectRequest(BaseModel): llm_model: str = "gpt-5-mini" +class RenameProjectRequest(BaseModel): + name: str = Field(min_length=1) + + class WritingPromptRequest(BaseModel): prompt: str = Field(min_length=1) @@ -76,6 +80,26 @@ def create_app( result.append(_read_json(project_file)) return result + @app.patch("/api/projects/{project_id}") + def rename_project(project_id: str, payload: RenameProjectRequest) -> dict[str, object]: + try: + project = workspace.rename_project(project_id, payload.name) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="Project not found") from exc + return {"id": project.project_id, "name": project.name} + + @app.delete("/api/projects/{project_id}") + def delete_project(project_id: str) -> dict[str, object]: + try: + workspace.delete_project(project_id) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="Project not found") from exc + return {"deleted": project_id} + + @app.post("/api/projects/{project_id}/delete") + def delete_project_via_post(project_id: str) -> dict[str, object]: + return delete_project(project_id) + @app.get("/api/zotero/collections/tree") def zotero_collection_tree() -> dict[str, object]: return {"collections": reader.get_collection_tree()} diff --git a/src/zotero_kb/projects.py b/src/zotero_kb/projects.py index b21900c..4d51899 100644 --- a/src/zotero_kb/projects.py +++ b/src/zotero_kb/projects.py @@ -30,8 +30,27 @@ class ProjectService: def _rebuild_project_index(self, project_id: str, selected_items: list[str]) -> dict[str, object]: cards_index = self._read_json(self.index_dir / "cards.json") + items_index = self._read_json(self.index_dir / "items.json") collections_index = self._read_json(self.index_dir / "collections.json") - project_cards = [cards_index[item_key] for item_key in selected_items if item_key in cards_index] + project_items: list[dict[str, object]] = [] + project_cards: list[dict[str, object]] = [] + for item_key in selected_items: + item_data = items_index.get(item_key, {}) + card_data = cards_index.get(item_key) + item_payload = { + "item_key": item_key, + "title": item_data.get("title", "Untitled"), + "creators": item_data.get("creators", []), + "year": item_data.get("year"), + "item_type": item_data.get("item_type", "unknown"), + "card_status": "done" if card_data else "pending", + "summary": card_data.get("summary") if card_data else None, + "claims": card_data.get("claims", []) if card_data else [], + "quotable_spans": card_data.get("quotable_spans", []) if card_data else [], + } + project_items.append(item_payload) + if card_data: + project_cards.append(card_data) project_collections = [ payload for payload in collections_index.values() @@ -40,6 +59,7 @@ class ProjectService: payload = { "project_id": project_id, "selected_items": selected_items, + "items": project_items, "cards": project_cards, "collections": project_collections, } @@ -64,4 +84,3 @@ class ProjectService: @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/templates/index.html b/src/zotero_kb/templates/index.html index 82092a0..e38be62 100644 --- a/src/zotero_kb/templates/index.html +++ b/src/zotero_kb/templates/index.html @@ -114,10 +114,145 @@ padding: 0.9rem; background: rgba(255, 255, 255, 0.65); } + .project-item strong, + .project-item .meta { + color: var(--ink); + } .project-item.active { border-color: var(--accent); + background: var(--accent-strong); + color: #f7f2e8; box-shadow: 0 0 0 1px rgba(44, 110, 73, 0.15); } + .project-item.active strong, + .project-item.active .meta { + color: inherit; + } + .project-list-entry { + display: grid; + gap: 0.75rem; + } + .project-list-main { + width: 100%; + min-height: auto; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: inherit; + text-align: left; + box-shadow: none; + } + .project-list-main:hover { + transform: none; + background: transparent; + } + .project-list-main strong, + .project-list-main .meta { + color: inherit; + } + .project-list-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.5rem; + } + .project-list-actions button { + width: auto; + min-height: 2rem; + padding: 0.45rem 0.85rem; + } + .project-inline-form, + .project-delete-confirmation { + display: grid; + gap: 0.7rem; + } + .project-inline-form input, + .project-delete-confirmation p { + margin: 0; + } + .project-delete-confirmation p { + color: inherit; + } + .project-list-entry.active .project-list-actions .secondary { + background: rgba(247, 242, 232, 0.14); + color: #f7f2e8; + } + .project-list-entry.active .project-list-actions .danger { + background: rgba(139, 58, 42, 0.22); + color: #fff1ea; + } + .project-item-detail { + margin-top: 0.85rem; + padding-top: 0.85rem; + border-top: 1px solid var(--line); + } + .project-item-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + } + .project-item-card-main { + min-width: 0; + } + .project-item-card-main strong { + display: block; + } + .project-item-actions { + display: inline-flex; + align-items: center; + gap: 0.45rem; + flex-shrink: 0; + } + .project-item-status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2rem; + padding: 0.2rem 0.8rem; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 700; + white-space: nowrap; + border: 1px solid transparent; + } + .project-item-status-pill.pending { + background: rgba(185, 132, 53, 0.14); + color: #855d1d; + border-color: rgba(185, 132, 53, 0.25); + } + .project-item-status-pill.generating { + background: rgba(44, 110, 73, 0.14); + color: var(--accent-strong); + border-color: rgba(44, 110, 73, 0.22); + } + .project-item-status-pill.done { + background: rgba(44, 110, 73, 0.18); + color: var(--accent-strong); + border-color: rgba(44, 110, 73, 0.25); + } + .project-item-status-pill.failed { + background: rgba(139, 58, 42, 0.12); + color: var(--danger); + border-color: rgba(139, 58, 42, 0.2); + } + .project-item-generate-button { + width: auto; + min-height: 2rem; + padding: 0.45rem 0.9rem; + font-size: 0.82rem; + white-space: nowrap; + } + .project-item-status { + display: inline-flex; + align-items: center; + justify-content: center; + } + .collection-row-toggle { + color: var(--accent-strong); + font-weight: 700; + } .row { display: flex; gap: 0.6rem; @@ -303,6 +438,44 @@ .window-pane.items { background: rgba(255, 253, 248, 0.96); } + .batch-generate-frame { + width: min(52rem, calc(100vw - 2rem)); + height: min(42rem, calc(100vh - 2rem)); + left: max(1rem, calc((100vw - min(52rem, calc(100vw - 2rem))) / 2)); + top: max(1rem, calc((100vh - min(42rem, calc(100vh - 2rem))) / 2)); + min-width: 0; + min-height: 0; + } + .batch-generate-body { + display: grid; + grid-template-rows: auto 1fr; + min-height: 0; + } + .batch-generate-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.25rem 0.5rem; + } + .batch-generate-list { + padding: 0.5rem 1.25rem 1.25rem; + } + .batch-generate-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.85rem; + align-items: start; + } + .batch-generate-item input[type="checkbox"] { + margin-top: 0.35rem; + } + .batch-generate-item-main strong { + display: block; + } + .batch-generate-item[aria-disabled="true"] { + opacity: 0.72; + } .window-scroll { min-height: 0; overflow: auto; @@ -457,20 +630,50 @@
-

Cards

+

项目文献

当前未选择项目。

- +
+ + +
-
+
-

卡片详情

-
点击卡片后,这里会显示摘要、claims 和可引用内容。
+

项目状态

+
导入后,项目条目会立即出现在上方列表;点击条目可就地展开详情。
+ +

Writing

两个入口独立:候选引用推荐和初版引用方案。

@@ -535,9 +738,16 @@ const state = { projects: [], currentProjectId: null, - currentCards: [], + currentProjectItems: [], + projectItemGenerationState: {}, + editingProjectId: null, + editingProjectName: "", + confirmingDeleteProjectId: null, + savingProjectId: null, + deletingProjectId: null, collectionTree: [], isImportWindowOpen: false, + isBatchGenerateWindowOpen: 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, @@ -567,6 +777,9 @@ loadingCollectionKey: null, nextCollectionItemsRequestToken: 0, activeCollectionItemsRequestToken: 0, + expandedProjectItemKey: null, + batchGenerateSelectedKeys: new Set(), + batchGenerateProgress: { current: 0, total: 0, active: false }, hoveredItemKey: null, hoverTimerId: null, previewItemKey: null, @@ -594,9 +807,17 @@ windowCollectionItems: document.getElementById("window-collection-items"), previewPopover: document.getElementById("item-preview-popover"), currentProjectLabel: document.getElementById("current-project-label"), - cardList: document.getElementById("card-list"), - cardDetail: document.getElementById("card-detail"), + projectItemList: document.getElementById("project-item-list"), + openBatchGenerateButton: document.getElementById("open-batch-generate-button"), reloadCardsButton: document.getElementById("reload-cards-button"), + batchGenerateShell: document.getElementById("batch-generate-shell"), + batchGenerateWindow: document.getElementById("batch-generate-window"), + closeBatchGenerateButton: document.getElementById("close-batch-generate-button"), + selectPendingBatchButton: document.getElementById("select-pending-batch-button"), + clearBatchSelectionButton: document.getElementById("clear-batch-selection-button"), + startBatchGenerateButton: document.getElementById("start-batch-generate-button"), + batchGenerateProgress: document.getElementById("batch-generate-progress"), + batchGenerateList: document.getElementById("batch-generate-list"), refreshProjectsButton: document.getElementById("refresh-projects-button"), recommendForm: document.getElementById("recommend-form"), recommendStatus: document.getElementById("recommend-status"), @@ -851,59 +1072,405 @@ elements.projectList.innerHTML = ""; for (const project of state.projects) { - const item = document.createElement("button"); - item.type = "button"; - item.className = "project-item" + (project.id === state.currentProjectId ? " active" : ""); - item.innerHTML = ` - ${project.name} -
${project.id}
-
${project.llm?.provider || "deterministic"} / ${project.llm?.model || "-"}
- `; - item.addEventListener("click", () => selectProject(project.id)); + const item = document.createElement("div"); + item.className = "project-item project-list-entry" + (project.id === state.currentProjectId ? " active" : ""); + const isEditing = state.editingProjectId === project.id; + const isConfirmingDelete = state.confirmingDeleteProjectId === project.id; + const isSaving = state.savingProjectId === project.id; + const isDeleting = state.deletingProjectId === project.id; + + if (isEditing) { + item.innerHTML = ` +
+ +
${project.id}
+
${project.llm?.provider || "deterministic"} / ${project.llm?.model || "-"}
+
+ + +
+
+ `; + const form = item.querySelector(".project-inline-form"); + const input = form.querySelector("input"); + const cancelButton = form.querySelector('button[type="button"]'); + input.addEventListener("input", (event) => { + state.editingProjectName = event.target.value; + }); + form.addEventListener("submit", (event) => { + event.preventDefault(); + saveProjectRename(project.id).catch(() => null); + }); + cancelButton.addEventListener("click", cancelProjectRename); + } else if (isConfirmingDelete) { + item.innerHTML = ` +
+ ${escapeHtml(project.name)} +

删除后只会移除这个项目及其项目内索引,不会删除全局文献库。

+
+ + +
+
+ `; + const [confirmButton, cancelButton] = item.querySelectorAll(".project-list-actions button"); + confirmButton.addEventListener("click", () => { + deleteProject(project.id).catch(() => null); + }); + cancelButton.addEventListener("click", cancelProjectDelete); + } else { + item.innerHTML = ` + +
+ + +
+ `; + const mainButton = item.querySelector(".project-list-main"); + const [renameButton, deleteButton] = item.querySelectorAll(".project-list-actions button"); + mainButton.addEventListener("click", () => selectProject(project.id)); + renameButton.addEventListener("click", () => startProjectRename(project.id)); + deleteButton.addEventListener("click", () => confirmProjectDelete(project.id)); + } + elements.projectList.appendChild(item); } } - function renderCards(cards) { - state.currentCards = cards || []; - if (!state.currentCards.length) { - elements.cardList.innerHTML = '
当前项目还没有卡片。先导入 Zotero 选中文献。
'; - elements.cardDetail.innerHTML = '
导入后点击卡片查看详情。
'; + function startProjectRename(projectId) { + const project = state.projects.find((entry) => entry.id === projectId); + if (!project) { + return; + } + state.editingProjectId = projectId; + state.editingProjectName = project.name || ""; + state.confirmingDeleteProjectId = null; + renderProjects(); + } + + function cancelProjectRename() { + state.editingProjectId = null; + state.editingProjectName = ""; + state.savingProjectId = null; + renderProjects(); + } + + async function saveProjectRename(projectId) { + const nextName = state.editingProjectName.trim(); + if (!nextName) { + setStatus(elements.projectStatus, "项目名称不能为空。", true); + return; + } + state.savingProjectId = projectId; + renderProjects(); + try { + await api(`/api/projects/${projectId}`, { + method: "PATCH", + body: JSON.stringify({ name: nextName }), + }); + state.editingProjectId = null; + state.editingProjectName = ""; + state.savingProjectId = null; + setStatus(elements.projectStatus, "项目名称已更新。"); + await loadProjects(); + } catch (error) { + state.savingProjectId = null; + renderProjects(); + setStatus(elements.projectStatus, error.message, true); + } + } + + function confirmProjectDelete(projectId) { + state.confirmingDeleteProjectId = projectId; + state.editingProjectId = null; + state.editingProjectName = ""; + renderProjects(); + } + + function cancelProjectDelete() { + state.confirmingDeleteProjectId = null; + state.deletingProjectId = null; + renderProjects(); + } + + async function deleteProject(projectId) { + state.deletingProjectId = projectId; + renderProjects(); + try { + try { + await api(`/api/projects/${projectId}`, { method: "DELETE" }); + } catch (error) { + if (error.message === "Method Not Allowed") { + await api(`/api/projects/${projectId}/delete`, { method: "POST" }); + } else { + throw error; + } + } + state.confirmingDeleteProjectId = null; + state.deletingProjectId = null; + if (state.currentProjectId === projectId) { + state.currentProjectId = null; + state.expandedProjectItemKey = null; + } + setStatus(elements.projectStatus, "项目已删除。"); + await loadProjects(); + } catch (error) { + state.deletingProjectId = null; + renderProjects(); + setStatus(elements.projectStatus, error.message, true); + } + } + + function toggleProjectItemDetail(itemKey) { + state.expandedProjectItemKey = state.expandedProjectItemKey === itemKey ? null : itemKey; + renderProjectItems(state.currentProjectItems); + } + + function getDisplayedProjectItemStatus(item) { + return state.projectItemGenerationState[item.item_key] || item.card_status; + } + + function getProjectItemStatusLabel(status) { + if (status === "done") return "已生成"; + if (status === "generating") return "生成中"; + if (status === "failed") return "生成失败"; + return "未生成"; + } + + function canGenerateProjectItem(status) { + return status === "pending" || status === "failed"; + } + + function updateBatchGenerateControls() { + const hasProject = Boolean(state.currentProjectId); + const hasItems = state.currentProjectItems.length > 0; + const hasSelection = state.batchGenerateSelectedKeys.size > 0; + elements.openBatchGenerateButton.disabled = !hasProject || !hasItems; + elements.selectPendingBatchButton.disabled = !hasItems || state.batchGenerateProgress.active; + elements.clearBatchSelectionButton.disabled = !state.batchGenerateSelectedKeys.size || state.batchGenerateProgress.active; + elements.startBatchGenerateButton.disabled = !hasSelection || state.batchGenerateProgress.active; + } + + function updateBatchGenerateProgress() { + const progress = state.batchGenerateProgress; + if (!progress.total) { + elements.batchGenerateProgress.textContent = "未开始批量生成。"; + updateBatchGenerateControls(); + return; + } + elements.batchGenerateProgress.textContent = progress.active + ? `正在批量生成 ${progress.current} / ${progress.total}` + : `批量生成完成 ${progress.current} / ${progress.total}`; + updateBatchGenerateControls(); + } + + async function refreshCurrentProjectItems() { + if (!state.currentProjectId) { + state.currentProjectItems = []; + renderProjectItems([]); + return; + } + const payload = await api(`/api/projects/${state.currentProjectId}/cards`); + const freshItems = payload.items || []; + state.batchGenerateSelectedKeys = new Set( + Array.from(state.batchGenerateSelectedKeys).filter((itemKey) => freshItems.some((item) => item.item_key === itemKey)) + ); + renderProjectItems(freshItems); + } + + function renderBatchGenerateList() { + if (!state.currentProjectItems.length) { + elements.batchGenerateList.innerHTML = '
当前项目还没有已导入文献。
'; + updateBatchGenerateControls(); return; } - elements.cardList.innerHTML = ""; - for (const card of state.currentCards) { - const item = document.createElement("div"); - item.className = "card-item"; - item.innerHTML = ` - ${card.title} -
${card.item_key}
-

${card.summary || "暂无摘要"}

+ elements.batchGenerateList.innerHTML = ""; + for (const item of state.currentProjectItems) { + const status = getDisplayedProjectItemStatus(item); + const selectable = status !== "done" && status !== "generating"; + const row = document.createElement("label"); + row.className = "project-item batch-generate-item"; + row.setAttribute("aria-disabled", selectable ? "false" : "true"); + row.innerHTML = ` + +
+ ${escapeHtml(item.title)} +
${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}
+
+ ${getProjectItemStatusLabel(status)} + `; + const checkbox = row.querySelector("input"); + checkbox.addEventListener("change", () => { + if (checkbox.checked) { + state.batchGenerateSelectedKeys.add(item.item_key); + } else { + state.batchGenerateSelectedKeys.delete(item.item_key); + } + updateBatchGenerateControls(); + }); + elements.batchGenerateList.appendChild(row); + } + updateBatchGenerateControls(); + } + + function openBatchGenerateWindow() { + if (!state.currentProjectId) { + return; + } + state.isBatchGenerateWindowOpen = true; + elements.batchGenerateShell.hidden = false; + elements.batchGenerateShell.setAttribute("aria-hidden", "false"); + document.body.classList.toggle("modal-open", true); + renderBatchGenerateList(); + updateBatchGenerateProgress(); + } + + function closeBatchGenerateWindow() { + state.isBatchGenerateWindowOpen = false; + elements.batchGenerateShell.hidden = true; + elements.batchGenerateShell.setAttribute("aria-hidden", "true"); + if (!state.isImportWindowOpen) { + document.body.classList.toggle("modal-open", false); + } + } + + function selectAllPendingBatchItems() { + state.batchGenerateSelectedKeys = new Set( + state.currentProjectItems + .filter((item) => canGenerateProjectItem(getDisplayedProjectItemStatus(item))) + .map((item) => item.item_key) + ); + renderBatchGenerateList(); + } + + function clearBatchGenerateSelection() { + state.batchGenerateSelectedKeys.clear(); + renderBatchGenerateList(); + } + + async function generateProjectItem(itemKey) { + if (!state.currentProjectId) { + return; + } + state.projectItemGenerationState[itemKey] = "generating"; + renderProjectItems(state.currentProjectItems); + renderBatchGenerateList(); + try { + const payload = await api(`/api/projects/${state.currentProjectId}/cards/generate`, { + method: "POST", + body: JSON.stringify({ item_keys: [itemKey] }), + }); + if ((payload.failed || []).includes(itemKey)) { + state.projectItemGenerationState[itemKey] = "failed"; + } else { + delete state.projectItemGenerationState[itemKey]; + await refreshCurrentProjectItems(); + } + } catch (_error) { + state.projectItemGenerationState[itemKey] = "failed"; + } + renderProjectItems(state.currentProjectItems); + renderBatchGenerateList(); + } + + async function runBatchGenerate() { + const itemKeys = Array.from(state.batchGenerateSelectedKeys); + if (!itemKeys.length) { + updateBatchGenerateControls(); + return; + } + state.batchGenerateProgress = { current: 0, total: itemKeys.length, active: true }; + updateBatchGenerateProgress(); + for (const itemKey of itemKeys) { + await generateProjectItem(itemKey); + state.batchGenerateProgress.current += 1; + state.batchGenerateSelectedKeys.delete(itemKey); + updateBatchGenerateProgress(); + } + state.batchGenerateProgress.active = false; + renderBatchGenerateList(); + updateBatchGenerateProgress(); + } + + function renderProjectItems(items) { + state.currentProjectItems = items || []; + if (!state.currentProjectItems.length) { + elements.projectItemList.innerHTML = '
当前项目还没有条目。先导入 Zotero 文献。
'; + updateBatchGenerateControls(); + if (state.isBatchGenerateWindowOpen) { + renderBatchGenerateList(); + } + return; + } + + elements.projectItemList.innerHTML = ""; + for (const item of state.currentProjectItems) { + const card = document.createElement("div"); + card.className = "card-item"; + const status = getDisplayedProjectItemStatus(item); + const statusLabel = getProjectItemStatusLabel(status); + card.innerHTML = ` +
+
+ ${escapeHtml(item.title)} +
${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}
+
+
+ ${statusLabel} + ${canGenerateProjectItem(status) ? '' : ''} +
+
+

${status === "done" ? (escapeHtml(item.summary || "暂无摘要")) : (status === "generating" ? "正在生成卡片内容,请稍候。" : (status === "failed" ? "生成失败,可重试。" : "已导入项目,尚未生成卡片。"))}

`; - const [detailButton, removeButton] = item.querySelectorAll("button"); - detailButton.addEventListener("click", () => showCardDetail(card)); - removeButton.addEventListener("click", () => removeCard(card.item_key)); - elements.cardList.appendChild(item); - } - } + const detailButton = card.querySelector(".row .secondary"); + const generateButton = card.querySelector(".project-item-generate-button"); + const removeButton = card.querySelector(".danger"); + detailButton.addEventListener("click", () => toggleProjectItemDetail(item.item_key)); + if (generateButton) { + generateButton.addEventListener("click", () => { + generateProjectItem(item.item_key).catch(() => null); + }); + } + removeButton.addEventListener("click", () => removeCard(item.item_key)); - function showCardDetail(card) { - const claims = (card.claims || []).map((claim) => `
  • ${claim}
  • `).join("") || "
  • 暂无 claims
  • "; - const quotes = (card.quotable_spans || []).map((quote) => `
  • ${quote}
  • `).join("") || "
  • 暂无可引用片段
  • "; - elements.cardDetail.innerHTML = ` - ${card.title} -
    ${card.item_key}
    -

    ${card.summary || "暂无摘要"}

    -

    Claims

    - -

    Quotable

    - - `; + if (state.expandedProjectItemKey === item.item_key) { + const detail = document.createElement("div"); + detail.className = "project-item-detail"; + if (status === "done") { + const claims = (item.claims || []).map((claim) => `
  • ${escapeHtml(claim)}
  • `).join("") || "
  • 暂无 claims
  • "; + const quotes = (item.quotable_spans || []).map((quote) => `
  • ${escapeHtml(quote)}
  • `).join("") || "
  • 暂无可引用片段
  • "; + detail.innerHTML = ` +
    ${item.item_key}
    +

    ${escapeHtml(item.summary || "暂无摘要")}

    +

    Claims

    + +

    Quotable

    + + `; + } else { + detail.innerHTML = ` +
    ${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}
    +

    ${status === "generating" ? "这篇文献正在生成卡片内容,请稍候刷新状态。" : (status === "failed" ? "这篇文献生成卡片失败,你可以再次点击生成重试。" : "这篇文献已经导入到当前项目,但还没有生成卡片内容。")}

    + `; + } + card.appendChild(detail); + } + + elements.projectItemList.appendChild(card); + } + updateBatchGenerateControls(); + if (state.isBatchGenerateWindowOpen) { + renderBatchGenerateList(); + } } function renderRecommendationResults(results) { @@ -1034,6 +1601,7 @@ const toggle = document.createElement("button"); toggle.type = "button"; toggle.className = "secondary"; + toggle.classList.add("collection-row-toggle"); toggle.style.flex = "0 0 auto"; toggle.style.width = "auto"; toggle.style.padding = "0.55rem 0.75rem"; @@ -1226,7 +1794,7 @@ state.selectedItemKeys.clear(); persistImportSessionState(); renderCollectionItems(); - renderCards(payload.project_view?.cards || []); + renderProjectItems(payload.project_view?.items || []); setStatus(elements.windowCollectionStatus, "导入完成。"); updateImportActionState(); closeImportWindow(); @@ -1276,6 +1844,15 @@ async function loadProjects() { const projects = await api("/api/projects"); state.projects = projects; + if (state.editingProjectId && !projects.some((project) => project.id === state.editingProjectId)) { + state.editingProjectId = null; + state.editingProjectName = ""; + state.savingProjectId = null; + } + if (state.confirmingDeleteProjectId && !projects.some((project) => project.id === state.confirmingDeleteProjectId)) { + state.confirmingDeleteProjectId = null; + state.deletingProjectId = null; + } if (state.currentProjectId && !projects.some((project) => project.id === state.currentProjectId)) { state.currentProjectId = projects.length ? projects[0].id : null; } @@ -1284,11 +1861,12 @@ } renderProjects(); updateImportActionState(); + updateBatchGenerateControls(); if (state.currentProjectId) { await selectProject(state.currentProjectId, false); return; } - renderCards([]); + renderProjectItems([]); } async function selectProject(projectId, rerender = true) { @@ -1298,7 +1876,7 @@ renderProjects(); } const payload = await api(`/api/projects/${projectId}/cards`); - renderCards(payload.cards || []); + renderProjectItems(payload.items || []); } async function removeCard(itemKey) { @@ -1333,6 +1911,13 @@ } await selectProject(state.currentProjectId); }); + elements.openBatchGenerateButton.addEventListener("click", openBatchGenerateWindow); + elements.closeBatchGenerateButton.addEventListener("click", closeBatchGenerateWindow); + elements.selectPendingBatchButton.addEventListener("click", selectAllPendingBatchItems); + elements.clearBatchSelectionButton.addEventListener("click", clearBatchGenerateSelection); + elements.startBatchGenerateButton.addEventListener("click", () => { + runBatchGenerate().catch(() => null); + }); elements.refreshProjectsButton.addEventListener("click", loadProjects); @@ -1365,6 +1950,8 @@ document.addEventListener("keydown", (event) => { if (event.key === "Escape" && state.isImportWindowOpen) { closeImportWindow(); + } else if (event.key === "Escape" && state.isBatchGenerateWindowOpen) { + closeBatchGenerateWindow(); } }); diff --git a/src/zotero_kb/workspace.py b/src/zotero_kb/workspace.py index 931eee3..9bae46f 100644 --- a/src/zotero_kb/workspace.py +++ b/src/zotero_kb/workspace.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import shutil from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path @@ -59,6 +60,23 @@ class Workspace: return ProjectRecord(project_id=project_id, name=name, project_dir=project_dir) + def rename_project(self, project_id: str, name: str) -> ProjectRecord: + project_dir = self.config.workspace_dir / "projects" / project_id + project_file = project_dir / "project.json" + if not project_file.exists(): + raise FileNotFoundError(project_id) + + project_payload = json.loads(project_file.read_text(encoding="utf-8")) + project_payload["name"] = name + self._write_json(project_file, project_payload) + return ProjectRecord(project_id=project_id, name=name, project_dir=project_dir) + + def delete_project(self, project_id: str) -> None: + project_dir = self.config.workspace_dir / "projects" / project_id + if not project_dir.exists(): + raise FileNotFoundError(project_id) + shutil.rmtree(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/tests/test_api.py b/tests/test_api.py index a0b2c8e..01485a2 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,8 +1,10 @@ import json from pathlib import Path +from fastapi.testclient import TestClient + from tests.fixtures.build_zotero_fixture import build_fixture_zotero_dir -from zotero_kb.api import CreateProjectRequest, WritingPromptRequest, create_app +from zotero_kb.api import CreateProjectRequest, RenameProjectRequest, WritingPromptRequest, create_app from zotero_kb.api import GenerateCardsRequest from zotero_kb.config import AppConfig @@ -57,6 +59,71 @@ def test_create_project_endpoint(tmp_path: Path) -> None: assert response["id"] == "thesis-ch2" +def test_rename_project_endpoint_updates_project_name(tmp_path: Path) -> None: + app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient()) + create_project = _route(app, "/api/projects", "POST") + rename_project = _route(app, "/api/projects/{project_id}", "PATCH") + list_projects = _route(app, "/api/projects", "GET") + + create_project( + CreateProjectRequest( + project_id="thesis-ch2", + name="Old Name", + llm_provider="openai", + llm_model="gpt-5-mini", + ) + ) + + response = rename_project("thesis-ch2", RenameProjectRequest(name="New Name")) + + assert response["id"] == "thesis-ch2" + assert response["name"] == "New Name" + assert list_projects()[0]["name"] == "New Name" + + +def test_delete_project_endpoint_removes_project_from_list(tmp_path: Path) -> None: + app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient()) + create_project = _route(app, "/api/projects", "POST") + delete_project = _route(app, "/api/projects/{project_id}", "DELETE") + list_projects = _route(app, "/api/projects", "GET") + + create_project( + CreateProjectRequest( + project_id="thesis-ch2", + name="Thesis Chapter 2", + llm_provider="openai", + llm_model="gpt-5-mini", + ) + ) + + response = delete_project("thesis-ch2") + + assert response["deleted"] == "thesis-ch2" + assert list_projects() == [] + + +def test_delete_project_post_fallback_endpoint_removes_project_from_list(tmp_path: Path) -> None: + app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient()) + client = TestClient(app) + + create_response = client.post( + "/api/projects", + json={ + "project_id": "thesis-ch2", + "name": "Thesis Chapter 2", + "llm_provider": "openai", + "llm_model": "gpt-5-mini", + }, + ) + assert create_response.status_code == 201 + + delete_response = client.post("/api/projects/thesis-ch2/delete") + + assert delete_response.status_code == 200 + assert delete_response.json() == {"deleted": "thesis-ch2"} + assert client.get("/api/projects").json() == [] + + def test_import_selected_items_endpoint(tmp_path: Path) -> None: app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient()) create_project = _route(app, "/api/projects", "POST") @@ -300,3 +367,26 @@ def test_cards_generate_creates_cards_for_pending_items(tmp_path: Path) -> None: assert len(response["items"]) == 1 assert response["items"][0]["item_key"] == "PAPER0001" assert response["items"][0]["card_status"] == "done" + + +def test_import_item_keys_endpoint_returns_pending_item_in_project_view(tmp_path: Path) -> None: + config = make_test_config(tmp_path) + config.bridge_file.unlink() + app = create_app(config, llm_client=FakeLlmClient()) + create_project = _route(app, "/api/projects", "POST") + import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST") + + create_project( + CreateProjectRequest( + project_id="thesis-ch2", + name="Thesis Chapter 2", + llm_provider="openai", + llm_model="gpt-5-mini", + ) + ) + + payload = import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]}) + + assert payload["project_view"]["selected_items"] == ["PAPER0001"] + assert payload["project_view"]["items"][0]["item_key"] == "PAPER0001" + assert payload["project_view"]["items"][0]["card_status"] == "pending" diff --git a/tests/test_projects.py b/tests/test_projects.py index f73e90e..83a81e4 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -74,3 +74,35 @@ def test_remove_item_from_project_updates_selected_items(tmp_path: Path) -> None payload = service.remove_item("thesis-ch2", "PAPER0001") assert payload["selected_items"] == [] + + +def test_add_item_to_project_returns_pending_item_when_card_not_generated(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") + + items_index_path = config.workspace_dir / "library" / "index" / "items.json" + items_index_path.parent.mkdir(parents=True, exist_ok=True) + items_index_path.write_text( + json.dumps( + { + "PAPER0001": { + "item_key": "PAPER0001", + "title": "Pending Item", + "creators": ["Alice Smith"], + "year": "2024", + "item_type": "journalArticle", + } + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + service = ProjectService(config.workspace_dir) + payload = service.add_items("thesis-ch2", ["PAPER0001"]) + + assert payload["selected_items"] == ["PAPER0001"] + assert len(payload["items"]) == 1 + assert payload["items"][0]["item_key"] == "PAPER0001" + assert payload["items"][0]["card_status"] == "pending" diff --git a/tests/test_ui.py b/tests/test_ui.py index a10008f..9d1ca32 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -122,6 +122,76 @@ def test_index_has_responsive_import_window_layout_rules(tmp_path) -> None: assert "border-bottom: 1px solid var(--line);" in html +def test_index_has_project_item_list_and_inline_detail_hooks(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert 'id="project-item-list"' in html + assert "expandedProjectItemKey" in html + assert "function renderProjectItems(items)" in html + assert "function toggleProjectItemDetail(itemKey)" in html + assert ".project-item-detail" in html + + +def test_index_collection_rows_use_readable_unselected_text(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert ".project-item strong" in html + assert ".project-item .meta" in html + assert ".collection-row-toggle" in html + + +def test_index_project_item_active_state_uses_high_contrast_text(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert ".project-item.active {" in html + assert "background: var(--accent-strong);" in html + assert "color: #f7f2e8;" in html + assert ".project-item.active strong," in html + assert ".project-item.active .meta {" in html + assert "color: inherit;" in html + + +def test_index_has_project_rename_delete_controls(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert "editingProjectId" in html + assert "confirmingDeleteProjectId" in html + assert "function startProjectRename(projectId)" in html + assert "function cancelProjectRename()" in html + assert "function saveProjectRename(projectId)" in html + assert "function confirmProjectDelete(projectId)" in html + assert "function deleteProject(projectId)" in html + assert 'method: "DELETE"' in html + assert 'method: "POST"' in html + assert 'api(`/api/projects/${projectId}/delete`' in html + assert 'error.message === "Method Not Allowed"' in html + assert ".project-list-entry" in html + assert ".project-list-main" in html + assert ".project-list-actions" in html + assert ".project-inline-form" in html + assert ".project-delete-confirmation" in html + + +def test_index_has_project_item_generate_actions_and_batch_window(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert 'id="open-batch-generate-button"' in html + assert 'id="batch-generate-shell"' in html + assert 'id="batch-generate-window"' in html + assert 'id="batch-generate-list"' in html + assert 'id="select-pending-batch-button"' in html + assert 'id="clear-batch-selection-button"' in html + assert 'id="start-batch-generate-button"' in html + assert 'id="batch-generate-progress"' in html + assert "function openBatchGenerateWindow()" in html + assert "function closeBatchGenerateWindow()" in html + assert "function generateProjectItem(itemKey)" in html + assert "function selectAllPendingBatchItems()" in html + assert "function runBatchGenerate()" in html + assert ".project-item-status-pill" in html + assert ".project-item-generate-button" in html + + def test_index_inline_script_is_valid_javascript(tmp_path) -> None: html = _get_index_html(tmp_path) script_start = html.rfind("