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"
|
llm_model: str = "gpt-5-mini"
|
||||||
|
|
||||||
|
|
||||||
|
class RenameProjectRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
class WritingPromptRequest(BaseModel):
|
class WritingPromptRequest(BaseModel):
|
||||||
prompt: str = Field(min_length=1)
|
prompt: str = Field(min_length=1)
|
||||||
|
|
||||||
@ -76,6 +80,26 @@ def create_app(
|
|||||||
result.append(_read_json(project_file))
|
result.append(_read_json(project_file))
|
||||||
return result
|
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")
|
@app.get("/api/zotero/collections/tree")
|
||||||
def zotero_collection_tree() -> dict[str, object]:
|
def zotero_collection_tree() -> dict[str, object]:
|
||||||
return {"collections": reader.get_collection_tree()}
|
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]:
|
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")
|
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")
|
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 = [
|
project_collections = [
|
||||||
payload
|
payload
|
||||||
for payload in collections_index.values()
|
for payload in collections_index.values()
|
||||||
@ -40,6 +59,7 @@ class ProjectService:
|
|||||||
payload = {
|
payload = {
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"selected_items": selected_items,
|
"selected_items": selected_items,
|
||||||
|
"items": project_items,
|
||||||
"cards": project_cards,
|
"cards": project_cards,
|
||||||
"collections": project_collections,
|
"collections": project_collections,
|
||||||
}
|
}
|
||||||
@ -64,4 +84,3 @@ class ProjectService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _write_json(path: Path, payload: object) -> None:
|
def _write_json(path: Path, payload: object) -> None:
|
||||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|||||||
@ -114,10 +114,145 @@
|
|||||||
padding: 0.9rem;
|
padding: 0.9rem;
|
||||||
background: rgba(255, 255, 255, 0.65);
|
background: rgba(255, 255, 255, 0.65);
|
||||||
}
|
}
|
||||||
|
.project-item strong,
|
||||||
|
.project-item .meta {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
.project-item.active {
|
.project-item.active {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-strong);
|
||||||
|
color: #f7f2e8;
|
||||||
box-shadow: 0 0 0 1px rgba(44, 110, 73, 0.15);
|
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 {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
@ -303,6 +438,44 @@
|
|||||||
.window-pane.items {
|
.window-pane.items {
|
||||||
background: rgba(255, 253, 248, 0.96);
|
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 {
|
.window-scroll {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@ -457,20 +630,50 @@
|
|||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="row" style="justify-content: space-between">
|
<div class="row" style="justify-content: space-between">
|
||||||
<div>
|
<div>
|
||||||
<h2>Cards</h2>
|
<h2>项目文献</h2>
|
||||||
<p id="current-project-label">当前未选择项目。</p>
|
<p id="current-project-label">当前未选择项目。</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div id="card-list" class="card-list"></div>
|
<div id="project-item-list" class="card-list"></div>
|
||||||
|
|
||||||
<div class="section surface">
|
<div class="section surface">
|
||||||
<h3>卡片详情</h3>
|
<h3>项目状态</h3>
|
||||||
<div id="card-detail" class="empty">点击卡片后,这里会显示摘要、claims 和可引用内容。</div>
|
<div class="empty">导入后,项目条目会立即出现在上方列表;点击条目可就地展开详情。</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="panel">
|
||||||
<h2>Writing</h2>
|
<h2>Writing</h2>
|
||||||
<p>两个入口独立:候选引用推荐和初版引用方案。</p>
|
<p>两个入口独立:候选引用推荐和初版引用方案。</p>
|
||||||
@ -535,9 +738,16 @@
|
|||||||
const state = {
|
const state = {
|
||||||
projects: [],
|
projects: [],
|
||||||
currentProjectId: null,
|
currentProjectId: null,
|
||||||
currentCards: [],
|
currentProjectItems: [],
|
||||||
|
projectItemGenerationState: {},
|
||||||
|
editingProjectId: null,
|
||||||
|
editingProjectName: "",
|
||||||
|
confirmingDeleteProjectId: null,
|
||||||
|
savingProjectId: null,
|
||||||
|
deletingProjectId: null,
|
||||||
collectionTree: [],
|
collectionTree: [],
|
||||||
isImportWindowOpen: false,
|
isImportWindowOpen: false,
|
||||||
|
isBatchGenerateWindowOpen: false,
|
||||||
windowX: typeof windowSessionState.x === "number" ? windowSessionState.x : null,
|
windowX: typeof windowSessionState.x === "number" ? windowSessionState.x : null,
|
||||||
windowY: typeof windowSessionState.y === "number" ? windowSessionState.y : null,
|
windowY: typeof windowSessionState.y === "number" ? windowSessionState.y : null,
|
||||||
windowWidth: typeof windowSessionState.width === "number" ? windowSessionState.width : null,
|
windowWidth: typeof windowSessionState.width === "number" ? windowSessionState.width : null,
|
||||||
@ -567,6 +777,9 @@
|
|||||||
loadingCollectionKey: null,
|
loadingCollectionKey: null,
|
||||||
nextCollectionItemsRequestToken: 0,
|
nextCollectionItemsRequestToken: 0,
|
||||||
activeCollectionItemsRequestToken: 0,
|
activeCollectionItemsRequestToken: 0,
|
||||||
|
expandedProjectItemKey: null,
|
||||||
|
batchGenerateSelectedKeys: new Set(),
|
||||||
|
batchGenerateProgress: { current: 0, total: 0, active: false },
|
||||||
hoveredItemKey: null,
|
hoveredItemKey: null,
|
||||||
hoverTimerId: null,
|
hoverTimerId: null,
|
||||||
previewItemKey: null,
|
previewItemKey: null,
|
||||||
@ -594,9 +807,17 @@
|
|||||||
windowCollectionItems: document.getElementById("window-collection-items"),
|
windowCollectionItems: document.getElementById("window-collection-items"),
|
||||||
previewPopover: document.getElementById("item-preview-popover"),
|
previewPopover: document.getElementById("item-preview-popover"),
|
||||||
currentProjectLabel: document.getElementById("current-project-label"),
|
currentProjectLabel: document.getElementById("current-project-label"),
|
||||||
cardList: document.getElementById("card-list"),
|
projectItemList: document.getElementById("project-item-list"),
|
||||||
cardDetail: document.getElementById("card-detail"),
|
openBatchGenerateButton: document.getElementById("open-batch-generate-button"),
|
||||||
reloadCardsButton: document.getElementById("reload-cards-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"),
|
refreshProjectsButton: document.getElementById("refresh-projects-button"),
|
||||||
recommendForm: document.getElementById("recommend-form"),
|
recommendForm: document.getElementById("recommend-form"),
|
||||||
recommendStatus: document.getElementById("recommend-status"),
|
recommendStatus: document.getElementById("recommend-status"),
|
||||||
@ -851,59 +1072,405 @@
|
|||||||
|
|
||||||
elements.projectList.innerHTML = "";
|
elements.projectList.innerHTML = "";
|
||||||
for (const project of state.projects) {
|
for (const project of state.projects) {
|
||||||
const item = document.createElement("button");
|
const item = document.createElement("div");
|
||||||
item.type = "button";
|
item.className = "project-item project-list-entry" + (project.id === state.currentProjectId ? " active" : "");
|
||||||
item.className = "project-item" + (project.id === state.currentProjectId ? " active" : "");
|
const isEditing = state.editingProjectId === project.id;
|
||||||
item.innerHTML = `
|
const isConfirmingDelete = state.confirmingDeleteProjectId === project.id;
|
||||||
<strong>${project.name}</strong>
|
const isSaving = state.savingProjectId === project.id;
|
||||||
<div class="meta">${project.id}</div>
|
const isDeleting = state.deletingProjectId === project.id;
|
||||||
<div class="meta">${project.llm?.provider || "deterministic"} / ${project.llm?.model || "-"}</div>
|
|
||||||
`;
|
if (isEditing) {
|
||||||
item.addEventListener("click", () => selectProject(project.id));
|
item.innerHTML = `
|
||||||
|
<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>
|
||||||
|
`;
|
||||||
|
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);
|
elements.projectList.appendChild(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCards(cards) {
|
function startProjectRename(projectId) {
|
||||||
state.currentCards = cards || [];
|
const project = state.projects.find((entry) => entry.id === projectId);
|
||||||
if (!state.currentCards.length) {
|
if (!project) {
|
||||||
elements.cardList.innerHTML = '<div class="empty">当前项目还没有卡片。先导入 Zotero 选中文献。</div>';
|
return;
|
||||||
elements.cardDetail.innerHTML = '<div class="empty">导入后点击卡片查看详情。</div>';
|
}
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
elements.cardList.innerHTML = "";
|
elements.batchGenerateList.innerHTML = "";
|
||||||
for (const card of state.currentCards) {
|
for (const item of state.currentProjectItems) {
|
||||||
const item = document.createElement("div");
|
const status = getDisplayedProjectItemStatus(item);
|
||||||
item.className = "card-item";
|
const selectable = status !== "done" && status !== "generating";
|
||||||
item.innerHTML = `
|
const row = document.createElement("label");
|
||||||
<strong>${card.title}</strong>
|
row.className = "project-item batch-generate-item";
|
||||||
<div class="meta">${card.item_key}</div>
|
row.setAttribute("aria-disabled", selectable ? "false" : "true");
|
||||||
<p>${card.summary || "暂无摘要"}</p>
|
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">
|
<div class="row">
|
||||||
<button type="button" class="secondary">查看详情</button>
|
<button type="button" class="secondary">查看详情</button>
|
||||||
<button type="button" class="danger">移出项目</button>
|
<button type="button" class="danger">移出项目</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
const [detailButton, removeButton] = item.querySelectorAll("button");
|
const detailButton = card.querySelector(".row .secondary");
|
||||||
detailButton.addEventListener("click", () => showCardDetail(card));
|
const generateButton = card.querySelector(".project-item-generate-button");
|
||||||
removeButton.addEventListener("click", () => removeCard(card.item_key));
|
const removeButton = card.querySelector(".danger");
|
||||||
elements.cardList.appendChild(item);
|
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) {
|
if (state.expandedProjectItemKey === item.item_key) {
|
||||||
const claims = (card.claims || []).map((claim) => `<li>${claim}</li>`).join("") || "<li>暂无 claims</li>";
|
const detail = document.createElement("div");
|
||||||
const quotes = (card.quotable_spans || []).map((quote) => `<li>${quote}</li>`).join("") || "<li>暂无可引用片段</li>";
|
detail.className = "project-item-detail";
|
||||||
elements.cardDetail.innerHTML = `
|
if (status === "done") {
|
||||||
<strong>${card.title}</strong>
|
const claims = (item.claims || []).map((claim) => `<li>${escapeHtml(claim)}</li>`).join("") || "<li>暂无 claims</li>";
|
||||||
<div class="meta">${card.item_key}</div>
|
const quotes = (item.quotable_spans || []).map((quote) => `<li>${escapeHtml(quote)}</li>`).join("") || "<li>暂无可引用片段</li>";
|
||||||
<p>${card.summary || "暂无摘要"}</p>
|
detail.innerHTML = `
|
||||||
<h4>Claims</h4>
|
<div class="meta">${item.item_key}</div>
|
||||||
<ul>${claims}</ul>
|
<p>${escapeHtml(item.summary || "暂无摘要")}</p>
|
||||||
<h4>Quotable</h4>
|
<h4>Claims</h4>
|
||||||
<ul>${quotes}</ul>
|
<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) {
|
function renderRecommendationResults(results) {
|
||||||
@ -1034,6 +1601,7 @@
|
|||||||
const toggle = document.createElement("button");
|
const toggle = document.createElement("button");
|
||||||
toggle.type = "button";
|
toggle.type = "button";
|
||||||
toggle.className = "secondary";
|
toggle.className = "secondary";
|
||||||
|
toggle.classList.add("collection-row-toggle");
|
||||||
toggle.style.flex = "0 0 auto";
|
toggle.style.flex = "0 0 auto";
|
||||||
toggle.style.width = "auto";
|
toggle.style.width = "auto";
|
||||||
toggle.style.padding = "0.55rem 0.75rem";
|
toggle.style.padding = "0.55rem 0.75rem";
|
||||||
@ -1226,7 +1794,7 @@
|
|||||||
state.selectedItemKeys.clear();
|
state.selectedItemKeys.clear();
|
||||||
persistImportSessionState();
|
persistImportSessionState();
|
||||||
renderCollectionItems();
|
renderCollectionItems();
|
||||||
renderCards(payload.project_view?.cards || []);
|
renderProjectItems(payload.project_view?.items || []);
|
||||||
setStatus(elements.windowCollectionStatus, "导入完成。");
|
setStatus(elements.windowCollectionStatus, "导入完成。");
|
||||||
updateImportActionState();
|
updateImportActionState();
|
||||||
closeImportWindow();
|
closeImportWindow();
|
||||||
@ -1276,6 +1844,15 @@
|
|||||||
async function loadProjects() {
|
async function loadProjects() {
|
||||||
const projects = await api("/api/projects");
|
const projects = await api("/api/projects");
|
||||||
state.projects = 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)) {
|
if (state.currentProjectId && !projects.some((project) => project.id === state.currentProjectId)) {
|
||||||
state.currentProjectId = projects.length ? projects[0].id : null;
|
state.currentProjectId = projects.length ? projects[0].id : null;
|
||||||
}
|
}
|
||||||
@ -1284,11 +1861,12 @@
|
|||||||
}
|
}
|
||||||
renderProjects();
|
renderProjects();
|
||||||
updateImportActionState();
|
updateImportActionState();
|
||||||
|
updateBatchGenerateControls();
|
||||||
if (state.currentProjectId) {
|
if (state.currentProjectId) {
|
||||||
await selectProject(state.currentProjectId, false);
|
await selectProject(state.currentProjectId, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderCards([]);
|
renderProjectItems([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectProject(projectId, rerender = true) {
|
async function selectProject(projectId, rerender = true) {
|
||||||
@ -1298,7 +1876,7 @@
|
|||||||
renderProjects();
|
renderProjects();
|
||||||
}
|
}
|
||||||
const payload = await api(`/api/projects/${projectId}/cards`);
|
const payload = await api(`/api/projects/${projectId}/cards`);
|
||||||
renderCards(payload.cards || []);
|
renderProjectItems(payload.items || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeCard(itemKey) {
|
async function removeCard(itemKey) {
|
||||||
@ -1333,6 +1911,13 @@
|
|||||||
}
|
}
|
||||||
await selectProject(state.currentProjectId);
|
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);
|
elements.refreshProjectsButton.addEventListener("click", loadProjects);
|
||||||
|
|
||||||
@ -1365,6 +1950,8 @@
|
|||||||
document.addEventListener("keydown", (event) => {
|
document.addEventListener("keydown", (event) => {
|
||||||
if (event.key === "Escape" && state.isImportWindowOpen) {
|
if (event.key === "Escape" && state.isImportWindowOpen) {
|
||||||
closeImportWindow();
|
closeImportWindow();
|
||||||
|
} else if (event.key === "Escape" && state.isBatchGenerateWindowOpen) {
|
||||||
|
closeBatchGenerateWindow();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -59,6 +60,23 @@ class Workspace:
|
|||||||
|
|
||||||
return ProjectRecord(project_id=project_id, name=name, project_dir=project_dir)
|
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
|
@staticmethod
|
||||||
def _write_json(path: Path, payload: object) -> None:
|
def _write_json(path: Path, payload: object) -> None:
|
||||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from tests.fixtures.build_zotero_fixture import build_fixture_zotero_dir
|
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.api import GenerateCardsRequest
|
||||||
from zotero_kb.config import AppConfig
|
from zotero_kb.config import AppConfig
|
||||||
|
|
||||||
@ -57,6 +59,71 @@ def test_create_project_endpoint(tmp_path: Path) -> None:
|
|||||||
assert response["id"] == "thesis-ch2"
|
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:
|
def test_import_selected_items_endpoint(tmp_path: Path) -> None:
|
||||||
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
|
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
|
||||||
create_project = _route(app, "/api/projects", "POST")
|
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 len(response["items"]) == 1
|
||||||
assert response["items"][0]["item_key"] == "PAPER0001"
|
assert response["items"][0]["item_key"] == "PAPER0001"
|
||||||
assert response["items"][0]["card_status"] == "done"
|
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")
|
payload = service.remove_item("thesis-ch2", "PAPER0001")
|
||||||
|
|
||||||
assert payload["selected_items"] == []
|
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
|
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:
|
def test_index_inline_script_is_valid_javascript(tmp_path) -> None:
|
||||||
html = _get_index_html(tmp_path)
|
html = _get_index_html(tmp_path)
|
||||||
script_start = html.rfind("<script>")
|
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" / "project.json").is_file()
|
||||||
assert (config.workspace_dir / "projects" / "thesis-ch2" / "selected-items.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()
|
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