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.
This commit is contained in:
parent
4d45602c45
commit
cf7e2feb19
254
docs/superpowers/plans/2026-04-16-import-window-responsive.md
Normal file
254
docs/superpowers/plans/2026-04-16-import-window-responsive.md
Normal file
@ -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"
|
||||
```
|
||||
@ -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
|
||||
<div id="project-item-list" class="card-list"></div>
|
||||
```
|
||||
|
||||
- replace the fixed detail surface with a passive note:
|
||||
|
||||
```html
|
||||
<div class="section surface">
|
||||
<h3>项目状态</h3>
|
||||
<div class="empty">导入后,项目条目会立即出现在上方列表;点击条目可就地展开详情。</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- 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 = '<div class="empty">当前项目还没有条目。先导入 Zotero 文献。</div>';
|
||||
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 = `
|
||||
<strong>${escapeHtml(item.title)}</strong>
|
||||
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
|
||||
<p>${item.card_status === "done" ? (item.summary || "暂无摘要") : "已导入项目,尚未生成卡片。"}</p>
|
||||
<div class="row">
|
||||
<button type="button" class="secondary">查看详情</button>
|
||||
<button type="button" class="${item.card_status === "done" ? "secondary" : ""}">${statusLabel}</button>
|
||||
<button type="button" class="danger">移出项目</button>
|
||||
</div>
|
||||
`;
|
||||
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"
|
||||
? `
|
||||
<div class="meta">${item.item_key}</div>
|
||||
<p>${item.summary || "暂无摘要"}</p>
|
||||
<h4>Claims</h4>
|
||||
<ul>${(item.claims || []).map((claim) => `<li>${escapeHtml(claim)}</li>`).join("") || "<li>暂无 claims</li>"}</ul>
|
||||
<h4>Quotable</h4>
|
||||
<ul>${(item.quotable_spans || []).map((quote) => `<li>${escapeHtml(quote)}</li>`).join("") || "<li>暂无可引用片段</li>"}</ul>
|
||||
`
|
||||
: `
|
||||
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
|
||||
<p>这篇文献已经导入到当前项目,但还没有生成卡片内容。</p>
|
||||
`;
|
||||
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"
|
||||
```
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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()}
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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 @@
|
||||
<section class="panel">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<div>
|
||||
<h2>Cards</h2>
|
||||
<h2>项目文献</h2>
|
||||
<p id="current-project-label">当前未选择项目。</p>
|
||||
</div>
|
||||
<button id="reload-cards-button" type="button" class="secondary">刷新卡片</button>
|
||||
<div class="row" style="justify-content:flex-end">
|
||||
<button id="open-batch-generate-button" type="button" class="secondary">批量生成文献</button>
|
||||
<button id="reload-cards-button" type="button" class="secondary">刷新文献</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="card-list" class="card-list"></div>
|
||||
<div id="project-item-list" class="card-list"></div>
|
||||
|
||||
<div class="section surface">
|
||||
<h3>卡片详情</h3>
|
||||
<div id="card-detail" class="empty">点击卡片后,这里会显示摘要、claims 和可引用内容。</div>
|
||||
<h3>项目状态</h3>
|
||||
<div class="empty">导入后,项目条目会立即出现在上方列表;点击条目可就地展开详情。</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="batch-generate-shell" class="window-shell" hidden aria-hidden="true">
|
||||
<section id="batch-generate-window" class="window-frame batch-generate-frame" role="dialog" aria-modal="true" aria-labelledby="batch-generate-title">
|
||||
<div class="window-toolbar">
|
||||
<div>
|
||||
<h3 id="batch-generate-title">批量生成文献卡片</h3>
|
||||
<p class="meta">当前项目内已导入文献</p>
|
||||
</div>
|
||||
<div class="window-toolbar-actions">
|
||||
<div class="window-action-cluster">
|
||||
<button id="select-pending-batch-button" type="button" class="secondary window-toolbar-button">一键选择未生成</button>
|
||||
<button id="clear-batch-selection-button" type="button" class="danger window-toolbar-button">清空选择</button>
|
||||
<button id="start-batch-generate-button" type="button" class="window-toolbar-button">开始生成</button>
|
||||
</div>
|
||||
<div class="window-control-cluster" aria-label="批量生成窗口控制">
|
||||
<button id="close-batch-generate-button" type="button" class="window-control-button close" title="关闭">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="batch-generate-body">
|
||||
<div class="batch-generate-summary">
|
||||
<div id="batch-generate-progress" class="status">未开始批量生成。</div>
|
||||
</div>
|
||||
<div id="batch-generate-list" class="batch-generate-list window-scroll collection-items-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Writing</h2>
|
||||
<p>两个入口独立:候选引用推荐和初版引用方案。</p>
|
||||
@ -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" : "");
|
||||
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 = `
|
||||
<strong>${project.name}</strong>
|
||||
<form class="project-inline-form">
|
||||
<input type="text" value="${escapeHtml(state.editingProjectName)}" aria-label="项目名称" />
|
||||
<div class="meta">${project.id}</div>
|
||||
<div class="meta">${project.llm?.provider || "deterministic"} / ${project.llm?.model || "-"}</div>
|
||||
<div class="project-list-actions">
|
||||
<button type="submit" ${isSaving ? "disabled" : ""}>保存</button>
|
||||
<button type="button" class="secondary">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
item.addEventListener("click", () => selectProject(project.id));
|
||||
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 = `
|
||||
<div class="project-delete-confirmation">
|
||||
<strong>${escapeHtml(project.name)}</strong>
|
||||
<p>删除后只会移除这个项目及其项目内索引,不会删除全局文献库。</p>
|
||||
<div class="project-list-actions">
|
||||
<button type="button" class="danger" ${isDeleting ? "disabled" : ""}>确认删除</button>
|
||||
<button type="button" class="secondary" ${isDeleting ? "disabled" : ""}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const [confirmButton, cancelButton] = item.querySelectorAll(".project-list-actions button");
|
||||
confirmButton.addEventListener("click", () => {
|
||||
deleteProject(project.id).catch(() => null);
|
||||
});
|
||||
cancelButton.addEventListener("click", cancelProjectDelete);
|
||||
} else {
|
||||
item.innerHTML = `
|
||||
<button type="button" class="project-list-main">
|
||||
<strong>${escapeHtml(project.name)}</strong>
|
||||
<div class="meta">${project.id}</div>
|
||||
<div class="meta">${project.llm?.provider || "deterministic"} / ${project.llm?.model || "-"}</div>
|
||||
</button>
|
||||
<div class="project-list-actions">
|
||||
<button type="button" class="secondary">改名</button>
|
||||
<button type="button" class="danger">删除</button>
|
||||
</div>
|
||||
`;
|
||||
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 = '<div class="empty">当前项目还没有卡片。先导入 Zotero 选中文献。</div>';
|
||||
elements.cardDetail.innerHTML = '<div class="empty">导入后点击卡片查看详情。</div>';
|
||||
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 = '<div class="empty">当前项目还没有已导入文献。</div>';
|
||||
updateBatchGenerateControls();
|
||||
return;
|
||||
}
|
||||
|
||||
elements.cardList.innerHTML = "";
|
||||
for (const card of state.currentCards) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "card-item";
|
||||
item.innerHTML = `
|
||||
<strong>${card.title}</strong>
|
||||
<div class="meta">${card.item_key}</div>
|
||||
<p>${card.summary || "暂无摘要"}</p>
|
||||
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 = `
|
||||
<input type="checkbox" ${state.batchGenerateSelectedKeys.has(item.item_key) ? "checked" : ""} ${selectable ? "" : "disabled"} />
|
||||
<div class="batch-generate-item-main">
|
||||
<strong>${escapeHtml(item.title)}</strong>
|
||||
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
|
||||
</div>
|
||||
<span class="project-item-status-pill ${status}">${getProjectItemStatusLabel(status)}</span>
|
||||
`;
|
||||
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 = '<div class="empty">当前项目还没有条目。先导入 Zotero 文献。</div>';
|
||||
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 = `
|
||||
<div class="project-item-card-header">
|
||||
<div class="project-item-card-main">
|
||||
<strong>${escapeHtml(item.title)}</strong>
|
||||
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
|
||||
</div>
|
||||
<div class="project-item-actions">
|
||||
<span class="project-item-status-pill ${status}">${statusLabel}</span>
|
||||
${canGenerateProjectItem(status) ? '<button type="button" class="secondary project-item-generate-button">生成</button>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<p>${status === "done" ? (escapeHtml(item.summary || "暂无摘要")) : (status === "generating" ? "正在生成卡片内容,请稍候。" : (status === "failed" ? "生成失败,可重试。" : "已导入项目,尚未生成卡片。"))}</p>
|
||||
<div class="row">
|
||||
<button type="button" class="secondary">查看详情</button>
|
||||
<button type="button" class="danger">移出项目</button>
|
||||
</div>
|
||||
`;
|
||||
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) => `<li>${claim}</li>`).join("") || "<li>暂无 claims</li>";
|
||||
const quotes = (card.quotable_spans || []).map((quote) => `<li>${quote}</li>`).join("") || "<li>暂无可引用片段</li>";
|
||||
elements.cardDetail.innerHTML = `
|
||||
<strong>${card.title}</strong>
|
||||
<div class="meta">${card.item_key}</div>
|
||||
<p>${card.summary || "暂无摘要"}</p>
|
||||
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) => `<li>${escapeHtml(claim)}</li>`).join("") || "<li>暂无 claims</li>";
|
||||
const quotes = (item.quotable_spans || []).map((quote) => `<li>${escapeHtml(quote)}</li>`).join("") || "<li>暂无可引用片段</li>";
|
||||
detail.innerHTML = `
|
||||
<div class="meta">${item.item_key}</div>
|
||||
<p>${escapeHtml(item.summary || "暂无摘要")}</p>
|
||||
<h4>Claims</h4>
|
||||
<ul>${claims}</ul>
|
||||
<h4>Quotable</h4>
|
||||
<ul>${quotes}</ul>
|
||||
`;
|
||||
} else {
|
||||
detail.innerHTML = `
|
||||
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
|
||||
<p>${status === "generating" ? "这篇文献正在生成卡片内容,请稍候刷新状态。" : (status === "failed" ? "这篇文献生成卡片失败,你可以再次点击生成重试。" : "这篇文献已经导入到当前项目,但还没有生成卡片内容。")}</p>
|
||||
`;
|
||||
}
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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("<script>")
|
||||
|
||||
@ -32,3 +32,42 @@ def test_create_project_writes_project_files(tmp_path: Path) -> None:
|
||||
assert (config.workspace_dir / "projects" / "thesis-ch2" / "project.json").is_file()
|
||||
assert (config.workspace_dir / "projects" / "thesis-ch2" / "selected-items.json").is_file()
|
||||
assert (config.workspace_dir / "projects" / "thesis-ch2" / "project-index.json").is_file()
|
||||
|
||||
|
||||
def test_rename_project_updates_project_name(tmp_path: Path) -> None:
|
||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
||||
workspace = Workspace(config)
|
||||
workspace.ensure_layout()
|
||||
workspace.create_project(
|
||||
project_id="thesis-ch2",
|
||||
name="Old Name",
|
||||
llm_provider="openai",
|
||||
llm_model="gpt-5-mini",
|
||||
)
|
||||
|
||||
project = workspace.rename_project("thesis-ch2", "New Name")
|
||||
|
||||
assert project.project_id == "thesis-ch2"
|
||||
assert project.name == "New Name"
|
||||
assert '"name": "New Name"' in (
|
||||
config.workspace_dir / "projects" / "thesis-ch2" / "project.json"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_delete_project_removes_project_directory_only(tmp_path: Path) -> None:
|
||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
||||
workspace = Workspace(config)
|
||||
workspace.ensure_layout()
|
||||
workspace.create_project(
|
||||
project_id="thesis-ch2",
|
||||
name="Thesis Chapter 2",
|
||||
llm_provider="openai",
|
||||
llm_model="gpt-5-mini",
|
||||
)
|
||||
preserved_index = config.workspace_dir / "library" / "index" / "items.json"
|
||||
preserved_index.write_text('{"PAPER0001": {"title": "Keep me"}}', encoding="utf-8")
|
||||
|
||||
workspace.delete_project("thesis-ch2")
|
||||
|
||||
assert not (config.workspace_dir / "projects" / "thesis-ch2").exists()
|
||||
assert preserved_index.is_file()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user