Compare commits

..

10 Commits

Author SHA1 Message Date
Saberlve
aaafa78883 feat: Add multilingual support for project cards and attachments
- Introduced `card_language` attribute in ProjectRecord and Workspace classes to handle multiple languages for project cards.
- Updated project creation and renaming methods to accept and store the card language.
- Enhanced ZoteroReader to read and return attachment metadata, including language-specific summaries and claims.
- Modified LLM client to generate card content based on the specified language, supporting both English and Chinese.
- Updated tests to cover new functionality, ensuring correct handling of multilingual card generation and retrieval.
- Adjusted UI tests to verify the presence of language selection options and proper rendering of multilingual content.
2026-04-22 10:21:03 +08:00
Saberlve
cf7e2feb19 feat: Implement project management features including rename and delete functionality
- Added project renaming capability in the workspace with appropriate API endpoints.
- Implemented project deletion functionality, ensuring project directories are removed.
- Updated UI to support project renaming and deletion, including inline editing and confirmation dialogs.
- Enhanced batch generation feature for project items with selection and progress tracking.
- Added tests for project renaming and deletion to ensure functionality and integrity.
2026-04-21 16:55:42 +08:00
Saberlve
4d45602c45 initiation 2026-04-16 13:34:39 +08:00
Saberlve
55edb5522a fix: restructure .window-body to single column so import-actions spans full width
The import-actions div was a sibling of import-split in a 2-column grid,
causing it to only occupy the right 60% column. Changed .window-body to
a 2-row single-column grid so import-actions sits below import-split
spanning the full width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 21:01:55 +08:00
Saberlve
c6e1d20c68 fix: XSS vulnerabilities and error handling in import window
- Escape creators, year, item_type fields in renderImportItemList and
  showItemPreview (was only escaping title and abstract)
- loadImportState uses api() wrapper for proper error handling
- generateSelectedCards uses api() wrapper with try/catch
- Remove duplicate isImportWindowOpen in state object

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 20:34:42 +08:00
Saberlve
aae8a6340a feat: add two-column import window layout with batch generate
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 20:28:16 +08:00
Saberlve
4350f9b3a1 feat: add POST /api/projects/{id}/cards/generate endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 20:08:14 +08:00
Saberlve
9ba1668a66 refactor: import_item_keys no longer generates cards immediately
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 19:36:00 +08:00
Saberlve
1f7cbc7d10 fix: add missing projects.py to worktree
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 19:03:36 +08:00
Saberlve
6c1713e190 test: add missing fixture files and __init__.py to worktree
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 18:58:46 +08:00
50 changed files with 9028 additions and 98 deletions

8
.claude/settings.json Normal file
View File

@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git check-ignore:*)"
]
}
}

0
.codex Normal file
View File

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
workspace/

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.10

View File

@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1776244824573}

View File

@ -0,0 +1,2 @@
{"type":"server-started","port":56756,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:56756","screen_dir":"/root/code/zotero-kb/.superpowers/brainstorm/31471-1776242964/content","state_dir":"/root/code/zotero-kb/.superpowers/brainstorm/31471-1776242964/state"}
{"type":"server-stopped","reason":"idle timeout"}

View File

@ -0,0 +1 @@
31479

130
CLAUDE.md Normal file
View File

@ -0,0 +1,130 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Zotero KB is a local FastAPI web service that reads from a Zotero SQLite database and generates structured Markdown knowledge cards for academic writing workflows. It provides project management, collection browsing, batch card generation via LLM (DeepSeek), and citation recommendation APIs.
## Development Commands
Package manager is `uv`. All commands run through `uv run`.
```bash
# Install dependencies
UV_CACHE_DIR=/tmp/uv-cache uv sync --extra dev
# Run the server
UV_CACHE_DIR=/tmp/uv-cache uv run python main.py
# Serves on http://127.0.0.1:8000
# Run all tests
UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q
# Run a single test file
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py -q
# Run a single test
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py::test_create_project_endpoint -q
```
## Environment Variables
| Variable | Default | Purpose |
|----------|---------|---------|
| `ZOTERO_DATA_DIR` | `/mnt/c/Users/WSX/Zotero` | Path to Zotero data directory (must contain `zotero.sqlite`) |
| `ZOTERO_KB_WORKSPACE` | `workspace` | Workspace directory for projects, indexes, and cards |
| `ZOTERO_BRIDGE_FILE` | `workspace/bridge/selected-items.json` | Bridge file for Zotero-to-project item import |
| `DEEPSEEK_API_KEY` | — | Required when projects use `llm_provider=deepseek` |
## Architecture
### Layer Overview
`api.py` → FastAPI routes → `Workspace` / `ProjectService` / `WritingService` / `ZoteroReader` / `CardBuilder`
All business logic lives in `src/zotero_kb/`. There is no database migration system; the app creates directories on demand.
### ZoteroReader (`zotero_reader.py`)
Reads directly from `zotero.sqlite` via `sqlite3`. Key capabilities:
- `read_items(item_keys)` — full record with metadata, creators, tags, notes, attachment texts
- `get_collection_tree()` / `get_collection_items(collection_key)` — browse collections hierarchically
- `search_items(query)` — title/abstract substring search
- PDF text extraction delegates to `pdftotext` CLI; other attachments read as plain text
- Attachment paths resolve from `storage:<key>` relative to `ZOTERO_DATA_DIR/storage/`
### Workspace & Project Model (`workspace.py`, `projects.py`)
A **project** is a JSON file plus two companion files under `workspace/projects/<project_id>/`:
- `project.json` — metadata including `llm.provider`, `llm.model`, `card_language`
- `selected-items.json` — ordered list of Zotero item keys belonging to the project
- `project-index.json` — cached denormalized view rebuilt on every read
`Workspace` creates/renames/deletes projects. `ProjectService` manages item selection and rebuilds `project-index.json` by reading three shared indexes:
- `workspace/library/index/items.json` — item metadata
- `workspace/library/index/cards.json` — generated card data (supports per-language variants)
- `workspace/library/index/collections.json` — collection metadata
### Card Generation (`cards.py`, `llm.py`)
`CardBuilder.build_or_update(item, language)`:
1. Builds a **source bundle** from the item record (metadata + notes + attachment texts)
2. Hashes and caches the bundle to `library/cache/source-bundles/`
3. Calls `llm_client.generate_card(source_bundle)`
4. Renders a Markdown file to `library/collections/<collection_path>/<title> [<key>][<lang>].md`
5. Updates `items.json`, `cards.json`, and `collections.json`
The LLM layer supports two providers:
- `deepseek` — calls DeepSeek Chat API, requires `DEEPSEEK_API_KEY`
- `deterministic` (fallback) — derives card fields from abstract/notes without any API call
Cards support **language variants**: `cards.json` stores a map `item_key -> {language -> card_data}`. The `project-index.json` selects the variant matching the project's `card_language`.
### Writing Support (`writing.py`)
- `recommend_citations(project_id, prompt)` — simple term-overlap scoring across project cards
- `generate_plan(project_id, prompt)` — returns a single-section plan using the top-scored card
### Bridge Import (`bridge.py`)
The `ZOTERO_BRIDGE_FILE` (JSON with `selected_keys` array) is read by the `/api/projects/{id}/imports/selected-items` endpoint to import items that were pre-selected in Zotero. This is separate from the UI-driven `/imports/item-keys` endpoint.
### Frontend
A single `index.html` (Jinja2 template rendered as static HTML) provides the full UI. It uses vanilla JS to call the REST API. No build step or JS bundler.
## Testing
Tests use `fastapi.testclient.TestClient` with a `FakeLlmClient` injected via `create_app(config, llm_client=...)`. The fixture builder (`tests/fixtures/build_zotero_fixture.py`) creates an in-memory Zotero SQLite schema with sample data. Most tests extract route handlers directly from the FastAPI app rather than using HTTP-level client calls.
## Workspace Directory Layout
```
workspace/
bridge/
selected-items.json # bridge file from Zotero
library/
index/
items.json # item metadata index
cards.json # card data index (language variants)
collections.json # collection metadata index
collections/
<collection_path>/
<title> [<key>][<lang>].md # generated Markdown cards
cache/
source-bundles/
<key>.<lang>.json # cached source bundles
projects/
<project_id>/
project.json # project config
selected-items.json # item keys in this project
project-index.json # denormalized project view
```
## Related Subdirectories
- `zotcard/` — Zotero plugin (separate JS project, not part of the Python service)
- `zotero-rag/` — Standalone RAG search service (separate project)
- `zotero-bridge/` — Zotero plugin bridge component
- `skills/zotero-citation-planner/` — Claude skill for reading project indexes and generating citation plans

76
README.md Normal file
View File

@ -0,0 +1,76 @@
# Zotero KB
本地 Zotero 文献卡片工作台。
## 当前能力
- 从本地 Zotero 数据目录读取条目、标签、笔记、collection 和附件文本
- 通过项目管理指定文献子集
- 生成 Markdown 知识卡片和 JSON 索引
- 提供项目内引用推荐和初版引用方案 API
- 提供最小 Web 控制台
- 提供 Claude Code / Codex 可直接读取的 skill 文件
- 支持按 Zotero collection 树浏览并逐篇选择文献导入到当前项目
## 本地运行
1. 安装依赖
```bash
UV_CACHE_DIR=/tmp/uv-cache uv sync --extra dev
```
2. 设置环境变量
```bash
export DEEPSEEK_API_KEY=你的_deepseek_api_key
export ZOTERO_DATA_DIR=/mnt/c/Users/WSX/Zotero
export ZOTERO_KB_WORKSPACE=workspace
export ZOTERO_BRIDGE_FILE=workspace/bridge/selected-items.json
```
如果你已经在 `~/.bashrc` 里配过这些变量,这一步可以跳过。
3. 启动服务
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run python main.py
```
4. 打开浏览器访问
```text
http://127.0.0.1:8000
```
5. 在左侧点击 `导入文献`
- 先选择一个项目
- 点击 `导入文献` 打开全屏导入界面
- 左侧点击一个 Zotero collection
- 右侧会显示该 collection 直接包含的文献
- 逐篇勾选需要导入的文献
- 点击顶部 `导入所选`
- 文献列表默认只显示标题和年份
- 鼠标悬停标题 3 秒后会显示摘要预览
- 在窄屏或不方便 hover 的场景下,可点击 `i` 按钮查看摘要
默认环境变量:
- `ZOTERO_DATA_DIR=/mnt/c/Users/WSX/Zotero`
- `ZOTERO_KB_WORKSPACE=workspace`
- `ZOTERO_BRIDGE_FILE=workspace/bridge/selected-items.json`
- `DEEPSEEK_API_KEY=...`
如果项目创建时使用:
- `llm_provider=deepseek`
- `llm_model=deepseek-chat`
导入文献生成卡片时会自动读取 `DEEPSEEK_API_KEY` 调用 DeepSeek。
## 测试
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q
```

View File

@ -0,0 +1,705 @@
# Import Floating Window Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the full-screen import modal with a draggable, resizable, minimizable floating window while preserving all existing import behaviors.
**Architecture:** All UI code lives in a single `index.html` file (template served by FastAPI). The change modifies CSS classes (modal → window frame), adds window control HTML elements, adds drag/resize/minimize/maximize JavaScript, and persists window state to `sessionStorage`.
**Tech Stack:** Vanilla JS, CSS custom properties, FastAPI serving as single-page app.
**Files:**
- Modify: `src/zotero_kb/templates/index.html`
- Modify: `tests/test_ui.py`
---
## Task 1: Replace Modal CSS with Window Frame CSS
**Files:**
- Modify: `src/zotero_kb/templates/index.html:147-206`
- [ ] **Step 1: Replace `.modal-shell` CSS with `.window-shell`**
Old (lines 147154):
```css
.modal-shell[hidden] {
display: none;
}
.modal-shell {
position: fixed;
inset: 0;
z-index: 40;
}
```
New:
```css
.window-shell[hidden] {
display: none;
}
.window-shell {
position: fixed;
inset: 0;
z-index: 40;
pointer-events: none;
}
.window-shell:not([hidden]) {
pointer-events: auto;
}
```
- [ ] **Step 2: Replace `.modal-overlay` CSS with `.window-frame` base**
Old (lines 155173):
```css
.modal-overlay {
position: absolute;
inset: 0;
border-radius: 0;
background: rgba(24, 28, 22, 0.42);
}
.modal-card {
position: relative;
z-index: 1;
width: 100vw;
height: 100vh;
display: grid;
grid-template-rows: auto auto 1fr;
background: var(--surface);
border: 0;
border-radius: 0;
box-shadow: none;
overflow: hidden;
}
```
New:
```css
.window-frame {
position: fixed;
z-index: 40;
width: var(--fw-width, 90vw);
height: var(--fw-height, 75vh);
left: var(--fw-left, calc((100vw - var(--fw-width, 90vw)) / 2));
top: var(--fw-top, calc((100vh - var(--fw-height, 75vh)) / 2));
min-width: 40rem;
min-height: 30rem;
border-radius: 16px;
box-shadow: 0 24px 64px rgba(35, 31, 21, 0.22);
display: grid;
grid-template-rows: auto 1fr auto;
overflow: hidden;
background: var(--surface);
border: 1px solid var(--line);
}
.window-frame.maximized {
width: 100vw;
height: 100vh;
left: 0 !important;
top: 0 !important;
border-radius: 0;
--fw-width: 100vw;
--fw-height: 100vh;
}
.window-frame:not(.maximized):not(.minimized) {
resize: both;
overflow: auto;
}
```
- [ ] **Step 3: Add `.window-minimized-bar` CSS**
After `.window-frame.maximized` block, add:
```css
.window-minimized-bar {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 50;
height: 2.5rem;
width: 14rem;
border-radius: 8px;
background: var(--surface);
border: 1px solid var(--line);
box-shadow: 0 8px 24px rgba(35, 31, 21, 0.14);
display: none;
align-items: center;
padding: 0 0.75rem;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
color: var(--ink);
}
.window-minimized-bar:not([hidden]) {
display: flex;
}
.window-minimized-bar:hover {
background: var(--panel);
}
```
- [ ] **Step 4: Update `.modal-toolbar` to `.window-toolbar`**
Find `.modal-toolbar` (line ~174) and `.modal-toolbar-actions` — rename to `.window-toolbar` and `.window-toolbar-actions`. Find `.modal-body` and `.modal-pane` — rename to `.window-body` and `.window-pane`. Find `.modal-scroll` — rename to `.window-scroll`.
- [ ] **Step 5: Add drag cursor CSS**
In the existing `button:hover` block or nearby, add:
```css
.window-toolbar {
cursor: grab;
}
.window-toolbar:active {
cursor: grabbing;
}
```
- [ ] **Step 6: Commit**
```bash
git add src/zotero_kb/templates/index.html
git commit -m "feat(import-window): replace modal CSS with window frame CSS"
```
---
## Task 2: Replace Modal HTML Structure with Window Frame
**Files:**
- Modify: `src/zotero_kb/templates/index.html:342370`
- [ ] **Step 1: Replace the modal HTML with window shell + window frame + minimized bar**
Old (lines 342370):
```html
<div id="import-modal" class="modal-shell" hidden aria-hidden="true">
<button id="import-modal-overlay" class="modal-overlay" type="button" aria-label="关闭导入弹窗"></button>
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="import-modal-title">
<div class="modal-toolbar">
<button id="close-import-modal-button" type="button" class="secondary">返回主页面</button>
<div>
<h3 id="import-modal-title">Import From Zotero</h3>
<p id="import-modal-project-label" class="meta">未选择项目</p>
</div>
<div class="modal-toolbar-actions">
<div id="modal-selected-count" class="meta">已选 0 篇</div>
<button id="modal-clear-selection-button" type="button" class="danger">清空选择</button>
<button id="modal-import-selected-items-button" type="button">导入所选</button>
</div>
</div>
<div class="modal-body">
<div class="modal-pane collections">
<small>Collections</small>
<div id="collection-status" class="status"></div>
<div id="modal-collection-tree" class="modal-scroll tree-list"></div>
</div>
<div class="modal-pane items">
<small>Collection Items</small>
<div id="modal-collection-items" class="modal-scroll collection-items-list"></div>
</div>
</div>
<div id="item-preview-popover" class="preview-popover" hidden></div>
</section>
</div>
```
New:
```html
<div id="import-window-shell" class="window-shell" hidden aria-hidden="true">
<section id="import-window" class="window-frame" role="dialog" aria-modal="true" aria-labelledby="import-window-title">
<div class="window-toolbar" id="import-window-toolbar">
<div style="display:flex;align-items:center;gap:0.6rem">
<span style="font-size:1.2rem;line-height:1"></span>
<div>
<h3 id="import-window-title">Import From Zotero</h3>
<p id="import-window-project-label" class="meta">未选择项目</p>
</div>
</div>
<div class="window-toolbar-actions">
<div id="window-selected-count" class="meta">已选 0 篇</div>
<button id="window-clear-selection-button" type="button" class="danger">清空选择</button>
<button id="window-import-selected-items-button" type="button">导入所选</button>
<button id="window-minimize-button" type="button" class="secondary" title="最小化" style="width:auto;padding:0.55rem 0.75rem">_</button>
<button id="window-maximize-button" type="button" class="secondary" title="最大化" style="width:auto;padding:0.55rem 0.75rem"></button>
<button id="window-close-button" type="button" class="secondary" title="关闭" style="width:auto;padding:0.55rem 0.75rem">×</button>
</div>
</div>
<div class="window-body">
<div class="window-pane collections">
<small>Collections</small>
<div id="window-collection-status" class="status"></div>
<div id="window-collection-tree" class="window-scroll tree-list"></div>
</div>
<div class="window-pane items">
<small>Collection Items</small>
<div id="window-collection-items" class="window-scroll collection-items-list"></div>
</div>
</div>
<div id="item-preview-popover" class="preview-popover" hidden></div>
</section>
</div>
<div id="import-window-minimized-bar" class="window-minimized-bar" hidden>
<span style="flex:1;text-align:left">导入文献</span>
<button id="window-restore-button" type="button" class="secondary" style="width:auto;padding:0.3rem 0.5rem;font-size:0.8rem"></button>
</div>
```
- [ ] **Step 2: Commit**
```bash
git add src/zotero_kb/templates/index.html
git commit -m "feat(import-window): replace modal HTML with window frame structure"
```
---
## Task 3: Rename JS State Keys and Element References
**Files:**
- Modify: `src/zotero_kb/templates/index.html:419498`
- [ ] **Step 1: Rename `IMPORT_SESSION_STORAGE_KEY`**
Old:
```js
const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal";
```
New:
```js
const WINDOW_SESSION_STORAGE_KEY = "zotero-kb.import-window";
const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal";
```
- [ ] **Step 2: Add `readWindowSessionState` function**
After `readImportSessionState()` function (~line 434), add:
```js
function readWindowSessionState() {
try {
const raw = window.sessionStorage.getItem(WINDOW_SESSION_STORAGE_KEY);
if (!raw) return {};
const payload = JSON.parse(raw);
return typeof payload === "object" && payload ? payload : {};
} catch (_error) {
return {};
}
}
const windowSessionState = readWindowSessionState();
```
- [ ] **Step 3: Add window state to the `state` object**
In the `state` object (~line 438), add after `isImportModalOpen`:
```js
isImportWindowOpen: false,
windowX: typeof windowSessionState.x === "number" ? windowSessionState.x : null,
windowY: typeof windowSessionState.y === "number" ? windowSessionState.y : null,
windowWidth: typeof windowSessionState.width === "number" ? windowSessionState.width : null,
windowHeight: typeof windowSessionState.height === "number" ? windowSessionState.height : null,
isWindowMinimized: Boolean(windowSessionState.isMinimized),
isWindowMaximized: Boolean(windowSessionState.isMaximized),
isWindowDragging: false,
windowDragStartX: 0,
windowDragStartY: 0,
windowDragStartLeft: 0,
windowDragStartTop: 0,
```
- [ ] **Step 4: Rename `elements.importModal` and `importModalOverlay` references**
Update the `elements` object (lines ~467498):
```js
importWindowShell: document.getElementById("import-window-shell"),
importWindow: document.getElementById("import-window"),
windowToolbar: document.getElementById("import-window-toolbar"),
windowMinimizeButton: document.getElementById("window-minimize-button"),
windowMaximizeButton: document.getElementById("window-maximize-button"),
windowCloseButton: document.getElementById("window-close-button"),
windowRestoreButton: document.getElementById("window-restore-button"),
importWindowMinimizedBar: document.getElementById("import-window-minimized-bar"),
windowProjectLabel: document.getElementById("import-window-project-label"),
windowSelectedCount: document.getElementById("window-selected-count"),
windowClearSelectionButton: document.getElementById("window-clear-selection-button"),
windowImportSelectedItemsButton: document.getElementById("window-import-selected-items-button"),
windowCollectionStatus: document.getElementById("window-collection-status"),
windowCollectionTree: document.getElementById("window-collection-tree"),
windowCollectionItems: document.getElementById("window-collection-items"),
```
Remove: `importModalOverlay`, `openImportModalButton`, `closeImportModalButton`, `importModalProjectLabel`, `collectionStatus`, `collectionTree`, `collectionItems`, `selectedCount`, `clearSelectionButton`, `importSelectedItemsButton`.
- [ ] **Step 5: Add `persistWindowSessionState` function**
After `persistImportSessionState()` function (~line 509):
```js
function persistWindowSessionState() {
const fw = elements.importWindow;
if (!fw) return;
const style = window.getComputedStyle(fw);
const width = parseFloat(style.width);
const height = parseFloat(style.height);
const left = parseFloat(style.left);
const top = parseFloat(style.top);
window.sessionStorage.setItem(
WINDOW_SESSION_STORAGE_KEY,
JSON.stringify({
x: left,
y: top,
width: width,
height: height,
isMinimized: state.isWindowMinimized,
isMaximized: state.isWindowMaximized,
})
);
}
```
- [ ] **Step 6: Commit**
```bash
git add src/zotero_kb/templates/index.html
git commit -m "feat(import-window): add window state management to JS"
```
---
## Task 4: Implement Drag, Minimize, Maximize, and Close Logic
**Files:**
- Modify: `src/zotero_kb/templates/index.html`
- [ ] **Step 1: Add window apply position/size helper**
After `persistWindowSessionState()`, add:
```js
function applyWindowPositionAndSize() {
const fw = elements.importWindow;
if (!fw) return;
fw.style.left = state.windowX !== null ? `${state.windowX}px` : "";
fw.style.top = state.windowY !== null ? `${state.windowY}px` : "";
fw.style.width = state.windowWidth !== null ? `${state.windowWidth}px` : "";
fw.style.height = state.windowHeight !== null ? `${state.windowHeight}px` : "";
}
function initWindowFromSession() {
const fw = elements.importWindow;
const bar = elements.importWindowMinimizedBar;
if (state.isWindowMinimized) {
fw.classList.add("minimized");
fw.classList.remove("maximized");
bar.hidden = false;
} else if (state.isWindowMaximized) {
fw.classList.add("maximized");
fw.classList.remove("minimized");
fw.style.width = "100vw";
fw.style.height = "100vh";
fw.style.left = "0";
fw.style.top = "0";
bar.hidden = true;
} else {
if (state.windowX !== null) {
applyWindowPositionAndSize();
}
fw.classList.remove("minimized", "maximized");
bar.hidden = true;
}
}
```
- [ ] **Step 2: Add drag handlers**
After `initWindowFromSession()`:
```js
function startWindowDrag(event) {
const fw = elements.importWindow;
if (!fw || fw.classList.contains("maximized") || fw.classList.contains("minimized")) return;
event.preventDefault();
state.isWindowDragging = true;
state.windowDragStartX = event.clientX;
state.windowDragStartY = event.clientY;
state.windowDragStartLeft = parseFloat(fw.style.left) || 0;
state.windowDragStartTop = parseFloat(fw.style.top) || 0;
fw.style.transition = "none";
}
function moveWindowDrag(event) {
if (!state.isWindowDragging) return;
event.preventDefault();
const fw = elements.importWindow;
const dx = event.clientX - state.windowDragStartX;
const dy = event.clientY - state.windowDragStartY;
let newLeft = state.windowDragStartLeft + dx;
let newTop = state.windowDragStartTop + dy;
// boundary clamp
const maxLeft = window.innerWidth - (parseFloat(fw.style.width) || fw.offsetWidth);
const maxTop = window.innerHeight - (parseFloat(fw.style.height) || fw.offsetHeight);
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
newTop = Math.max(0, Math.min(newTop, maxTop));
fw.style.left = `${newLeft}px`;
fw.style.top = `${newTop}px`;
state.windowX = newLeft;
state.windowY = newTop;
}
function endWindowDrag() {
if (!state.isWindowDragging) return;
state.isWindowDragging = false;
persistWindowSessionState();
}
function minimizeWindow() {
state.isWindowMinimized = true;
state.isWindowMaximized = false;
elements.importWindow.classList.add("minimized");
elements.importWindow.classList.remove("maximized");
elements.importWindowMinimizedBar.hidden = false;
persistWindowSessionState();
}
function maximizeWindow() {
state.isWindowMaximized = !state.isWindowMaximized;
if (state.isWindowMaximized) {
state.isWindowMinimized = false;
elements.importWindow.classList.add("maximized");
elements.importWindow.classList.remove("minimized");
elements.importWindowMinimizedBar.hidden = true;
elements.importWindow.style.width = "100vw";
elements.importWindow.style.height = "100vh";
elements.importWindow.style.left = "0";
elements.importWindow.style.top = "0";
} else {
elements.importWindow.classList.remove("maximized");
applyWindowPositionAndSize();
}
persistWindowSessionState();
}
function restoreWindow() {
state.isWindowMinimized = false;
elements.importWindow.classList.remove("minimized");
elements.importWindowMinimizedBar.hidden = true;
applyWindowPositionAndSize();
persistWindowSessionState();
}
```
- [ ] **Step 3: Wire up drag event listeners**
Find where `elements.projectForm.addEventListener` is set up (~line 1079). Before that block, add:
```js
elements.windowToolbar.addEventListener("pointerdown", startWindowDrag);
document.addEventListener("pointermove", moveWindowDrag);
document.addEventListener("pointerup", endWindowDrag);
elements.windowMinimizeButton.addEventListener("click", minimizeWindow);
elements.windowMaximizeButton.addEventListener("click", maximizeWindow);
elements.windowCloseButton.addEventListener("click", closeImportWindow);
elements.windowRestoreButton.addEventListener("click", restoreWindow);
elements.importWindowMinimizedBar.addEventListener("click", (e) => {
if (e.target === elements.windowRestoreButton) return;
restoreWindow();
});
```
- [ ] **Step 4: Rename `openImportModal``openImportWindow` and `closeImportModal``closeImportWindow`**
In the JS, rename:
- `function openImportModal()``function openImportWindow()`
- `function closeImportModal()``function closeImportWindow()`
- `openImportModalButton` → (removed, not needed as separate element)
- `closeImportModalButton` → (removed)
Find all call sites and update them. The `Esc` key handler should call `closeImportWindow`.
- [ ] **Step 5: Update the open function to use window state**
The `openImportWindow` function should call `initWindowFromSession()` instead of the modal open logic:
```js
state.isImportWindowOpen = true;
elements.importWindowShell.hidden = false;
elements.importWindowShell.setAttribute("aria-hidden", "false");
document.body.classList.toggle("modal-open", true);
cancelAbstractPreview();
updateImportActionState();
initWindowFromSession();
```
- [ ] **Step 6: Update the close function**
`closeImportWindow` should:
```js
state.isImportWindowOpen = false;
elements.importWindowShell.hidden = true;
elements.importWindowShell.setAttribute("aria-hidden", "true");
document.body.classList.toggle("modal-open", false);
cancelAbstractPreview();
// Note: does NOT clear window position/size/minimized state
```
- [ ] **Step 7: Update the button that opens the window**
The `导入文献` button (`open-import-modal-button`) handler should call `openImportWindow()`:
```js
elements.openImportModalButton.addEventListener("click", () => {
openImportWindow();
});
```
The element `openImportModalButton` should be kept in `elements` for this reference.
- [ ] **Step 8: Update Esc key handler**
Old:
```js
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.isImportModalOpen) {
closeImportModal();
}
});
```
New:
```js
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.isImportWindowOpen) {
closeImportWindow();
}
});
```
- [ ] **Step 9: Update `syncCurrentProjectLabels` and `updateImportActionState`**
Update all references:
- `elements.importModalProjectLabel``elements.windowProjectLabel`
- `elements.collectionStatus``elements.windowCollectionStatus`
- `elements.collectionTree``elements.windowCollectionTree`
- `elements.collectionItems``elements.windowCollectionItems`
- `elements.selectedCount``elements.windowSelectedCount`
- `elements.clearSelectionButton``elements.windowClearSelectionButton`
- `elements.importSelectedItemsButton``elements.windowImportSelectedItemsButton`
- [ ] **Step 10: Update `renderCollectionTree`, `renderCollectionItems`, `renderSelectedCount`, `setStatus` call sites**
Update all `elements.collectionTree`, `elements.collectionItems`, `elements.collectionStatus`, `elements.selectedCount` references to use the renamed `window*` variants.
- [ ] **Step 11: Update `closeImportWindow` call inside `importSelectedItems`**
When import succeeds, call `closeImportWindow()` instead of `closeImportModal()`.
- [ ] **Step 12: Update `openImportWindow` call inside `loadCollectionTree` and `loadCollectionItems`**
There are call sites inside `openImportModal()` (now `openImportWindow()`) that call `loadCollectionTree()` and `loadCollectionItems()`. These remain — just the function name changed.
- [ ] **Step 13: Commit**
```bash
git add src/zotero_kb/templates/index.html
git commit -m "feat(import-window): implement drag, minimize, maximize, close logic"
```
---
## Task 5: Update Tests
**Files:**
- Modify: `tests/test_ui.py`
- [ ] **Step 1: Update `test_index_contains_import_modal_controls`**
Replace the old test with:
```python
def test_index_contains_import_window_controls(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert 'id="open-import-modal-button"' in html
assert 'id="import-window-shell"' in html
assert 'id="import-window"' in html
assert 'id="window-minimize-button"' in html
assert 'id="window-maximize-button"' in html
assert 'id="window-close-button"' in html
assert 'id="window-restore-button"' in html
assert 'id="import-window-minimized-bar"' in html
assert 'id="window-project-label"' in html
assert 'id="window-collection-tree"' in html
assert 'id="window-collection-items"' in html
assert 'id="window-selected-count"' in html
assert 'id="window-clear-selection-button"' in html
assert 'id="window-import-selected-items-button"' in html
assert 'id="item-preview-popover"' in html
assert 'const ABSTRACT_PREVIEW_DELAY_MS = 3000;' in html
assert "isImportWindowOpen: false" in html
assert "function openImportWindow()" in html
assert "function closeImportWindow()" in html
assert "function updateImportActionState()" in html
assert "function scheduleAbstractPreview(item, target)" in html
assert "function cancelAbstractPreview()" in html
assert "function showAbstractPreview(item, target)" in html
assert "details-button" in html
assert "?include_descendants=false" in html
assert "window.classList.add(\"minimized\")" in html
assert "window.classList.add(\"maximized\")" in html
assert "WINDOW_SESSION_STORAGE_KEY" in html
```
- [ ] **Step 2: Add new test for window CSS classes**
After the above test, add:
```python
def test_index_window_frame_css(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert ".window-frame" in html
assert ".window-shell" in html
assert ".window-toolbar" in html
assert ".window-body" in html
assert ".window-minimized-bar" in html
assert '"zotero-kb.import-window"' in html
assert "startWindowDrag" in html
assert "minimizeWindow" in html
assert "maximizeWindow" in html
assert "restoreWindow" in html
assert "initWindowFromSession" in html
assert "persistWindowSessionState" in html
```
- [ ] **Step 3: Run tests to verify**
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -v
```
Expected: All tests pass.
- [ ] **Step 4: Commit**
```bash
git add tests/test_ui.py
git commit -m "test(import-window): update UI tests for floating window"
```
---
## Self-Review Checklist
1. **Spec coverage:** Each requirement in `2026-04-15-import-floating-window-design.md` is implemented:
- Window attributes (90vw×75vh, centered, rounded, shadow) → Task 1
- Toolbar drag → Task 4 Steps 13
- Minimize/maximize/close buttons → Task 4 Steps 45
- Minimized bar → Task 2 HTML + Task 4 Step 5
- Session state persistence → Task 3 Steps 2, 5, Task 4 Step 3
- Esc closes window → Task 4 Step 8
- All existing behaviors unchanged → Task 4 Steps 912
2. **Placeholder scan:** No "TBD", "TODO", or vague language. All code is concrete.
3. **Type consistency:** All renamed JS state keys and element references use consistent `window*` prefix throughout. The `state` object uses `isWindowMinimized`, `isWindowMaximized`, `isWindowDragging` etc. consistently across all tasks.
4. **Spec completeness check:** `window-minimized-bar` has `hidden` attribute in HTML and `display:none``display:flex` in CSS. Maximized removes rounded corners. Drag is bounded to viewport. Import button disabled when no project selected is unchanged.

View File

@ -0,0 +1,601 @@
# Zotero Collection Import Fullscreen Modal Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Convert the current centered import modal into a full-screen modal with left-side collection browsing, right-side direct-item selection, top-bar actions, and delayed abstract preview.
**Architecture:** Keep the existing FastAPI backend and reuse the current collection tree and item import endpoints. Refactor the single-page `index.html` template so the import flow becomes a full-screen modal with a fixed top bar, a left collection tree, and a right item list that only loads direct collection items (`include_descendants=false`). Preserve session-scoped selection state across collection switches and modal reopen, but remove collection-level bulk-select behavior.
**Tech Stack:** FastAPI, inline HTML/CSS/vanilla JavaScript, pytest
---
## File Structure
- Modify: `src/zotero_kb/templates/index.html`
- Convert the current centered modal into a full-screen modal shell
- Remove collection-level bulk-select behavior
- Change collection item loading to direct-only
- Update item rows to `title + year`
- Add delayed preview behavior and mobile details-button fallback
- Modify: `tests/test_ui.py`
- Update UI contract assertions for the new full-screen modal structure and preview hooks
- Modify: `README.md`
- Update the user walkthrough to describe the full-screen modal and one-by-one item selection
### Task 1: Update The UI Contract For The Fullscreen Modal
**Files:**
- Modify: `tests/test_ui.py`
- Read: `src/zotero_kb/templates/index.html`
- [ ] **Step 1: Write the failing assertions**
Extend `test_index_contains_import_modal_controls` so it asserts the new full-screen modal hooks and removes the old collection-bulk-select assumption.
Use this function body:
```python
def test_index_contains_import_modal_controls(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert 'id="open-import-modal-button"' in html
assert 'id="import-modal"' in html
assert 'id="close-import-modal-button"' in html
assert 'id="import-modal-project-label"' in html
assert 'id="modal-selected-count"' in html
assert 'id="modal-clear-selection-button"' in html
assert 'id="modal-import-selected-items-button"' in html
assert 'id="modal-collection-tree"' in html
assert 'id="modal-collection-items"' in html
assert 'id="item-preview-popover"' in html
assert 'const ABSTRACT_PREVIEW_DELAY_MS = 3000;' in html
assert 'function scheduleAbstractPreview(item, target)' in html
assert 'function cancelAbstractPreview()' in html
assert 'function showAbstractPreview(item, target)' in html
assert 'details-button' in html
assert 'isImportModalOpen: false' in html
assert 'document.body.classList.toggle("modal-open"' in html
assert 'id="modal-select-descendants-button"' not in html
```
- [ ] **Step 2: Run the targeted UI test and confirm it fails**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v
```
Expected: FAIL because the current template still contains the old top-bar/selection contract and may still include `modal-select-descendants-button`.
- [ ] **Step 3: Update the test file**
Edit `tests/test_ui.py` so the full file becomes:
```python
from zotero_kb.api import create_app
from zotero_kb.config import AppConfig
def _get_index_html(tmp_path) -> str:
app = create_app(
AppConfig(
workspace_dir=tmp_path / "workspace",
zotero_data_dir=tmp_path / "zotero",
bridge_file=tmp_path / "bridge.json",
)
)
for route in app.routes:
if getattr(route, "path", None) == "/" and "GET" in getattr(route, "methods", set()):
return route.endpoint()
raise AssertionError("GET / route not found")
def test_index_contains_base_page_forms(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert 'id="create-project-form"' in html
assert 'id="recommend-form"' in html
assert 'id="plan-form"' in html
def test_index_contains_import_modal_controls(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert 'id="open-import-modal-button"' in html
assert 'id="import-modal"' in html
assert 'id="close-import-modal-button"' in html
assert 'id="import-modal-project-label"' in html
assert 'id="modal-selected-count"' in html
assert 'id="modal-clear-selection-button"' in html
assert 'id="modal-import-selected-items-button"' in html
assert 'id="modal-collection-tree"' in html
assert 'id="modal-collection-items"' in html
assert 'id="item-preview-popover"' in html
assert 'const ABSTRACT_PREVIEW_DELAY_MS = 3000;' in html
assert 'function scheduleAbstractPreview(item, target)' in html
assert 'function cancelAbstractPreview()' in html
assert 'function showAbstractPreview(item, target)' in html
assert 'details-button' in html
assert 'isImportModalOpen: false' in html
assert 'document.body.classList.toggle("modal-open"' in html
assert 'id="modal-select-descendants-button"' not in html
```
- [ ] **Step 4: Run the targeted UI test again**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v
```
Expected: still FAIL, now because the current template does not yet match the new full-screen modal contract.
- [ ] **Step 5: Commit**
```bash
git add tests/test_ui.py
git commit -m "test: define fullscreen import modal contract"
```
### Task 2: Convert The Centered Modal To A Fullscreen Modal Shell
**Files:**
- Modify: `src/zotero_kb/templates/index.html`
- Test: `tests/test_ui.py`
- [ ] **Step 1: Replace the current modal container styles**
In `src/zotero_kb/templates/index.html`, replace the current centered-card modal CSS:
```css
.modal-card {
position: relative;
z-index: 1;
width: min(1100px, calc(100vw - 2rem));
max-height: calc(100vh - 2rem);
margin: 1rem auto;
display: grid;
grid-template-rows: auto auto 1fr auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: 0 24px 60px rgba(35, 31, 21, 0.2);
overflow: hidden;
}
```
with a full-screen shell:
```css
.modal-card {
position: relative;
z-index: 1;
width: 100vw;
height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
background: var(--surface);
border: 0;
border-radius: 0;
box-shadow: none;
overflow: hidden;
}
.modal-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--line);
background: rgba(255, 253, 248, 0.98);
}
.modal-toolbar-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.modal-body {
display: grid;
grid-template-columns: 22rem minmax(0, 1fr);
min-height: 0;
}
.modal-pane {
min-height: 0;
padding: 1rem 1.25rem;
}
.modal-pane.collections {
border-right: 1px solid var(--line);
}
```
- [ ] **Step 2: Replace the modal markup**
Replace the current modal header/footer structure:
```html
<header class="modal-header">...</header>
<div id="collection-status" class="status"></div>
<div class="modal-body">...</div>
<footer class="modal-action-bar">...</footer>
```
with a full-screen toolbar plus two-column body:
```html
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="import-modal-title">
<div class="modal-toolbar">
<button id="close-import-modal-button" type="button" class="secondary">返回主页面</button>
<div>
<h3 id="import-modal-title">Import From Zotero</h3>
<p id="import-modal-project-label" class="meta">未选择项目</p>
</div>
<div class="modal-toolbar-actions">
<div id="modal-selected-count" class="meta">已选 0 篇</div>
<button id="modal-clear-selection-button" type="button" class="danger">清空选择</button>
<button id="modal-import-selected-items-button" type="button">导入所选</button>
</div>
</div>
<div id="collection-status" class="status"></div>
<div class="modal-body">
<div class="modal-pane collections">
<small>Collections</small>
<div id="modal-collection-tree" class="modal-scroll tree-list"></div>
</div>
<div class="modal-pane items">
<small>Collection Items</small>
<div id="modal-collection-items" class="modal-scroll collection-items-list"></div>
</div>
</div>
<div id="item-preview-popover" class="preview-popover" hidden></div>
</section>
```
- [ ] **Step 3: Remove the old collection-level bulk select button from the markup**
Delete this old button entirely:
```html
<button id="modal-select-descendants-button" type="button" class="secondary">全选当前目录及子目录</button>
```
- [ ] **Step 4: Run the targeted UI test**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/zotero_kb/templates/index.html tests/test_ui.py
git commit -m "feat: convert import modal to fullscreen layout"
```
### Task 3: Change Modal Selection Semantics To Direct-Item, One-By-One Selection
**Files:**
- Modify: `src/zotero_kb/templates/index.html`
- Test: `tests/test_api.py`
- [ ] **Step 1: Update collection item loading to direct-only**
In `loadCollectionItems(collectionKey)`, replace:
```javascript
const payload = await api(`/api/zotero/collections/${collectionKey}/items`);
```
with:
```javascript
const payload = await api(`/api/zotero/collections/${collectionKey}/items?include_descendants=false`);
```
- [ ] **Step 2: Remove collection-level bulk-select logic**
Delete the old helper and event binding:
```javascript
function selectVisibleItems() {
for (const item of state.visibleCollectionItems) {
state.selectedItemKeys.add(item.item_key);
}
persistImportSessionState();
renderCollectionItems();
updateImportActionState();
}
elements.selectDescendantsButton.addEventListener("click", selectVisibleItems);
```
Do not replace it with another collection-level bulk action.
- [ ] **Step 3: Keep only clear/import actions in the top bar**
Update `updateImportActionState()` so it no longer references `elements.selectDescendantsButton`.
Use:
```javascript
function updateImportActionState() {
const hasProject = Boolean(state.currentProjectId);
const hasSelection = state.selectedItemKeys.size > 0;
syncCurrentProjectLabels();
elements.importSelectedItemsButton.disabled = !hasProject || !hasSelection;
elements.importSelectedItemsButton.title = !hasProject ? "请先选择项目" : (hasSelection ? "" : "请先选择文献");
elements.clearSelectionButton.disabled = !state.selectedItemKeys.size;
renderSelectedCount();
}
```
- [ ] **Step 4: Make the item row default render title + year only**
Replace the current item row template:
```javascript
row.innerHTML = `
<input type="checkbox" ${state.selectedItemKeys.has(item.item_key) ? "checked" : ""} />
<div>
<strong>${item.title}</strong>
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
</div>
`;
```
with:
```javascript
row.innerHTML = `
<input type="checkbox" ${state.selectedItemKeys.has(item.item_key) ? "checked" : ""} />
<div class="item-row-body">
<button type="button" class="title-button">${item.title}</button>
<div class="meta">${item.year || "-"}</div>
<button type="button" class="secondary details-button" aria-label="查看摘要">i</button>
</div>
`;
```
- [ ] **Step 5: Run focused tests**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py tests/test_api.py -q
```
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add src/zotero_kb/templates/index.html
git commit -m "feat: switch import modal to direct-item selection"
```
### Task 4: Add Delayed Abstract Preview And Touch Fallback
**Files:**
- Modify: `src/zotero_kb/templates/index.html`
- Test: `tests/test_ui.py`
- [ ] **Step 1: Add preview helpers**
Add these helpers near the modal item rendering code:
```javascript
function clearAbstractPreviewTimer() {
if (state.hoverTimerId !== null) {
window.clearTimeout(state.hoverTimerId);
state.hoverTimerId = null;
}
}
function cancelAbstractPreview() {
clearAbstractPreviewTimer();
state.hoveredItemKey = null;
state.previewItemKey = null;
elements.previewPopover.hidden = true;
elements.previewPopover.textContent = "";
}
function showAbstractPreview(item, target) {
clearAbstractPreviewTimer();
const abstract = typeof item.abstract === "string" ? item.abstract.trim() : "";
if (!abstract) {
cancelAbstractPreview();
return;
}
state.previewItemKey = item.item_key;
elements.previewPopover.textContent = abstract;
elements.previewPopover.hidden = false;
}
function scheduleAbstractPreview(item, target) {
const abstract = typeof item.abstract === "string" ? item.abstract.trim() : "";
if (!abstract) {
return;
}
cancelAbstractPreview();
state.hoveredItemKey = item.item_key;
state.hoverTimerId = window.setTimeout(() => {
if (state.hoveredItemKey === item.item_key) {
showAbstractPreview(item, target);
}
}, ABSTRACT_PREVIEW_DELAY_MS);
}
```
- [ ] **Step 2: Wire preview behavior into item rows**
Bind these events in `renderCollectionItems()`:
```javascript
checkbox.addEventListener("change", () => {
cancelAbstractPreview();
toggleItemSelection(item.item_key);
});
titleButton.addEventListener("mouseenter", () => scheduleAbstractPreview(item, titleButton));
titleButton.addEventListener("mouseleave", cancelAbstractPreview);
detailsButton.addEventListener("click", (event) => {
event.preventDefault();
if (state.previewItemKey === item.item_key && !elements.previewPopover.hidden) {
cancelAbstractPreview();
return;
}
showAbstractPreview(item, detailsButton);
});
```
Also add:
```javascript
elements.collectionItems.addEventListener("scroll", cancelAbstractPreview);
```
and call `cancelAbstractPreview()` inside:
- `loadCollectionItems()`
- `toggleItemSelection()`
- `clearSelectedItems()`
- `importSelectedItems()`
- `openImportModal()`
- `closeImportModal()`
- [ ] **Step 3: Add compact-row and preview styles**
Add:
```css
.collection-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.75rem;
align-items: center;
padding: 0.75rem 0.9rem;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.72);
}
.item-row-body {
display: flex;
align-items: center;
gap: 0.55rem;
min-width: 0;
}
.title-button {
flex: 1;
min-width: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: var(--ink);
text-align: left;
font-weight: 600;
}
.details-button {
width: 2.2rem;
min-width: 2.2rem;
padding: 0.45rem 0;
}
.preview-popover {
position: fixed;
z-index: 60;
}
```
- [ ] **Step 4: Run the UI test and full suite**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v
UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q
```
Expected:
- first command: PASS
- second command: all tests PASS
- [ ] **Step 5: Commit**
```bash
git add src/zotero_kb/templates/index.html tests/test_ui.py
git commit -m "feat: add delayed preview to fullscreen import modal"
```
### Task 5: Update The User Walkthrough
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Replace the old inline-import instructions**
Replace the current import walkthrough bullets with:
```md
5. 在左侧点击 `导入文献`
- 先选择一个项目
- 点击 `导入文献` 打开全屏导入界面
- 左侧点击一个 Zotero collection
- 右侧会显示该 collection 直接包含的文献
- 逐篇勾选需要导入的文献
- 点击顶部 `导入所选`
- 鼠标悬停标题 3 秒会显示摘要预览
- 在窄屏或不方便 hover 的场景下,可点击 `i` 按钮查看摘要
```
- [ ] **Step 2: Run the full test suite**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q
```
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add README.md
git commit -m "docs: describe fullscreen import modal flow"
```
## Self-Review Checklist
- Spec coverage:
- full-screen modal shell: Task 2
- direct-item-only collection loading: Task 3
- one-by-one selection semantics: Task 3
- delayed abstract preview and details-button fallback: Task 4
- updated user walkthrough: Task 5
- Placeholder scan:
- no `TODO`, `TBD`, or vague “handle later” instructions remain
- Type consistency:
- ids and helpers remain consistent across tasks:
- `open-import-modal-button`
- `import-modal`
- `import-modal-project-label`
- `modal-selected-count`
- `modal-clear-selection-button`
- `modal-import-selected-items-button`
- `modal-collection-tree`
- `modal-collection-items`
- `scheduleAbstractPreview`
- `cancelAbstractPreview`
- `showAbstractPreview`

View File

@ -0,0 +1,711 @@
# Zotero Collection Import Modal Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the crowded inline Zotero importer with a modal-based importer that preserves session selection state, keeps rows title-only by default, and shows abstracts through delayed preview.
**Architecture:** Keep the existing FastAPI backend and current collection/item endpoints unchanged. Refactor the single-page `index.html` template so the main page only renders an import trigger, while a hidden modal owns the collection tree, item list, sticky actions, and preview popover state. Reuse the existing in-page state object and fetch helpers, extending them for modal lifecycle and delayed preview behavior.
**Tech Stack:** FastAPI, inline HTML/CSS/vanilla JavaScript, pytest
---
## File Structure
- Modify: `src/zotero_kb/templates/index.html`
- Replace the inline importer markup with a single trigger button
- Add modal shell, overlay, sticky action bar, and abstract preview popover
- Update the in-page JavaScript state and event handlers for modal lifecycle and delayed preview
- Modify: `tests/test_ui.py`
- Replace assertions for the old inline importer with assertions for the trigger button, modal shell, and preview hooks
- Modify: `README.md`
- Update the UI walkthrough so it describes opening the import modal instead of using the inline importer
### Task 1: Lock The Modal UI Contract In Tests
**Files:**
- Modify: `tests/test_ui.py`
- Read: `src/zotero_kb/templates/index.html`
- [ ] **Step 1: Write the failing test**
Replace the existing inline-import assertions with this test body:
```python
from zotero_kb.api import create_app
from zotero_kb.config import AppConfig
def test_index_contains_import_modal_controls(tmp_path) -> None:
app = create_app(
AppConfig(
workspace_dir=tmp_path / "workspace",
zotero_data_dir=tmp_path / "zotero",
bridge_file=tmp_path / "bridge.json",
)
)
for route in app.routes:
if getattr(route, "path", None) == "/" and "GET" in getattr(route, "methods", set()):
html = route.endpoint()
break
else:
raise AssertionError("GET / route not found")
assert 'id="create-project-form"' in html
assert 'id="open-import-modal-button"' in html
assert 'id="import-modal"' in html
assert 'id="close-import-modal-button"' in html
assert 'id="modal-collection-tree"' in html
assert 'id="modal-collection-items"' in html
assert 'id="modal-selected-count"' in html
assert 'id="modal-select-descendants-button"' in html
assert 'id="modal-clear-selection-button"' in html
assert 'id="modal-import-selected-items-button"' in html
assert 'id="item-preview-popover"' in html
assert 'const ABSTRACT_PREVIEW_DELAY_MS = 3000;' in html
assert 'isImportModalOpen: false' in html
assert 'id="recommend-form"' in html
assert 'id="plan-form"' in html
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v
```
Expected: FAIL because the template still contains `collection-tree`, `collection-items`, and the old inline action ids instead of the modal ids.
- [ ] **Step 3: Update the test file**
Make `tests/test_ui.py` exactly:
```python
from zotero_kb.api import create_app
from zotero_kb.config import AppConfig
def test_index_contains_import_modal_controls(tmp_path) -> None:
app = create_app(
AppConfig(
workspace_dir=tmp_path / "workspace",
zotero_data_dir=tmp_path / "zotero",
bridge_file=tmp_path / "bridge.json",
)
)
for route in app.routes:
if getattr(route, "path", None) == "/" and "GET" in getattr(route, "methods", set()):
html = route.endpoint()
break
else:
raise AssertionError("GET / route not found")
assert 'id="create-project-form"' in html
assert 'id="open-import-modal-button"' in html
assert 'id="import-modal"' in html
assert 'id="close-import-modal-button"' in html
assert 'id="modal-collection-tree"' in html
assert 'id="modal-collection-items"' in html
assert 'id="modal-selected-count"' in html
assert 'id="modal-select-descendants-button"' in html
assert 'id="modal-clear-selection-button"' in html
assert 'id="modal-import-selected-items-button"' in html
assert 'id="item-preview-popover"' in html
assert 'const ABSTRACT_PREVIEW_DELAY_MS = 3000;' in html
assert 'isImportModalOpen: false' in html
assert 'id="recommend-form"' in html
assert 'id="plan-form"' in html
```
- [ ] **Step 4: Run test to verify it still fails for the right reason**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v
```
Expected: FAIL on missing modal ids in `index.html`.
- [ ] **Step 5: Commit**
```bash
git add tests/test_ui.py
git commit -m "test: define modal import ui contract"
```
### Task 2: Replace The Inline Importer Markup With A Modal Shell
**Files:**
- Modify: `src/zotero_kb/templates/index.html`
- Test: `tests/test_ui.py`
- [ ] **Step 1: Write the failing implementation target**
In `src/zotero_kb/templates/index.html`, replace the old inline import section:
```html
<div class="section surface">
<h3>从 Zotero 导入</h3>
<p class="meta">按当前 Zotero collection 结构浏览,选择目录后批量导入该目录及子目录的文献。</p>
<div id="collection-status" class="status"></div>
<div class="import-region">
<div>
<small>Collections</small>
<div id="collection-tree" class="import-scroll tree-list"></div>
</div>
<div>
<small>Collection Items</small>
<div id="collection-items" class="import-scroll collection-items-list"></div>
</div>
<div class="action-bar">
<div id="selected-count" class="meta">已选 0 篇</div>
<button id="select-descendants-button" type="button" class="secondary">全选当前目录及子目录</button>
<button id="clear-selection-button" type="button" class="danger">清空选择</button>
<button id="import-selected-items-button" type="button">导入所选到当前项目</button>
</div>
</div>
</div>
```
with a trigger plus modal shell:
```html
<div class="section surface">
<h3>导入文献</h3>
<p class="meta">打开子界面浏览 Zotero collection 树,再批量导入到当前项目。</p>
<button id="open-import-modal-button" type="button">导入文献</button>
</div>
<div id="import-modal" class="modal-shell" hidden aria-hidden="true">
<button id="import-modal-overlay" class="modal-overlay" type="button" aria-label="关闭导入弹窗"></button>
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="import-modal-title">
<header class="modal-header">
<div>
<h3 id="import-modal-title">Import From Zotero</h3>
<p id="import-modal-project-label" class="meta">未选择项目</p>
</div>
<div class="modal-header-actions">
<div id="modal-selected-count" class="meta">已选 0 篇</div>
<button id="close-import-modal-button" type="button" class="secondary">关闭</button>
</div>
</header>
<div id="collection-status" class="status"></div>
<div class="modal-body">
<div class="modal-pane">
<small>Collections</small>
<div id="modal-collection-tree" class="modal-scroll tree-list"></div>
</div>
<div class="modal-pane">
<small>Collection Items</small>
<div id="modal-collection-items" class="modal-scroll collection-items-list"></div>
</div>
</div>
<footer class="modal-action-bar">
<button id="modal-select-descendants-button" type="button" class="secondary">全选当前目录及子目录</button>
<button id="modal-clear-selection-button" type="button" class="danger">清空选择</button>
<button id="modal-import-selected-items-button" type="button">导入所选到当前项目</button>
</footer>
<div id="item-preview-popover" class="preview-popover" hidden></div>
</section>
</div>
```
- [ ] **Step 2: Run the UI test to verify the markup is still missing**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v
```
Expected: FAIL until the new markup and ids are present.
- [ ] **Step 3: Add the modal CSS and markup**
In the `<style>` block, add these rules and remove `import-region`-specific layout rules that only served the inline importer:
```css
.modal-shell[hidden] {
display: none;
}
.modal-shell {
position: fixed;
inset: 0;
z-index: 40;
}
.modal-overlay {
position: absolute;
inset: 0;
border-radius: 0;
background: rgba(24, 28, 22, 0.42);
}
.modal-card {
position: relative;
z-index: 1;
width: min(1100px, calc(100vw - 2rem));
max-height: calc(100vh - 2rem);
margin: 1rem auto;
display: grid;
grid-template-rows: auto auto 1fr auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: 0 24px 60px rgba(35, 31, 21, 0.2);
overflow: hidden;
}
.modal-header,
.modal-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--line);
}
.modal-action-bar {
border-top: 1px solid var(--line);
border-bottom: 0;
}
.modal-body {
display: grid;
grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.2fr);
min-height: 24rem;
}
.modal-pane {
display: grid;
gap: 0.6rem;
padding: 1rem 1.25rem;
min-height: 0;
}
.modal-scroll {
min-height: 0;
overflow: auto;
}
.preview-popover {
position: absolute;
max-width: 28rem;
padding: 0.85rem 1rem;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255, 253, 248, 0.98);
box-shadow: 0 18px 40px rgba(35, 31, 21, 0.16);
color: var(--ink);
}
@media (max-width: 980px) {
.modal-card {
width: calc(100vw - 1rem);
margin: 0.5rem auto;
max-height: calc(100vh - 1rem);
}
.modal-body {
grid-template-columns: 1fr;
grid-template-rows: minmax(12rem, 1fr) minmax(12rem, 1fr);
}
.modal-header,
.modal-action-bar {
flex-wrap: wrap;
}
}
```
- [ ] **Step 4: Run the targeted test to verify it passes**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/zotero_kb/templates/index.html tests/test_ui.py
git commit -m "feat: add zotero import modal shell"
```
### Task 3: Move Import State And Actions Into The Modal
**Files:**
- Modify: `src/zotero_kb/templates/index.html`
- Test: `tests/test_ui.py`
- Read: `tests/test_api.py`
- [ ] **Step 1: Add a failing assertion for modal state hooks**
Append these assertions to `test_index_contains_import_modal_controls`:
```python
assert "function openImportModal()" in html
assert "function closeImportModal()" in html
assert "function updateImportActionState()" in html
assert 'document.body.classList.toggle("modal-open"' in html
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v
```
Expected: FAIL because the current script does not expose modal lifecycle helpers.
- [ ] **Step 3: Update the state object, element bindings, and modal actions**
In `src/zotero_kb/templates/index.html`, update the script setup to include modal state and new DOM bindings:
```javascript
const ABSTRACT_PREVIEW_DELAY_MS = 3000;
const state = {
projects: [],
currentProjectId: null,
currentCards: [],
collectionTree: [],
isImportModalOpen: false,
selectedCollectionKey: null,
expandedCollectionKeys: new Set(),
selectedItemKeys: new Set(),
visibleCollectionItems: [],
hoveredItemKey: null,
hoverTimerId: null,
previewItemKey: null,
};
const elements = {
openImportModalButton: document.getElementById("open-import-modal-button"),
importModal: document.getElementById("import-modal"),
importModalOverlay: document.getElementById("import-modal-overlay"),
closeImportModalButton: document.getElementById("close-import-modal-button"),
importModalProjectLabel: document.getElementById("import-modal-project-label"),
collectionStatus: document.getElementById("collection-status"),
collectionTree: document.getElementById("modal-collection-tree"),
collectionItems: document.getElementById("modal-collection-items"),
selectedCount: document.getElementById("modal-selected-count"),
selectDescendantsButton: document.getElementById("modal-select-descendants-button"),
clearSelectionButton: document.getElementById("modal-clear-selection-button"),
importSelectedItemsButton: document.getElementById("modal-import-selected-items-button"),
previewPopover: document.getElementById("item-preview-popover"),
// keep existing project, cards, and writing bindings unchanged
};
function openImportModal() {
state.isImportModalOpen = true;
elements.importModal.hidden = false;
elements.importModal.setAttribute("aria-hidden", "false");
document.body.classList.toggle("modal-open", true);
updateImportActionState();
}
function closeImportModal() {
state.isImportModalOpen = false;
elements.importModal.hidden = true;
elements.importModal.setAttribute("aria-hidden", "true");
document.body.classList.toggle("modal-open", false);
cancelAbstractPreview();
}
function updateImportActionState() {
const hasProject = Boolean(state.currentProjectId);
const hasSelection = state.selectedItemKeys.size > 0;
elements.importModalProjectLabel.textContent = hasProject ? `当前项目:${state.currentProjectId}` : "未选择项目";
elements.importSelectedItemsButton.disabled = !hasProject || !hasSelection;
elements.importSelectedItemsButton.title = hasProject ? "" : "请先选择项目";
}
elements.openImportModalButton.addEventListener("click", openImportModal);
elements.closeImportModalButton.addEventListener("click", closeImportModal);
elements.importModalOverlay.addEventListener("click", closeImportModal);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.isImportModalOpen) {
closeImportModal();
}
});
```
Then update the existing import callbacks:
```javascript
async function importSelectedItems() {
if (!state.currentProjectId || !state.selectedItemKeys.size) {
updateImportActionState();
return;
}
setStatus(elements.collectionStatus, "正在导入...");
const payload = await api(`/api/projects/${state.currentProjectId}/imports/item-keys`, {
method: "POST",
body: JSON.stringify({ item_keys: Array.from(state.selectedItemKeys) }),
});
state.selectedItemKeys.clear();
renderSelectedCount();
renderCollectionItems();
renderCards(payload.project_view.cards || []);
setStatus(elements.collectionStatus, "导入完成。");
updateImportActionState();
closeImportModal();
}
```
Finally, make `selectProject()` and `toggleItemSelection()` call `updateImportActionState()` after they mutate project or selection state.
- [ ] **Step 4: Run focused UI and API regression tests**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py tests/test_api.py -q
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/zotero_kb/templates/index.html tests/test_ui.py
git commit -m "feat: wire modal import state"
```
### Task 4: Add Delayed Abstract Preview And Title-Only Rows
**Files:**
- Modify: `src/zotero_kb/templates/index.html`
- Modify: `tests/test_ui.py`
- [ ] **Step 1: Extend the UI test with preview-hook assertions**
Append these assertions to `test_index_contains_import_modal_controls`:
```python
assert "function scheduleAbstractPreview(item, target)" in html
assert "function cancelAbstractPreview()" in html
assert "function showAbstractPreview(item, target)" in html
assert "details-button" in html
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v
```
Expected: FAIL because preview helpers and the touch fallback trigger are not present yet.
- [ ] **Step 3: Update row rendering and preview helpers**
In `renderCollectionItems()`, replace the current row template:
```javascript
row.innerHTML = `
<div class="checkbox-row">
<input type="checkbox" ${state.selectedItemKeys.has(item.item_key) ? "checked" : ""} />
<div>
<strong>${item.title}</strong>
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
<p>${item.abstract || "暂无摘要"}</p>
</div>
</div>
`;
```
with a compact title-only row:
```javascript
row.innerHTML = `
<div class="checkbox-row">
<input type="checkbox" ${state.selectedItemKeys.has(item.item_key) ? "checked" : ""} />
<div class="item-row-body">
<button type="button" class="title-button">${item.title}</button>
<button type="button" class="secondary details-button" aria-label="查看摘要">i</button>
</div>
</div>
`;
const checkbox = row.querySelector('input[type="checkbox"]');
const titleButton = row.querySelector(".title-button");
const detailsButton = row.querySelector(".details-button");
checkbox.addEventListener("change", (event) => {
cancelAbstractPreview();
toggleItemSelection(item.item_key, event.target.checked);
});
titleButton.addEventListener("mouseenter", () => scheduleAbstractPreview(item, titleButton));
titleButton.addEventListener("mouseleave", cancelAbstractPreview);
detailsButton.addEventListener("click", () => showAbstractPreview(item, detailsButton));
```
Add the preview helpers near the other rendering utilities:
```javascript
function scheduleAbstractPreview(item, target) {
cancelAbstractPreview();
if (!item.abstract) {
return;
}
state.hoveredItemKey = item.item_key;
state.hoverTimerId = window.setTimeout(() => {
showAbstractPreview(item, target);
}, ABSTRACT_PREVIEW_DELAY_MS);
}
function cancelAbstractPreview() {
if (state.hoverTimerId) {
window.clearTimeout(state.hoverTimerId);
state.hoverTimerId = null;
}
state.hoveredItemKey = null;
state.previewItemKey = null;
elements.previewPopover.hidden = true;
elements.previewPopover.textContent = "";
}
function showAbstractPreview(item, target) {
cancelAbstractPreview();
if (!item.abstract) {
return;
}
state.previewItemKey = item.item_key;
const rect = target.getBoundingClientRect();
elements.previewPopover.textContent = item.abstract;
elements.previewPopover.style.top = `${Math.round(rect.bottom + window.scrollY + 8)}px`;
elements.previewPopover.style.left = `${Math.round(Math.min(rect.left + window.scrollX, window.innerWidth - 320))}px`;
elements.previewPopover.hidden = false;
}
```
Also add these cleanup hooks:
```javascript
elements.collectionItems.addEventListener("scroll", cancelAbstractPreview);
async function selectCollection(collectionKey) {
cancelAbstractPreview();
state.selectedCollectionKey = collectionKey;
// keep the rest of the existing fetch logic unchanged
}
```
Add small style rules for the compact row:
```css
.item-row-body {
display: flex;
align-items: center;
gap: 0.5rem;
}
.title-button {
flex: 1;
padding: 0;
background: transparent;
color: var(--ink);
text-align: left;
border-radius: 0;
}
.details-button {
width: 2rem;
min-width: 2rem;
padding: 0.45rem 0;
}
```
- [ ] **Step 4: Run the UI test and the full suite**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q
UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q
```
Expected:
- first command: PASS
- second command: all tests PASS
- [ ] **Step 5: Commit**
```bash
git add src/zotero_kb/templates/index.html tests/test_ui.py
git commit -m "feat: add delayed abstract preview in import modal"
```
### Task 5: Update The User-Facing Walkthrough
**Files:**
- Modify: `README.md`
- Test: `tests/test_ui.py`
- [ ] **Step 1: Add the README delta**
In the UI usage section, replace any wording that implies the importer is inline with this paragraph:
```md
创建项目后,左侧点击 `导入文献` 会打开一个弹窗。弹窗左侧显示 Zotero collection 树,右侧显示当前 collection 及子 collection 下的文献标题列表。勾选后点击 `导入所选到当前项目` 即可导入;标题悬停 3 秒会显示摘要预览。
```
- [ ] **Step 2: Run a quick regression check**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py tests/test_api.py -q
```
Expected: PASS
- [ ] **Step 3: Manually verify the modal flow**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run python main.py
```
Then verify in the browser:
1. `http://127.0.0.1:8000` opens the app.
2. Clicking `导入文献` opens the modal.
3. With no project selected, the modal opens but the import button is disabled.
4. After selecting a project, the import button enables once at least one item is checked.
5. Closing and reopening the modal preserves selected items.
6. Hovering a title for less than 3 seconds shows nothing.
7. Hovering a title for at least 3 seconds shows the abstract preview.
8. Successful import closes the modal and refreshes cards.
- [ ] **Step 4: Commit**
```bash
git add README.md
git commit -m "docs: describe modal zotero import flow"
```
## Self-Review Checklist
- Spec coverage:
- modal trigger and shell: Task 2
- session-scoped selection memory and modal lifecycle: Task 3
- title-only rows and delayed abstract preview: Task 4
- user-facing workflow update: Task 5
- Placeholder scan:
- no `TODO`, `TBD`, or “similar to Task N” references remain
- Type consistency:
- modal ids and state names are consistent across tests and implementation:
- `open-import-modal-button`
- `import-modal`
- `modal-collection-tree`
- `modal-collection-items`
- `modal-selected-count`
- `modal-import-selected-items-button`
- `isImportModalOpen`
- `scheduleAbstractPreview`
- `cancelAbstractPreview`

View File

@ -0,0 +1,378 @@
# Zotero Collection Import Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the current left-panel import widgets with a Zotero collection tree importer that supports descendant-aware browsing, remembered checkbox state, and batch import into the active project.
**Architecture:** Extend the SQLite-backed `ZoteroReader` with collection-tree and collection-item queries, expose those through dedicated API endpoints, and replace the current left-panel import UI with a tree-plus-item-list importer that keeps selection state in the browser session. Reuse the existing `POST /api/projects/{project_id}/imports/item-keys` endpoint as the import execution path.
**Tech Stack:** Python 3.10, FastAPI, sqlite3, vanilla HTML/CSS/JavaScript, pytest, uv
---
## File Structure
- Modify: `src/zotero_kb/zotero_reader.py`
Purpose: build collection tree metadata and collection-scoped item listings with descendant inclusion.
- Modify: `src/zotero_kb/api.py`
Purpose: expose collection tree and collection item endpoints.
- Modify: `src/zotero_kb/templates/index.html`
Purpose: replace the left-panel import UI with a collection tree importer and batch selection workflow.
- Modify: `tests/test_zotero_reader.py`
Purpose: cover collection tree generation and descendant-aware item listing.
- Modify: `tests/test_api.py`
Purpose: cover collection tree and collection items endpoints plus batch import path.
- Modify: `tests/test_ui.py`
Purpose: ensure the index template includes the new collection importer controls.
- Modify: `README.md`
Purpose: document the new left-panel import workflow.
### Task 1: Add Collection Tree Reader Support
**Files:**
- Modify: `src/zotero_kb/zotero_reader.py`
- Modify: `tests/test_zotero_reader.py`
- [ ] **Step 1: Write the failing reader tests**
```python
def test_build_collection_tree_returns_descendant_counts(tmp_path: Path) -> None:
fixture_dir = tmp_path / "zotero"
fixture_dir.mkdir()
build_fixture_zotero_dir(fixture_dir)
reader = ZoteroReader(fixture_dir)
tree = reader.get_collection_tree()
assert len(tree) == 1
root = tree[0]
assert root["name"] == "Theory"
assert root["direct_item_count"] == 0
assert root["descendant_item_count"] == 1
assert root["children"][0]["name"] == "Drafting"
def test_get_collection_items_includes_descendants(tmp_path: Path) -> None:
fixture_dir = tmp_path / "zotero"
fixture_dir.mkdir()
build_fixture_zotero_dir(fixture_dir)
reader = ZoteroReader(fixture_dir)
items = reader.get_collection_items("COLL0001", include_descendants=True)
assert [item["item_key"] for item in items] == ["PAPER0001"]
assert items[0]["collection_paths"] == [["Theory", "Drafting"]]
```
- [ ] **Step 2: Run test to verify it fails**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_zotero_reader.py::test_build_collection_tree_returns_descendant_counts tests/test_zotero_reader.py::test_get_collection_items_includes_descendants -q`
Expected: FAIL with `AttributeError` because `get_collection_tree` and `get_collection_items` do not exist.
- [ ] **Step 3: Write minimal implementation**
```python
def get_collection_tree(self) -> list[dict[str, object]]:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
try:
collections = self._read_collection_lookup(conn)
direct_counts = self._read_direct_item_counts(conn)
children_lookup = self._build_children_lookup(collections)
return [
self._build_collection_node(collection_id, collections, children_lookup, direct_counts)
for collection_id, row in collections.items()
if row["parentCollectionID"] is None
]
finally:
conn.close()
def get_collection_items(self, collection_key: str, include_descendants: bool = True) -> list[dict[str, object]]:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
try:
field_lookup = self._read_field_lookup(conn)
collections = self._read_collection_lookup(conn)
children_lookup = self._build_children_lookup(collections)
collection_ids = self._resolve_collection_ids(collection_key, collections, children_lookup, include_descendants)
item_ids = self._read_item_ids_for_collections(conn, collection_ids)
return self._read_item_summaries(conn, item_ids, field_lookup, collections)
finally:
conn.close()
```
```python
def _build_collection_node(
self,
collection_id: int,
collections: dict[int, sqlite3.Row],
children_lookup: dict[int, list[int]],
direct_counts: dict[int, int],
) -> dict[str, object]:
children = [
self._build_collection_node(child_id, collections, children_lookup, direct_counts)
for child_id in children_lookup.get(collection_id, [])
]
descendant_count = direct_counts.get(collection_id, 0) + sum(
int(child["descendant_item_count"]) for child in children
)
row = collections[collection_id]
return {
"collection_key": str(row["key"]),
"name": str(row["collectionName"]),
"parent_key": self._parent_key(row["parentCollectionID"], collections),
"children": children,
"direct_item_count": direct_counts.get(collection_id, 0),
"descendant_item_count": descendant_count,
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_zotero_reader.py::test_build_collection_tree_returns_descendant_counts tests/test_zotero_reader.py::test_get_collection_items_includes_descendants -q`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/zotero_kb/zotero_reader.py tests/test_zotero_reader.py
git commit -m "feat: add zotero collection tree reader support"
```
### Task 2: Expose Collection Import API
**Files:**
- Modify: `src/zotero_kb/api.py`
- Modify: `tests/test_api.py`
- [ ] **Step 1: Write the failing API tests**
```python
def test_collection_tree_endpoint(tmp_path: Path) -> None:
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
endpoint = _route(app, "/api/zotero/collections/tree", "GET")
payload = endpoint()
assert payload["collections"][0]["collection_key"] == "COLL0001"
assert payload["collections"][0]["children"][0]["collection_key"] == "COLL0002"
def test_collection_items_endpoint_includes_descendants(tmp_path: Path) -> None:
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
endpoint = _route(app, "/api/zotero/collections/{collection_key}/items", "GET")
payload = endpoint("COLL0001", True)
assert payload["items"][0]["item_key"] == "PAPER0001"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py::test_collection_tree_endpoint tests/test_api.py::test_collection_items_endpoint_includes_descendants -q`
Expected: FAIL because the routes are missing.
- [ ] **Step 3: Write minimal implementation**
```python
@app.get("/api/zotero/collections/tree")
def zotero_collection_tree() -> dict[str, object]:
return {"collections": reader.get_collection_tree()}
@app.get("/api/zotero/collections/{collection_key}/items")
def zotero_collection_items(collection_key: str, include_descendants: bool = True) -> dict[str, object]:
return {
"collection_key": collection_key,
"items": reader.get_collection_items(collection_key, include_descendants=include_descendants),
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py::test_collection_tree_endpoint tests/test_api.py::test_collection_items_endpoint_includes_descendants -q`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/zotero_kb/api.py tests/test_api.py
git commit -m "feat: expose zotero collection import api"
```
### Task 3: Replace Left-Panel Import UI
**Files:**
- Modify: `src/zotero_kb/templates/index.html`
- Modify: `tests/test_ui.py`
- [ ] **Step 1: Write the failing UI test**
```python
def test_index_contains_collection_import_controls(tmp_path) -> None:
app = create_app(
AppConfig(
workspace_dir=tmp_path / "workspace",
zotero_data_dir=tmp_path / "zotero",
bridge_file=tmp_path / "bridge.json",
)
)
endpoint = _route(app, "/", "GET")
html = endpoint()
assert 'id="collection-tree"' in html
assert 'id="collection-items"' in html
assert 'id="select-descendants-button"' in html
assert 'id="clear-selection-button"' in html
assert 'id="import-selected-items-button"' in html
```
- [ ] **Step 2: Run test to verify it fails**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_collection_import_controls -q`
Expected: FAIL because the template still contains `import-selected-button` and `zotero-search-form` instead of the new collection importer ids.
- [ ] **Step 3: Write minimal implementation**
```html
<div class="section surface">
<h3>从 Zotero 导入</h3>
<div id="collection-tree" class="tree"></div>
<div id="collection-items" class="result-list"></div>
<div class="action-bar">
<span id="selected-count">已选 0 篇</span>
<button id="select-descendants-button" type="button" class="secondary">全选当前目录及子目录</button>
<button id="clear-selection-button" type="button" class="danger">清空选择</button>
<button id="import-selected-items-button" type="button">导入所选到当前项目</button>
</div>
</div>
```
```javascript
state.collectionTree = [];
state.selectedCollectionKey = null;
state.selectedItemKeys = new Set();
state.expandedCollectionKeys = new Set();
state.visibleCollectionItems = [];
async function loadCollectionTree() {
const payload = await api("/api/zotero/collections/tree");
state.collectionTree = payload.collections || [];
renderCollectionTree();
}
async function selectCollection(collectionKey) {
state.selectedCollectionKey = collectionKey;
const payload = await api(`/api/zotero/collections/${collectionKey}/items?include_descendants=true`);
state.visibleCollectionItems = payload.items || [];
renderCollectionItems();
}
function toggleItemSelection(itemKey, checked) {
if (checked) state.selectedItemKeys.add(itemKey);
else state.selectedItemKeys.delete(itemKey);
renderSelectedCount();
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_collection_import_controls -q`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/zotero_kb/templates/index.html tests/test_ui.py
git commit -m "feat: add zotero collection tree importer ui"
```
### Task 4: Wire Batch Import And Refresh
**Files:**
- Modify: `src/zotero_kb/templates/index.html`
- Modify: `tests/test_api.py`
- Modify: `README.md`
- [ ] **Step 1: Write the failing behavior test**
```python
def test_import_item_keys_endpoint_returns_project_view(tmp_path: Path) -> None:
config = make_test_config(tmp_path)
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="deepseek",
llm_model="deepseek-chat",
)
)
payload = import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
assert payload["project_view"]["cards"][0]["item_key"] == "PAPER0001"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py::test_import_item_keys_endpoint_returns_project_view -q`
Expected: FAIL only if the endpoint response shape or refreshed project view is wrong after the UI refactor.
- [ ] **Step 3: Write minimal implementation**
```javascript
document.getElementById("select-descendants-button").addEventListener("click", () => {
for (const item of state.visibleCollectionItems) {
state.selectedItemKeys.add(item.item_key);
}
renderCollectionItems();
});
document.getElementById("clear-selection-button").addEventListener("click", () => {
state.selectedItemKeys.clear();
renderCollectionItems();
});
document.getElementById("import-selected-items-button").addEventListener("click", async () => {
const payload = await api(`/api/projects/${state.currentProjectId}/imports/item-keys`, {
method: "POST",
body: JSON.stringify({ item_keys: Array.from(state.selectedItemKeys) }),
});
state.selectedItemKeys.clear();
renderCards(payload.project_view.cards || []);
renderCollectionItems();
});
```
```markdown
## 从 Zotero 导入
1. 选择一个项目
2. 在左栏 `从 Zotero 导入` 中展开 collection 树
3. 点击一个目录,系统会加载该目录及其子目录的文献
4. 勾选单篇文献,或点击 `全选当前目录及子目录`
5. 点击 `导入所选到当前项目`
```
- [ ] **Step 4: Run test to verify it passes**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py::test_import_item_keys_endpoint_returns_project_view -q`
Expected: PASS
- [ ] **Step 5: Run full verification**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q`
Expected: PASS with all tests green
- [ ] **Step 6: Commit**
```bash
git add src/zotero_kb/templates/index.html tests/test_api.py README.md
git commit -m "feat: support batch import from zotero collections"
```

View File

@ -0,0 +1,519 @@
# Zotero KB V1 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:** Build a first working Zotero KB service that can create projects, import currently selected Zotero items through a minimal bridge, read local Zotero data and attachments, generate Markdown cards plus JSON indexes, and provide two project-scoped writing endpoints.
**Architecture:** Use a Python FastAPI service with a file-backed workspace, a SQLite-powered Zotero reader, a card builder that normalizes source bundles before LLM generation, and project-scoped views layered on top of a canonical global library. Add a minimal Zotero bridge that exports selected item keys, and ship local SKILL files that read only one target project's content.
**Tech Stack:** Python 3.10, FastAPI, Uvicorn, pytest, sqlite3, pathlib, subprocess (`pdftotext`), standard-library JSON/HTML handling
---
### Task 1: Bootstrap the service and workspace model
**Files:**
- Create: `pyproject.toml`
- Create: `src/zotero_kb/__init__.py`
- Create: `src/zotero_kb/config.py`
- Create: `src/zotero_kb/workspace.py`
- Create: `tests/test_workspace.py`
- [ ] **Step 1: Write the failing workspace tests**
```python
from pathlib import Path
from zotero_kb.config import AppConfig
from zotero_kb.workspace import Workspace
def test_workspace_initialization_creates_required_directories(tmp_path: Path) -> None:
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
workspace = Workspace(config)
workspace.ensure_layout()
assert (config.workspace_dir / "library" / "collections").is_dir()
assert (config.workspace_dir / "library" / "index").is_dir()
assert (config.workspace_dir / "library" / "cache" / "source-bundles").is_dir()
assert (config.workspace_dir / "projects").is_dir()
def test_create_project_writes_project_files(tmp_path: Path) -> None:
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
workspace = Workspace(config)
workspace.ensure_layout()
project = workspace.create_project(
project_id="thesis-ch2",
name="Thesis Chapter 2",
llm_provider="openai",
llm_model="gpt-5-mini",
)
assert project.project_id == "thesis-ch2"
assert (config.workspace_dir / "projects" / "thesis-ch2" / "project.json").is_file()
assert (config.workspace_dir / "projects" / "thesis-ch2" / "selected-items.json").is_file()
assert (config.workspace_dir / "projects" / "thesis-ch2" / "project-index.json").is_file()
```
- [ ] **Step 2: Run test to verify it fails**
Run: `python3 -m pytest tests/test_workspace.py -q`
Expected: FAIL with `ModuleNotFoundError` for `zotero_kb`
- [ ] **Step 3: Write minimal implementation**
```toml
[project]
name = "zotero-kb"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.115,<1",
"uvicorn>=0.30,<1",
"pydantic>=2.8,<3",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3,<9",
"httpx>=0.27,<0.28",
]
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.pytest.ini_options]
pythonpath = ["src"]
```
```python
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class AppConfig:
workspace_dir: Path
zotero_data_dir: Path
bridge_file: Path | None = None
```
```python
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from .config import AppConfig
@dataclass(frozen=True)
class ProjectRecord:
project_id: str
name: str
project_dir: Path
class Workspace:
def __init__(self, config: AppConfig) -> None:
self.config = config
def ensure_layout(self) -> None:
for path in (
self.config.workspace_dir / "library" / "collections",
self.config.workspace_dir / "library" / "index",
self.config.workspace_dir / "library" / "cache" / "source-bundles",
self.config.workspace_dir / "projects",
):
path.mkdir(parents=True, exist_ok=True)
def create_project(self, project_id: str, name: str, llm_provider: str, llm_model: str) -> ProjectRecord:
self.ensure_layout()
project_dir = self.config.workspace_dir / "projects" / project_id
project_dir.mkdir(parents=True, exist_ok=True)
payload = {
"id": project_id,
"name": name,
"zotero_data_dir": str(self.config.zotero_data_dir),
"selection_mode": "zotero-bridge",
"llm": {"provider": llm_provider, "model": llm_model, "base_url": None},
"created_at": datetime.now(timezone.utc).isoformat(),
}
(project_dir / "project.json").write_text(json.dumps(payload, indent=2), encoding="utf-8")
(project_dir / "selected-items.json").write_text("[]\n", encoding="utf-8")
(project_dir / "project-index.json").write_text("{\"items\": []}\n", encoding="utf-8")
return ProjectRecord(project_id=project_id, name=name, project_dir=project_dir)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `python3 -m pytest tests/test_workspace.py -q`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add pyproject.toml src/zotero_kb/__init__.py src/zotero_kb/config.py src/zotero_kb/workspace.py tests/test_workspace.py
git commit -m "feat: bootstrap zotero kb workspace"
```
### Task 2: Implement the Zotero reader and bridge contract
**Files:**
- Create: `src/zotero_kb/zotero_reader.py`
- Create: `src/zotero_kb/bridge.py`
- Create: `tests/fixtures/build_zotero_fixture.py`
- Create: `tests/test_zotero_reader.py`
- [ ] **Step 1: Write the failing reader tests**
```python
from pathlib import Path
from zotero_kb.zotero_reader import ZoteroReader
def test_read_selected_items_from_fixture(tmp_path: Path) -> None:
fixture_dir = tmp_path / "zotero"
fixture_dir.mkdir()
build_fixture_zotero_dir(fixture_dir)
reader = ZoteroReader(fixture_dir)
items = reader.read_items(["PAPER0001"])
assert len(items) == 1
item = items[0]
assert item.item_key == "PAPER0001"
assert item.title == "Card Pipelines for Research Writing"
assert item.tags == ["llm", "writing"]
assert item.collection_paths == [["Theory", "Drafting"]]
assert item.attachment_texts[0].startswith("This paper studies")
def test_read_selected_keys_from_bridge_snapshot(tmp_path: Path) -> None:
bridge_file = tmp_path / "selected-items.json"
bridge_file.write_text("{\"selected_keys\": [\"PAPER0001\", \"PAPER0002\"]}", encoding="utf-8")
assert read_selected_keys(bridge_file) == ["PAPER0001", "PAPER0002"]
```
- [ ] **Step 2: Run test to verify it fails**
Run: `python3 -m pytest tests/test_zotero_reader.py -q`
Expected: FAIL because `ZoteroReader` and `read_selected_keys` do not exist
- [ ] **Step 3: Write minimal implementation**
```python
import json
from pathlib import Path
def read_selected_keys(bridge_file: Path) -> list[str]:
payload = json.loads(bridge_file.read_text(encoding="utf-8"))
return [str(item) for item in payload.get("selected_keys", [])]
```
```python
@dataclass(frozen=True)
class ZoteroItemRecord:
item_key: str
title: str
creators: list[str]
year: str | None
tags: list[str]
collection_paths: list[list[str]]
notes: list[str]
attachment_texts: list[str]
```
```python
class ZoteroReader:
def __init__(self, zotero_data_dir: Path) -> None:
self.zotero_data_dir = zotero_data_dir
def read_items(self, item_keys: list[str]) -> list[ZoteroItemRecord]:
# query zotero.sqlite for items, creators, tags, notes, collection paths
# resolve attachments through itemAttachments.path and extract text
...
```
- [ ] **Step 4: Run test to verify it passes**
Run: `python3 -m pytest tests/test_zotero_reader.py -q`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/zotero_kb/zotero_reader.py src/zotero_kb/bridge.py tests/fixtures/build_zotero_fixture.py tests/test_zotero_reader.py
git commit -m "feat: add zotero reader and bridge snapshot support"
```
### Task 3: Build cards and canonical indexes
**Files:**
- Create: `src/zotero_kb/cards.py`
- Create: `src/zotero_kb/llm.py`
- Create: `tests/test_cards.py`
- [ ] **Step 1: Write the failing card-builder tests**
```python
from pathlib import Path
from zotero_kb.cards import CardBuilder
from zotero_kb.zotero_reader import ZoteroItemRecord
def test_build_card_writes_markdown_and_indexes(tmp_path: Path) -> None:
item = ZoteroItemRecord(
item_key="PAPER0001",
title="Card Pipelines for Research Writing",
creators=["Alice Smith", "Bob Li"],
year="2024",
tags=["llm", "writing"],
collection_paths=[["Theory", "Drafting"]],
notes=["Merged notes matter."],
attachment_texts=["This paper studies card pipelines for research writing."],
)
builder = CardBuilder(workspace_dir=tmp_path, llm_client=FakeLlmClient())
result = builder.build_or_update(item)
assert result.card_path == tmp_path / "library" / "collections" / "Theory" / "Drafting" / "Card Pipelines for Research Writing [PAPER0001].md"
assert result.card_path.read_text(encoding="utf-8").startswith("---")
cards_index = json.loads((tmp_path / "library" / "index" / "cards.json").read_text(encoding="utf-8"))
assert cards_index["PAPER0001"]["title"] == "Card Pipelines for Research Writing"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `python3 -m pytest tests/test_cards.py -q`
Expected: FAIL because `CardBuilder` does not exist
- [ ] **Step 3: Write minimal implementation**
```python
class LlmClient(Protocol):
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
...
```
```python
class CardBuilder:
def __init__(self, workspace_dir: Path, llm_client: LlmClient) -> None:
...
def build_or_update(self, item: ZoteroItemRecord) -> CardBuildResult:
# write source bundle
# compute source_hash
# ask llm_client for structured sections
# render markdown card
# update items.json, cards.json, collections.json
...
```
- [ ] **Step 4: Run test to verify it passes**
Run: `python3 -m pytest tests/test_cards.py -q`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/zotero_kb/cards.py src/zotero_kb/llm.py tests/test_cards.py
git commit -m "feat: build markdown cards and canonical indexes"
```
### Task 4: Add project views and writing services
**Files:**
- Create: `src/zotero_kb/projects.py`
- Create: `src/zotero_kb/writing.py`
- Create: `tests/test_projects.py`
- Create: `tests/test_writing.py`
- [ ] **Step 1: Write the failing project and writing tests**
```python
def test_add_item_to_project_updates_selected_items_and_project_index(tmp_path: Path) -> None:
...
assert payload["selected_items"] == ["PAPER0001"]
assert payload["cards"][0]["item_key"] == "PAPER0001"
def test_recommend_citations_only_reads_project_items(tmp_path: Path) -> None:
result = service.recommend_citations(project_id="thesis-ch2", prompt="support scoped retrieval")
assert [item["item_key"] for item in result["results"]] == ["PAPER0001"]
def test_generate_plan_returns_structured_sections(tmp_path: Path) -> None:
plan = service.generate_plan(project_id="thesis-ch2", prompt="argue that project scoping improves drafting")
assert "sections" in plan
assert plan["sections"][0]["citations"][0]["item_key"] == "PAPER0001"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `python3 -m pytest tests/test_projects.py tests/test_writing.py -q`
Expected: FAIL because project and writing services do not exist
- [ ] **Step 3: Write minimal implementation**
```python
class ProjectService:
def add_items(self, project_id: str, item_keys: list[str]) -> dict[str, object]:
...
def remove_item(self, project_id: str, item_key: str) -> dict[str, object]:
...
```
```python
class WritingService:
def recommend_citations(self, project_id: str, prompt: str) -> dict[str, object]:
...
def generate_plan(self, project_id: str, prompt: str, stance: str | None = None) -> dict[str, object]:
...
```
- [ ] **Step 4: Run test to verify it passes**
Run: `python3 -m pytest tests/test_projects.py tests/test_writing.py -q`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/zotero_kb/projects.py src/zotero_kb/writing.py tests/test_projects.py tests/test_writing.py
git commit -m "feat: add project views and writing services"
```
### Task 5: Expose API and Web console
**Files:**
- Create: `src/zotero_kb/api.py`
- Create: `src/zotero_kb/main.py`
- Create: `src/zotero_kb/templates/index.html`
- Create: `tests/test_api.py`
- [ ] **Step 1: Write the failing API tests**
```python
from fastapi.testclient import TestClient
from zotero_kb.api import create_app
def test_create_project_endpoint(tmp_path: Path) -> None:
client = TestClient(create_app(make_test_config(tmp_path)))
response = client.post("/api/projects", json={"project_id": "thesis-ch2", "name": "Thesis Chapter 2"})
assert response.status_code == 201
assert response.json()["id"] == "thesis-ch2"
def test_import_selected_items_endpoint(tmp_path: Path) -> None:
client = TestClient(create_app(make_test_config(tmp_path)))
response = client.post("/api/projects/thesis-ch2/imports/selected-items")
assert response.status_code == 200
assert response.json()["imported_item_keys"] == ["PAPER0001"]
```
- [ ] **Step 2: Run test to verify it fails**
Run: `python3 -m pytest tests/test_api.py -q`
Expected: FAIL because `create_app` does not exist
- [ ] **Step 3: Write minimal implementation**
```python
def create_app(config: AppConfig) -> FastAPI:
app = FastAPI()
@app.get("/")
def index() -> HTMLResponse:
...
@app.post("/api/projects", status_code=201)
def create_project(payload: CreateProjectRequest) -> dict[str, object]:
...
@app.post("/api/projects/{project_id}/imports/selected-items")
def import_selected_items(project_id: str) -> dict[str, object]:
...
return app
```
- [ ] **Step 4: Run test to verify it passes**
Run: `python3 -m pytest tests/test_api.py -q`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/zotero_kb/api.py src/zotero_kb/main.py src/zotero_kb/templates/index.html tests/test_api.py
git commit -m "feat: expose zotero kb api and web console"
```
### Task 6: Ship skill files and Zotero bridge scaffold
**Files:**
- Create: `skills/zotero-citation-recommender/SKILL.md`
- Create: `skills/zotero-citation-planner/SKILL.md`
- Create: `zotero-bridge/src/bootstrap.js`
- Create: `zotero-bridge/src/manifest.json`
- Create: `README.md`
- Create: `tests/test_skill_assets.py`
- [ ] **Step 1: Write the failing asset tests**
```python
def test_skill_files_exist() -> None:
assert Path("skills/zotero-citation-recommender/SKILL.md").is_file()
assert Path("skills/zotero-citation-planner/SKILL.md").is_file()
def test_bridge_manifest_exists() -> None:
assert Path("zotero-bridge/src/manifest.json").is_file()
```
- [ ] **Step 2: Run test to verify it fails**
Run: `python3 -m pytest tests/test_skill_assets.py -q`
Expected: FAIL because skill and bridge files do not exist
- [ ] **Step 3: Write minimal implementation**
```markdown
# zotero-citation-recommender
Read `projects/<project-id>/project-index.json`, then open only the referenced card files from `library/collections/`. Recommend citations from those files only.
```
```javascript
// Export selected item keys from Zotero into a bridge snapshot file.
async function exportSelectedItems() {
const selectedItems = Zotero.getMainWindow().ZoteroPane.getSelectedItems();
...
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `python3 -m pytest tests/test_skill_assets.py -q`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add skills/zotero-citation-recommender/SKILL.md skills/zotero-citation-planner/SKILL.md zotero-bridge/src/bootstrap.js zotero-bridge/src/manifest.json README.md tests/test_skill_assets.py
git commit -m "feat: add skills and zotero bridge scaffold"
```

View 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"
```

View File

@ -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 items 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"
```

View File

@ -0,0 +1,627 @@
# Zotero KB Design
## Overview
This project builds a local Zotero-to-card pipeline for agent-friendly research workflows. It reads a local Zotero data directory, converts selected literature into lightweight knowledge cards, supports project-scoped writing assistance, and prepares a file-based integration path for Claude Code / Codex via SKILL.
The first implementation pass prioritizes a local `API + Web` service, not a Zotero-native product. Zotero remains the source of truth for bibliographic data and attachments. The generated card library becomes the reusable asset layer for humans and agents.
## Goals
- Read Zotero data directly from a local data directory such as `/mnt/c/Users/WSX/Zotero`
- Support importing the literature currently selected in Zotero into the local system
- Preserve Zotero collection hierarchy in the generated library structure
- Convert literature into lightweight Markdown knowledge cards with a JSON index
- Support project creation and project-scoped literature management
- Support adding and removing literature from a project at any time
- Support two independent writing-mode actions:
- citation candidate recommendation
- first-pass citation plan generation
- Prepare a file-based SKILL integration so Claude Code / Codex can read only the content of a specified project
## Non-Goals For V1
- Full knowledge-base mode UI or workflows across the entire library
- General-purpose chat over all literature
- Final paper text generation
- Editing Zotero data in place
- Replacing Zotero as the bibliographic source of truth
Knowledge-base mode is intentionally deferred. V1 only reserves the underlying global library structure and API surface needed for future implementation.
## Context And Constraints
### Local Zotero Access
The user confirmed that the Zotero data directory is available locally and contains both `zotero.sqlite` and `storage/`.
V1 uses a mixed ingestion model:
- Read bibliographic data, notes, tags, collections, and attachments from the local Zotero data directory
- Use a minimal Zotero bridge only to retrieve the keys of the items currently selected in the Zotero UI
This split is required because direct filesystem access can read the library contents but cannot reliably observe Zotero's current UI selection state.
### Reference Projects
- `zotero-rag` is the main implementation reference for local web service structure, attachment extraction, and search-oriented pipeline composition
- `zotcard` is the main reference for the Zotero-side bridge pattern and selected-item access inside the Zotero desktop runtime
The new system is intentionally not identical to either:
- unlike `zotero-rag`, the durable asset is a card library rather than only a retrieval index
- unlike `zotcard`, the primary product surface is a local service and file-based workspace rather than a Zotero-first note plugin
## Product Model
The system has two storage scopes with different purposes.
### 1. Global Library Scope
This stores the full set of imported literature and generated cards across all imports. It is the foundation for the future knowledge-base mode.
Responsibilities:
- maintain the canonical local card library
- preserve collection hierarchy across all imported items
- store normalized item metadata and card indexes
- avoid regenerating duplicate cards for the same Zotero item unless the source content changes
### 2. Project Scope
Projects are scoped working sets built on top of the global library. A project contains references to selected literature rather than owning the canonical copy of every card.
Responsibilities:
- define which literature belongs to the project
- support adding and removing selected items at any time
- provide a project-scoped view for writing assistance
- constrain Claude Code / Codex to read only the project's content
This split avoids redundant card copies while preserving future flexibility for project-specific views and exports.
## Architecture
The service is divided into five units.
### 1. Zotero Bridge
Purpose:
- ask Zotero for the currently selected item keys
Characteristics:
- minimal surface area
- no card logic
- no library parsing
- no knowledge of projects
The bridge can be a small Zotero plugin or local bridge script embedded into a plugin package. Its only required output is a list of selected `item_key` values.
### 2. Zotero Reader
Purpose:
- read local Zotero data from `zotero.sqlite` and `storage/`
- load item metadata, creators, abstract, tags, notes, collections, and attachments
- extract attachment text for supported files
Characteristics:
- local-data-first
- deterministic
- independent from UI
### 3. Card Builder
Purpose:
- normalize raw Zotero material into a `source bundle`
- call a configurable LLM to transform source material into an agent-readable card
- write Markdown cards and update JSON indexes
Characteristics:
- one card per Zotero regular item
- source-aware hash for rebuild detection
- card schema stable enough for direct SKILL consumption
### 4. Project Manager
Purpose:
- create projects
- track which Zotero items belong to which project
- expose project-scoped listings and stats
- allow project item removal without affecting the global library
### 5. Writing Service
Purpose:
- run project-scoped writing assistance over project cards
V1 includes two independent actions:
- recommend citation candidates
- generate a first-pass citation plan
These actions consume project-scoped card views and never read outside the target project.
## Directory Layout
The workspace uses a shared library plus project views.
```text
workspace/
library/
collections/
Theory/
Subtopic/
Paper A [ABCD1234].md
index/
items.json
cards.json
collections.json
cache/
source-bundles/
ABCD1234.json
projects/
thesis-ch2/
project.json
selected-items.json
project-index.json
```
### Library Scope Files
- `library/collections/`: canonical Markdown cards arranged to mirror Zotero collection hierarchy
- `library/index/items.json`: normalized item metadata keyed by Zotero item key
- `library/index/cards.json`: card metadata used for filtering, retrieval, and writing-mode preparation
- `library/index/collections.json`: normalized collection tree and item membership
- `library/cache/source-bundles/`: raw normalized source material captured before LLM card generation
### Project Scope Files
- `projects/<project-id>/project.json`: project metadata and model configuration
- `projects/<project-id>/selected-items.json`: ordered set of Zotero item keys assigned to the project
- `projects/<project-id>/project-index.json`: precomputed project view for fast loading and SKILL consumption
Projects do not need to duplicate every canonical card. The project index maps selected items to canonical card paths in the global library.
## Data Model
### Project Metadata
Example:
```json
{
"id": "thesis-ch2",
"name": "Thesis Chapter 2",
"zotero_data_dir": "/mnt/c/Users/WSX/Zotero",
"selection_mode": "zotero-bridge",
"llm": {
"provider": "openai",
"model": "gpt-5-mini",
"base_url": null
},
"created_at": "2026-04-15T00:00:00Z"
}
```
### Item Index Entry
`library/index/items.json` stores one normalized entry per Zotero item.
Required fields:
- `item_key`
- `title`
- `creators`
- `year`
- `item_type`
- `abstract`
- `tags`
- `collection_paths`
- `note_ids`
- `attachment_keys`
- `attachment_status`
- `card_path`
- `source_hash`
- `updated_at`
### Card Index Entry
`library/index/cards.json` stores fields optimized for reading and writing assistance.
Required fields:
- `item_key`
- `card_path`
- `title`
- `summary`
- `keywords`
- `claims`
- `quotable_spans`
- `writing_hints`
- `updated_at`
### Collection Index Entry
`library/index/collections.json` stores normalized collection information.
Required fields:
- `collection_key`
- `name`
- `parent_key`
- `path`
- `item_keys`
## Card Format
Each regular Zotero item generates one Markdown card in the canonical library.
Example:
```md
---
item_key: ABCD1234
title: Retrieval-Augmented Writing in Humanities Research
year: 2024
collections:
- Theory/Subtopic
authors:
- Alice Smith
- Bob Li
tags:
- llm
- retrieval
attachment_status: ok
source_hash: "sha256:6f6f5d9de1b5a9b98d7ce1c76d7a8d5c441d72e3"
---
# Summary
This paper argues that card-oriented retrieval pipelines improve citation-grounded drafting when notes, metadata, and attachment text are merged before synthesis.
# Core Claims
- Combined metadata, notes, and full-text evidence produce more reliable citation suggestions than metadata-only indexing.
- Project-scoped reading reduces irrelevant retrieval during drafting.
# Methods
- Compares card-generation inputs across three corpus preparation strategies.
- Evaluates writing-support quality with citation recommendation tasks.
# Evidence
- Reports higher citation precision for the merged-input pipeline.
- Includes examples where note-only systems miss relevant methodological context.
# Quotable Passages
- p.12: "Project-scoped card retrieval improves citation precision during drafting."
- Note: Author stresses that project-bounded retrieval lowers topical drift.
# Writing Hints
- Useful for defining the problem
- Useful as a supporting citation for claims about scoped retrieval during drafting
```
The card format is intentionally designed for:
- direct human reading
- direct SKILL consumption
- predictable parsing by the Web service
## Import Flow
### Selected-Items Import
1. The user selects multiple items in Zotero
2. The Zotero bridge returns the selected `item_key` values
3. The service reads the local Zotero database and storage directory
4. The reader collects metadata, notes, tags, collection paths, and attachment text
5. The builder creates or updates source bundles
6. The builder generates or refreshes Markdown cards and indexes
7. The project manager adds the selected item keys to the target project
8. The project view is rebuilt so Web and SKILL consumers can read the updated subset
### Add / Remove Semantics
- Adding is `upsert` by `item_key`
- A Zotero item can belong to multiple projects
- Removing an item from a project only updates that project
- Removing an item from a project never deletes it from Zotero
- The canonical card remains in the global library unless explicit future garbage collection is added
## LLM Configuration
Card generation is model-driven and configurable via API at the project level.
V1 requirements:
- support configurable provider and model fields
- support prompt templates for card generation
- keep the source bundle on disk before generation
- treat LLM output as structured content that is converted into canonical Markdown sections
The service should not hardcode a single provider design. It should expose a provider abstraction with one stable card-generation contract.
## Modes
### Writing Mode
Writing mode is project-scoped and exposes two independent actions, each implemented as a dedicated skill.
### Skill 1: Citation Recommendation
**Skill name:** `zotero-citation-recommender`
**Purpose:** Given a writing intent or draft paragraph, recommend relevant literature from the project scope.
**Input:**
- writing intent text or draft paragraph
**Output:**
- structured list of recommended literature
- why each item is relevant
- card-derived claims or quotable spans
- suggested rhetorical role such as:
- definition
- supporting evidence
- contrast
- limitation
**API endpoint:** `POST /api/projects/{project_id}/writing/recommend-citations`
---
### Skill 2: Citation Plan Generation
**Skill name:** `zotero-citation-planner`
**Purpose:** Generate a first-pass citation plan for a writing intent, suggesting paragraph structure, citation order, and the role of each cited work.
**Input:**
- writing intent text
- optional stance
- optional paragraph goal
- optional length expectation
**Output:**
- paragraph-level citation plan
- suggested sub-structure
- citation order
- recommended role for each cited work
- notes about which claims should be grounded by which items
This skill generates a plan, not polished final prose.
**API endpoint:** `POST /api/projects/{project_id}/writing/generate-plan`
### Knowledge-Base Mode
Knowledge-base mode is reserved for future implementation and is not part of V1 delivery.
V1 only reserves:
- the global library storage model
- future-facing API stubs
- indexes suitable for full-library browsing and retrieval later
## API Surface
The initial service should expose the following HTTP endpoints.
### Project Endpoints
- `POST /api/projects`
- create a project
- `GET /api/projects`
- list projects
- `GET /api/projects/{project_id}`
- get project detail and counts
### Import Endpoints
- `POST /api/projects/{project_id}/imports/selected-items`
- read selected item keys from the Zotero bridge
- ingest source data from the local Zotero directory
- generate or update cards
- add the imported items to the project
### Project Item Endpoints
- `GET /api/projects/{project_id}/items`
- list project items
- `DELETE /api/projects/{project_id}/items/{item_key}`
- remove an item from the project
### Card Endpoints
- `GET /api/projects/{project_id}/cards`
- list cards visible to the project
- `POST /api/projects/{project_id}/cards/rebuild`
- rebuild all cards in the project scope
- `POST /api/projects/{project_id}/cards/{item_key}/rebuild`
- rebuild one card
### Writing Endpoints
- `POST /api/projects/{project_id}/writing/recommend-citations`
- return project-scoped citation candidates
- `POST /api/projects/{project_id}/writing/generate-plan`
- return a project-scoped first-pass citation plan
### Future Knowledge-Base Endpoints
These routes are reserved in V1:
- `GET /api/library/cards`
- `GET /api/library/collections`
- `POST /api/library/search`
## Web UI
The Web UI is an operational console, not the primary agent interface.
### Left Panel
- project switcher
- create project action
- import selected Zotero items action
- project collection tree
### Center Panel
- project card list
- filters by title, author, tag, year, and keyword
- card detail viewer
- remove-from-project action
- rebuild-card action
### Right Panel
- writing mode tabs
- recommendation
- citation plan
- structured input form
- structured output display
The UI should remain intentionally small. It exists to manage and inspect the workspace rather than replace the card files as the main knowledge surface.
## Error Handling
The system should degrade gracefully and preserve intermediate assets where possible.
### Bridge Failure
- if the Zotero bridge cannot read current selection, return an explicit import error
- do not affect existing projects or cards
### Attachment Failure
- if attachment text extraction fails, still build a partial card from available metadata, abstract, notes, and tags
- mark `attachment_status` as `missing` or `partial`
### LLM Failure
- keep the source bundle on disk
- mark the item as card generation failed
- allow single-item or project-level retry
### Source Drift
- detect changes through `source_hash`
- mark cards stale when Zotero source content changes
- surface rebuild-needed state in project views
## Testing Strategy
V1 should focus on deterministic tests around boundaries rather than broad UI snapshots.
### SQLite Reader Tests
- parse items, creators, notes, tags, and collection relationships from fixture data
- resolve attachment mappings from the Zotero schema used by the local directory
### Project Management Tests
- create projects
- add selected items
- deduplicate repeated imports
- remove project items without mutating the canonical library
### Card Builder Tests
- normalize source bundles
- verify prompt input assembly
- stub model output
- verify Markdown card writing and JSON index updates
- verify stale detection using `source_hash`
### Writing Service Tests
- citation recommendation returns the expected structured shape
- citation plan generation returns the expected structured plan shape
- both actions remain project-scoped
### API Tests
- validate project creation, import, removal, rebuild, and writing endpoints
## SKILL Integration
The agent integration is file-based. Claude Code / Codex should read only the selected project's content.
Two writing-mode skills are provided:
- `skills/zotero-citation-recommender/SKILL.md` — handles citation candidate recommendation
- `skills/zotero-citation-planner/SKILL.md` — handles first-pass citation plan generation
A project reader skill is also provided:
- `skills/zotero-project-reader/SKILL.md` — core project and card reading
Responsibilities of each skill:
**zotero-project-reader:**
- read `projects/<project-id>/project.json`
- read `selected-items.json` and `project-index.json`
- load only the canonical cards referenced by the project
**zotero-citation-recommender:**
- read project-scoped cards
- accept writing intent or draft paragraph as input
- return structured citation recommendations with relevance rationale, claims, and rhetorical roles
**zotero-citation-planner:**
- read project-scoped cards
- accept writing intent and optional stance/goal/length as input
- return paragraph-level citation plan with structure, order, and per-work roles
Each skill must not access the entire global library unless the target project explicitly references those items.
## Implementation Priorities For V1
1. Build the local workspace structure and project model
2. Implement Zotero local-data reading from `zotero.sqlite` and `storage/`
3. Implement the minimal Zotero selected-items bridge
4. Build source bundle normalization and card generation
5. Expose project import and item removal APIs
6. Expose project-scoped writing endpoints
7. Build the minimal Web console
8. Add file-based SKILL integration
## Open Decisions Resolved
The following decisions were explicitly settled during brainstorming:
- service entrypoint is `API + Web`
- Claude Code / Codex integration is file-based, not API-driven
- card output format is `Markdown cards + JSON index`
- writing mode contains two independent actions
- knowledge-base mode is deferred, but its storage model is reserved now
- the data source is the local Zotero data directory
- current-selection import uses a small Zotero bridge for selection keys only
- collection hierarchy must mirror Zotero
## Delivery Boundary
V1 is complete when the user can:
- create a project
- select multiple items in Zotero
- import the selected items into that project
- generate canonical Markdown cards from local Zotero data and attachments
- remove items from the project without affecting Zotero
- request citation candidates for a project-scoped writing intent
- request a first-pass citation plan for a project-scoped writing intent
- point Claude Code / Codex at a project and have it read only that project's cards

View File

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

View File

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

5
main.py Normal file
View File

@ -0,0 +1,5 @@
from zotero_kb.main import main
if __name__ == "__main__":
main()

25
pyproject.toml Normal file
View File

@ -0,0 +1,25 @@
[project]
name = "zotero-kb"
version = "0.1.0"
description = "Local Zotero knowledge card workspace for agent writing workflows"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.115,<1",
"uvicorn>=0.30,<1",
"pydantic>=2.8,<3",
"jinja2>=3.1,<4",
]
[project.optional-dependencies]
dev = [
"httpx>=0.27,<0.28",
"pytest>=8.3,<9",
]
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.pytest.ini_options]
pythonpath = ["src"]

View File

@ -0,0 +1,53 @@
---
name: zotero-cite
description: >
从指定的 Zotero KB 项目中检索知识卡片,为论文写作推荐最合适的引用文献。
只能访问用户明确指定的项目,严禁读取其他项目。
---
# Zotero Citation Skill
Use this skill when the user needs citations from a specific Zotero KB project while writing.
## Constraints
- **ONLY** read the project explicitly specified by the user.
- NEVER guess a project_id. If the user does not provide one, ask for it.
- NEVER read other projects' indexes unless the user explicitly asks.
## Workflow
1. **Get project_id** from the user if not provided.
2. **Read** `workspace/projects/{project_id}/project.json` to confirm the project exists and get its name.
3. **Read** `workspace/projects/{project_id}/project-index.json` to get all cards in the project.
4. **Build context** from the cards:
- title, summary, claims, citations, quotable_spans, attachments
- creator names and publication year (from `items` array)
5. **Analyze the user's writing context** (the sentence/paragraph/section they are working on).
6. **Recommend 15 most relevant citations** from the project's cards only.
7. **Format the response** in Chinese (matching the card language if it is zh).
## Output Format
For each recommended citation, provide:
```
### 推荐引用 {n}
- **文献标题**: {title}
- **作者**: {creators}
- **年份**: {year}
- **item_key**: {item_key}(用于插入引用标记)
- **相关性说明**: {why this card matches the writing context}
- **可用论点**: {12 key claims from the card}
- **可直接引用的原文**: {quotable_spans if any}
- **建议的修辞角色**: {supporting evidence / counterpoint / background / methodological reference / etc.}
- **引用建议**: {a concrete suggestion for how to weave this citation into the user's text}
```
If no relevant card is found, state clearly that the current project does not contain matching literature, and suggest either:
- broadening the writing topic, or
- importing more items into the project.
## Cardinal Rule
> The user has curated this project for a reason. Do NOT suggest citations from outside the project index, even if you know the literature personally.

View File

@ -0,0 +1,2 @@
"""Zotero KB package."""

View File

@ -21,6 +21,12 @@ class CreateProjectRequest(BaseModel):
name: str = Field(min_length=1) name: str = Field(min_length=1)
llm_provider: str = "openai" llm_provider: str = "openai"
llm_model: str = "gpt-5-mini" llm_model: str = "gpt-5-mini"
card_language: str = "zh"
class RenameProjectRequest(BaseModel):
name: str = Field(min_length=1)
card_language: str = "zh"
class WritingPromptRequest(BaseModel): class WritingPromptRequest(BaseModel):
@ -35,6 +41,10 @@ class ImportItemKeysRequest(BaseModel):
item_keys: list[str] = Field(min_length=1) item_keys: list[str] = Field(min_length=1)
class GenerateCardsRequest(BaseModel):
item_keys: list[str] = Field(min_length=1)
def create_app( def create_app(
config: AppConfig, config: AppConfig,
*, *,
@ -57,6 +67,7 @@ def create_app(
name=payload.name, name=payload.name,
llm_provider=payload.llm_provider, llm_provider=payload.llm_provider,
llm_model=payload.llm_model, llm_model=payload.llm_model,
card_language=payload.card_language,
) )
return {"id": project.project_id, "name": project.name} return {"id": project.project_id, "name": project.name}
@ -72,6 +83,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, payload.card_language)
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()}
@ -87,6 +118,13 @@ def create_app(
def search_zotero_items(query: str = "", limit: int = 20) -> dict[str, object]: def search_zotero_items(query: str = "", limit: int = 20) -> dict[str, object]:
return {"items": reader.search_items(query, limit=limit)} return {"items": reader.search_items(query, limit=limit)}
@app.get("/api/zotero/items/{item_key}/attachments")
def zotero_item_attachments(item_key: str) -> dict[str, object]:
attachments = reader.get_item_attachments(item_key)
if not attachments:
raise HTTPException(status_code=404, detail="Item not found")
return {"item_key": item_key, "attachments": attachments}
@app.get("/api/projects/{project_id}") @app.get("/api/projects/{project_id}")
def get_project(project_id: str) -> dict[str, object]: def get_project(project_id: str) -> dict[str, object]:
project_dir = config.workspace_dir / "projects" / project_id project_dir = config.workspace_dir / "projects" / project_id
@ -117,9 +155,10 @@ def create_app(
builder = CardBuilder(config.workspace_dir, resolved_client) builder = CardBuilder(config.workspace_dir, resolved_client)
selected_keys = read_selected_keys(config.bridge_file) selected_keys = read_selected_keys(config.bridge_file)
items = reader.read_items(selected_keys) items = reader.read_items(selected_keys)
card_language = str(project_payload.get("card_language", "en"))
imported_keys = [] imported_keys = []
for item in items: for item in items:
builder.build_or_update(item) builder.build_or_update(item, card_language)
imported_keys.append(item.item_key) imported_keys.append(item.item_key)
project_view = project_service.add_items(project_id, imported_keys) project_view = project_service.add_items(project_id, imported_keys)
return {"project_id": project_id, "imported_item_keys": imported_keys, "project_view": project_view} return {"project_id": project_id, "imported_item_keys": imported_keys, "project_view": project_view}
@ -135,22 +174,28 @@ def create_app(
else: else:
item_keys = [str(value) for value in payload.item_keys] item_keys = [str(value) for value in payload.item_keys]
project_payload = _read_json(project_file)
llm_payload = project_payload.get("llm", {})
try:
resolved_client = llm_client or create_card_generation_client(
str(llm_payload.get("provider", "deterministic")),
str(llm_payload.get("model", "deterministic")),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
builder = CardBuilder(config.workspace_dir, resolved_client)
items = reader.read_items(item_keys) items = reader.read_items(item_keys)
imported_keys = []
# Write item metadata to items.json without generating cards
items_index_path = config.workspace_dir / "library" / "index" / "items.json"
items_index = project_service._read_json(items_index_path)
for item in items: for item in items:
builder.build_or_update(item) items_index[item.item_key] = {
imported_keys.append(item.item_key) "item_key": item.item_key,
"title": item.title,
"creators": item.creators,
"year": item.year,
"item_type": item.item_type,
"abstract": item.abstract,
"tags": item.tags,
"collection_paths": item.collection_paths,
"attachment_status": "ok" if item.attachment_texts else "missing",
}
project_service._write_json(items_index_path, items_index)
imported_keys = [item.item_key for item in items]
project_view = project_service.add_items(project_id, imported_keys) project_view = project_service.add_items(project_id, imported_keys)
return {"project_id": project_id, "imported_item_keys": imported_keys, "project_view": project_view} return {"project_id": project_id, "imported_item_keys": imported_keys, "project_view": project_view}
@ -195,6 +240,71 @@ def create_app(
) )
return {"project_id": project_id, "items": items, "pending_count": pending_count, "done_count": done_count} return {"project_id": project_id, "items": items, "pending_count": pending_count, "done_count": done_count}
@app.post("/api/projects/{project_id}/cards/generate")
def generate_cards(project_id: str, payload: GenerateCardsRequest | dict[str, object]) -> dict[str, object]:
project_file = config.workspace_dir / "projects" / project_id / "project.json"
if not project_file.exists():
raise HTTPException(status_code=404, detail="Project not found")
if isinstance(payload, dict):
item_keys = [str(value) for value in payload.get("item_keys", [])]
else:
item_keys = [str(value) for value in payload.item_keys]
project_payload = _read_json(project_file)
llm_payload = project_payload.get("llm", {})
try:
resolved_client = llm_client or create_card_generation_client(
str(llm_payload.get("provider", "deterministic")),
str(llm_payload.get("model", "deterministic")),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
builder = CardBuilder(config.workspace_dir, resolved_client)
items = reader.read_items(item_keys)
card_language = str(project_payload.get("card_language", "en"))
items_index_path = config.workspace_dir / "library" / "index" / "items.json"
items_index = project_service._read_json(items_index_path)
cards_index = project_service._read_json(config.workspace_dir / "library" / "index" / "cards.json")
generated: list[str] = []
failed: list[str] = []
response_items: list[dict[str, object]] = []
for item in items:
item_key = item.item_key
item_data = items_index.get(item_key, {})
try:
builder.build_or_update(item, card_language)
generated.append(item_key)
card_status = "done"
except Exception:
failed.append(item_key)
card_status = "failed"
response_items.append(
{
"item_key": item_key,
"title": item_data.get("title", item.title if hasattr(item, 'title') else "Untitled"),
"creators": item_data.get("creators", item.creators if hasattr(item, 'creators') else []),
"year": item_data.get("year", item.year if hasattr(item, 'year') else None),
"item_type": item_data.get("item_type", item.item_type if hasattr(item, 'item_type') else "unknown"),
"card_status": card_status,
}
)
if generated:
project_service.add_items(project_id, generated)
return {
"project_id": project_id,
"generated": generated,
"failed": failed,
"items": response_items,
}
@app.delete("/api/projects/{project_id}/items/{item_key}") @app.delete("/api/projects/{project_id}/items/{item_key}")
def remove_project_item(project_id: str, item_key: str) -> dict[str, object]: def remove_project_item(project_id: str, item_key: str) -> dict[str, object]:
return project_service.remove_item(project_id, item_key) return project_service.remove_item(project_id, item_key)

10
src/zotero_kb/bridge.py Normal file
View File

@ -0,0 +1,10 @@
from __future__ import annotations
import json
from pathlib import Path
def read_selected_keys(bridge_file: Path) -> list[str]:
payload = json.loads(bridge_file.read_text(encoding="utf-8"))
return [str(item) for item in payload.get("selected_keys", [])]

238
src/zotero_kb/cards.py Normal file
View File

@ -0,0 +1,238 @@
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .llm import CardGenerationClient
from .zotero_reader import ZoteroItemRecord
@dataclass(frozen=True)
class CardBuildResult:
item_key: str
card_path: Path
source_hash: str
language: str
class CardBuilder:
def __init__(self, workspace_dir: Path, llm_client: CardGenerationClient) -> None:
self.workspace_dir = workspace_dir
self.llm_client = llm_client
self.library_dir = workspace_dir / "library"
self.collections_dir = self.library_dir / "collections"
self.index_dir = self.library_dir / "index"
self.bundle_dir = self.library_dir / "cache" / "source-bundles"
for path in (self.collections_dir, self.index_dir, self.bundle_dir):
path.mkdir(parents=True, exist_ok=True)
def build_or_update(self, item: ZoteroItemRecord, language: str = "en") -> CardBuildResult:
source_bundle = self._build_source_bundle(item, language)
source_hash = self._hash_payload(source_bundle)
source_bundle["source_hash"] = source_hash
bundle_path = self.bundle_dir / f"{item.item_key}.{language}.json"
self._write_json(bundle_path, source_bundle)
card_data = self.llm_client.generate_card(source_bundle)
card_path = self._card_path(item, language)
card_path.parent.mkdir(parents=True, exist_ok=True)
card_path.write_text(self._render_card(item, source_hash, card_data), encoding="utf-8")
self._update_indexes(item, card_path, source_hash, card_data, language)
return CardBuildResult(item_key=item.item_key, card_path=card_path, source_hash=source_hash, language=language)
def _build_source_bundle(self, item: ZoteroItemRecord, language: str) -> dict[str, object]:
return {
"item_key": item.item_key,
"title": item.title,
"card_language": language,
"creators": item.creators,
"year": item.year,
"item_type": item.item_type,
"abstract": item.abstract,
"tags": item.tags,
"collection_paths": item.collection_paths,
"notes": item.notes,
"attachments": item.attachments,
"attachment_texts": item.attachment_texts,
}
@staticmethod
def _hash_payload(payload: dict[str, object]) -> str:
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
return f"sha256:{hashlib.sha256(raw).hexdigest()}"
def _card_path(self, item: ZoteroItemRecord, language: str) -> Path:
collection_path = item.collection_paths[0] if item.collection_paths else ["Unsorted"]
filename = f"{self._sanitize_filename(item.title)} [{item.item_key}][{language}].md"
return self.collections_dir.joinpath(*collection_path, filename)
@staticmethod
def _sanitize_filename(value: str) -> str:
value = re.sub(r"[\\\\/:*?\"<>|]", "", value).strip()
return value or "Untitled"
def _render_card(
self,
item: ZoteroItemRecord,
source_hash: str,
card_data: dict[str, object],
) -> str:
collections = item.collection_paths or [["Unsorted"]]
frontmatter_lines = [
"---",
f"item_key: {item.item_key}",
f"title: {item.title}",
f"year: {item.year or ''}",
"collections:",
]
frontmatter_lines.extend(f" - {'/'.join(path)}" for path in collections)
frontmatter_lines.append("authors:")
frontmatter_lines.extend(f" - {author}" for author in item.creators)
frontmatter_lines.append("tags:")
frontmatter_lines.extend(f" - {tag}" for tag in item.tags)
frontmatter_lines.append(f"attachment_status: {'ok' if item.attachment_texts else 'missing'}")
frontmatter_lines.append(f"source_hash: \"{source_hash}\"")
frontmatter_lines.append("---")
sections = [
("Summary", [str(card_data.get("summary", ""))]),
("Core Claims", [str(value) for value in card_data.get("core_claims", [])]),
("Methods", [str(value) for value in card_data.get("methods", [])]),
("Evidence", [str(value) for value in card_data.get("evidence", [])]),
("Source Files", self._render_attachments(item.attachments)),
("Citations", self._render_citations([value for value in card_data.get("citations", []) if isinstance(value, dict)])),
("Quotable Passages", [str(value) for value in card_data.get("quotable_passages", [])]),
("Writing Hints", [str(value) for value in card_data.get("writing_hints", [])]),
]
lines = frontmatter_lines + [""]
for heading, values in sections:
lines.append(f"# {heading}")
if heading == "Summary":
lines.append(values[0] if values and values[0] else "")
else:
lines.extend(f"- {value}" for value in values if value)
lines.append("")
return "\n".join(lines).strip() + "\n"
@staticmethod
def _normalize_citations(raw_citations: list[dict[str, Any]]) -> list[dict[str, object]]:
citations: list[dict[str, object]] = []
for citation in raw_citations:
citations.append(
{
"claim": str(citation.get("claim", "")),
"quote": str(citation.get("quote", "")),
"paraphrase": str(citation.get("paraphrase", "")),
"quote_source": str(citation.get("quote_source", "")),
"use_case": str(citation.get("use_case", "")),
}
)
return citations
@classmethod
def _render_attachments(cls, raw_attachments: list[dict[str, Any]]) -> list[str]:
lines: list[str] = []
for attachment in raw_attachments:
lines.append(f"- filename: {attachment.get('filename', '')}")
lines.append(f" path: {attachment.get('path', '')}")
lines.append(f" content_type: {attachment.get('content_type', '')}")
lines.append(f" is_pdf: {attachment.get('is_pdf', False)}")
return lines
@classmethod
def _render_citations(cls, raw_citations: list[dict[str, Any]]) -> list[str]:
citations = cls._normalize_citations(raw_citations)
lines: list[str] = []
for citation in citations:
lines.append(f"- claim: {citation['claim']}")
lines.append(f" quote: {citation['quote']}")
lines.append(f" paraphrase: {citation['paraphrase']}")
lines.append(f" quote_source: {citation['quote_source']}")
lines.append(f" use_case: {citation['use_case']}")
return lines
def _update_indexes(
self,
item: ZoteroItemRecord,
card_path: Path,
source_hash: str,
card_data: dict[str, object],
language: str,
) -> None:
items_index = self._read_json(self.index_dir / "items.json")
cards_index = self._read_json(self.index_dir / "cards.json")
collections_index = self._read_json(self.index_dir / "collections.json")
items_index[item.item_key] = {
"item_key": item.item_key,
"title": item.title,
"creators": item.creators,
"year": item.year,
"item_type": item.item_type,
"abstract": item.abstract,
"tags": item.tags,
"collection_paths": item.collection_paths,
"attachment_status": "ok" if item.attachment_texts else "missing",
"card_path": str(card_path),
"card_paths": {
**{
str(key): str(value)
for key, value in dict(items_index.get(item.item_key, {}).get("card_paths", {})).items()
},
language: str(card_path),
},
"source_hash": source_hash,
}
existing_variants = cards_index.get(item.item_key, {})
if not isinstance(existing_variants, dict) or any(
key in existing_variants for key in ("summary", "claims", "citations", "attachments")
):
existing_variants = {"en": existing_variants} if existing_variants else {}
existing_variants[language] = {
"item_key": item.item_key,
"language": language,
"card_path": str(card_path),
"title": item.title,
"summary": str(card_data.get("summary", "")),
"attachments": item.attachments,
"keywords": [str(value) for value in card_data.get("keywords", [])],
"claims": [str(value) for value in card_data.get("core_claims", [])],
"citations": self._normalize_citations(
[value for value in card_data.get("citations", []) if isinstance(value, dict)]
),
"quotable_spans": [str(value) for value in card_data.get("quotable_passages", [])],
"writing_hints": [str(value) for value in card_data.get("writing_hints", [])],
}
cards_index[item.item_key] = existing_variants
for path_parts in item.collection_paths or [["Unsorted"]]:
collection_key = "/".join(path_parts)
collections_index[collection_key] = {
"collection_key": collection_key,
"name": path_parts[-1],
"path": path_parts,
"item_keys": sorted(
set(collections_index.get(collection_key, {}).get("item_keys", []) + [item.item_key])
),
}
self._write_json(self.index_dir / "items.json", items_index)
self._write_json(self.index_dir / "cards.json", cards_index)
self._write_json(self.index_dir / "collections.json", collections_index)
@staticmethod
def _read_json(path: Path) -> dict[str, object]:
if not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8"))
@staticmethod
def _write_json(path: Path, payload: dict[str, object]) -> None:
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")

38
src/zotero_kb/config.py Normal file
View File

@ -0,0 +1,38 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class AppConfig:
workspace_dir: Path
zotero_data_dir: Path
bridge_file: Path | None = None
@classmethod
def from_env(cls) -> "AppConfig":
workspace_dir = Path(os.environ.get("ZOTERO_KB_WORKSPACE", "workspace"))
zotero_data_dir = Path(os.environ.get("ZOTERO_DATA_DIR", "/mnt/c/Users/WSX/Zotero"))
bridge_raw = os.environ.get("ZOTERO_BRIDGE_FILE")
bridge_file = _normalize_bridge_path(workspace_dir, bridge_raw)
return cls(workspace_dir=workspace_dir, zotero_data_dir=zotero_data_dir, bridge_file=bridge_file)
def _normalize_bridge_path(workspace_dir: Path, bridge_raw: str | None) -> Path:
if not bridge_raw:
return workspace_dir / "bridge" / "selected-items.json"
if bridge_raw.endswith("/") or bridge_raw.endswith("\\"):
raw_path = Path(bridge_raw) / "selected-items.json"
else:
raw_path = Path(bridge_raw)
if not raw_path.is_absolute():
raw_string = bridge_raw.replace("\\", "/")
if raw_string.startswith("workspace/") or raw_string == "workspace":
raw_path = workspace_dir / raw_path.relative_to("workspace")
else:
raw_path = Path.cwd() / raw_path
return raw_path

174
src/zotero_kb/llm.py Normal file
View File

@ -0,0 +1,174 @@
from __future__ import annotations
import json
import os
import urllib.request
from collections.abc import Mapping
from typing import Any, Callable, Protocol
class CardGenerationClient(Protocol):
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
"""Return structured card sections for a normalized source bundle."""
class DeterministicCardGenerationClient:
"""Fallback card generator used when no remote model client is configured."""
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
abstract = str(source_bundle.get("abstract", "")).strip()
language = str(source_bundle.get("card_language", "en"))
notes = [str(value).strip() for value in source_bundle.get("notes", []) if str(value).strip()]
attachment_texts = [
str(value).strip() for value in source_bundle.get("attachment_texts", []) if str(value).strip()
]
summary = abstract or (notes[0] if notes else "") or (attachment_texts[0][:240] if attachment_texts else "")
claims = (notes[:3] or [summary]) if summary else []
return {
"summary": summary if language == "en" else f"中文:{summary}",
"core_claims": claims if language == "en" else [f"中文:{claim}" for claim in claims],
"methods": [],
"evidence": attachment_texts[:3],
"citations": [
{
"claim": claims[0] if claims else summary,
"quote": attachment_texts[0][:240],
"paraphrase": summary if language == "en" else f"中文:{summary}",
"quote_source": "attachment_texts",
"use_case": "direct_quote",
}
] if attachment_texts else [],
"quotable_passages": attachment_texts[:2],
"writing_hints": [
"Use this card when the writing intent overlaps with its summary or notes."
if language == "en"
else "当写作意图与摘要或笔记相关时使用这张卡片。",
],
"keywords": [str(value) for value in source_bundle.get("tags", [])],
}
RequestFunction = Callable[[str, dict[str, object], dict[str, str]], dict[str, object]]
class DeepSeekCardGenerationClient:
def __init__(
self,
api_key: str,
*,
model: str = "deepseek-chat",
base_url: str = "https://api.deepseek.com/v1",
request_fn: RequestFunction | None = None,
) -> None:
self.api_key = api_key
self.model = model
self.base_url = base_url.rstrip("/")
self.request_fn = request_fn or self._default_request
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
language = str(source_bundle.get("card_language", "en"))
language_instruction = (
"Write summary, core_claims, methods, evidence, paraphrase fields, and writing_hints in Chinese."
if language == "zh"
else "Write summary, core_claims, methods, evidence, paraphrase fields, and writing_hints in English."
)
payload = {
"model": self.model,
"response_format": {"type": "json_object"},
"messages": [
{
"role": "system",
"content": (
"You convert research source bundles into structured evidence cards for academic writing. "
"Never hallucinate. Use only information explicitly supported by the source bundle. "
"Preserve uncertainty and hedging. Return valid JSON only with keys: "
"summary, core_claims, methods, evidence, citations, quotable_passages, writing_hints, keywords."
),
},
{
"role": "user",
"content": (
"Given this source bundle, return exactly one JSON object with keys: "
"summary, core_claims, methods, evidence, citations, quotable_passages, writing_hints, keywords.\n\n"
"Rules:\n"
"1. summary: 2-4 factual sentences, no hype.\n"
"2. core_claims: concrete findings only; preserve numbers and hedging.\n"
"3. methods: brief method or setup details actually stated in the source.\n"
"4. evidence: empirical or textual support from the source, numbers preferred.\n"
"5. citations: an array of objects with keys claim, quote, paraphrase, quote_source, use_case.\n"
"6. citations.quote must be verbatim text from notes or attachment_texts only.\n"
"7. Return 1-3 citations whenever the source bundle contains usable supporting text in notes or attachment_texts.\n"
"8. quote_source must identify where the quote came from, such as attachment_texts or notes.\n"
"9. use_case must be one of direct_quote, paraphrase, or background.\n"
"10. quotable_passages: short verbatim excerpts from notes or attachment_texts only.\n"
"11. Use empty arrays only when the source bundle truly contains no usable supporting quote; do not fabricate.\n"
f"12. {language_instruction}\n\n"
f"Source bundle:\n{json.dumps(source_bundle, ensure_ascii=False)}"
),
},
],
}
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
response = self.request_fn(f"{self.base_url}/chat/completions", payload, headers)
content = str(response["choices"][0]["message"]["content"])
parsed = json.loads(_strip_json_fence(content))
return {
"summary": str(parsed.get("summary", "")),
"core_claims": [str(value) for value in parsed.get("core_claims", [])],
"methods": [str(value) for value in parsed.get("methods", [])],
"evidence": [str(value) for value in parsed.get("evidence", [])],
"citations": [
{
"claim": str(value.get("claim", "")),
"quote": str(value.get("quote", "")),
"paraphrase": str(value.get("paraphrase", "")),
"quote_source": str(value.get("quote_source", "")),
"use_case": str(value.get("use_case", "")),
}
for value in parsed.get("citations", [])
if isinstance(value, dict)
],
"quotable_passages": [str(value) for value in parsed.get("quotable_passages", [])],
"writing_hints": [str(value) for value in parsed.get("writing_hints", [])],
"keywords": [str(value) for value in parsed.get("keywords", [])],
}
@staticmethod
def _default_request(url: str, payload: dict[str, object], headers: dict[str, str]) -> dict[str, object]:
request = urllib.request.Request(
url=url,
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers=headers,
method="POST",
)
with urllib.request.urlopen(request, timeout=120) as response:
return json.loads(response.read().decode("utf-8"))
def create_card_generation_client(
provider: str,
model: str,
*,
env: Mapping[str, str] | None = None,
request_fn: RequestFunction | None = None,
) -> CardGenerationClient:
current_env = os.environ if env is None else env
normalized = provider.lower()
if normalized == "deepseek":
api_key = current_env.get("DEEPSEEK_API_KEY")
if not api_key:
raise ValueError("DEEPSEEK_API_KEY is required when llm provider is deepseek")
return DeepSeekCardGenerationClient(api_key=api_key, model=model, request_fn=request_fn)
return DeterministicCardGenerationClient()
def _strip_json_fence(value: str) -> str:
stripped = value.strip()
if stripped.startswith("```"):
lines = stripped.splitlines()
if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].strip() == "```":
return "\n".join(lines[1:-1]).strip()
return stripped

15
src/zotero_kb/main.py Normal file
View File

@ -0,0 +1,15 @@
from __future__ import annotations
import uvicorn
from .api import create_app
from .config import AppConfig
def main() -> None:
config = AppConfig.from_env()
uvicorn.run(create_app(config), host="0.0.0.0", port=8000)
if __name__ == "__main__":
main()

105
src/zotero_kb/projects.py Normal file
View File

@ -0,0 +1,105 @@
from __future__ import annotations
import json
from pathlib import Path
class ProjectService:
def __init__(self, workspace_dir: Path) -> None:
self.workspace_dir = workspace_dir
self.projects_dir = workspace_dir / "projects"
self.index_dir = workspace_dir / "library" / "index"
def add_items(self, project_id: str, item_keys: list[str]) -> dict[str, object]:
selected_items = self._read_selected_items(project_id)
selected_items = sorted(set(selected_items + item_keys))
self._write_json(self._project_dir(project_id) / "selected-items.json", selected_items)
return self._rebuild_project_index(project_id, selected_items)
def remove_item(self, project_id: str, item_key: str) -> dict[str, object]:
selected_items = [key for key in self._read_selected_items(project_id) if key != item_key]
self._write_json(self._project_dir(project_id) / "selected-items.json", selected_items)
return self._rebuild_project_index(project_id, selected_items)
def get_project_view(self, project_id: str) -> dict[str, object]:
selected_items = self._read_selected_items(project_id)
return self._rebuild_project_index(project_id, selected_items)
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_payload = self._read_json(self._project_dir(project_id) / "project.json")
card_language = str(project_payload.get("card_language", "en"))
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_variants = self._normalize_card_variants(cards_index.get(item_key))
card_data = card_variants.get(card_language)
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,
"attachments": card_data.get("attachments", []) if card_data else [],
"claims": card_data.get("claims", []) if card_data else [],
"citations": card_data.get("citations", []) 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,
"card_language": card_language,
"selected_items": selected_items,
"items": project_items,
"cards": project_cards,
"collections": project_collections,
}
self._write_json(self._project_dir(project_id) / "project-index.json", payload)
return payload
def _read_selected_items(self, project_id: str) -> list[str]:
path = self._project_dir(project_id) / "selected-items.json"
if not path.exists():
return []
return [str(value) for value in json.loads(path.read_text(encoding="utf-8"))]
def _project_dir(self, project_id: str) -> Path:
return self.projects_dir / project_id
@staticmethod
def _normalize_card_variants(card_entry: object) -> dict[str, dict[str, object]]:
if not isinstance(card_entry, dict):
return {}
if any(key in card_entry for key in ("summary", "claims", "citations", "attachments")):
legacy_card = dict(card_entry)
legacy_card.setdefault("language", "en")
return {"en": legacy_card}
variants: dict[str, dict[str, object]] = {}
for language, payload in card_entry.items():
if isinstance(payload, dict):
variant = dict(payload)
variant.setdefault("language", str(language))
variants[str(language)] = variant
return variants
@staticmethod
def _read_json(path: Path) -> dict[str, object]:
if not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8"))
@staticmethod
def _write_json(path: Path, payload: object) -> None:
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,92 @@
from __future__ import annotations
import json
import shutil
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from .config import AppConfig
@dataclass(frozen=True)
class ProjectRecord:
project_id: str
name: str
project_dir: Path
card_language: str
class Workspace:
def __init__(self, config: AppConfig) -> None:
self.config = config
def ensure_layout(self) -> None:
for path in (
self.config.workspace_dir / "library" / "collections",
self.config.workspace_dir / "library" / "index",
self.config.workspace_dir / "library" / "cache" / "source-bundles",
self.config.workspace_dir / "bridge",
self.config.workspace_dir / "projects",
):
path.mkdir(parents=True, exist_ok=True)
def create_project(
self,
project_id: str,
name: str,
llm_provider: str,
llm_model: str,
card_language: str,
) -> ProjectRecord:
self.ensure_layout()
project_dir = self.config.workspace_dir / "projects" / project_id
project_dir.mkdir(parents=True, exist_ok=True)
project_payload = {
"id": project_id,
"name": name,
"zotero_data_dir": str(self.config.zotero_data_dir),
"selection_mode": "zotero-bridge",
"llm": {
"provider": llm_provider,
"model": llm_model,
"base_url": None,
},
"card_language": card_language,
"created_at": datetime.now(timezone.utc).isoformat(),
}
self._write_json(project_dir / "project.json", project_payload)
self._write_json(project_dir / "selected-items.json", [])
self._write_json(project_dir / "project-index.json", {"items": []})
return ProjectRecord(project_id=project_id, name=name, project_dir=project_dir, card_language=card_language)
def rename_project(self, project_id: str, name: str, card_language: str | None = None) -> 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
if card_language is not None:
project_payload["card_language"] = card_language
self._write_json(project_file, project_payload)
return ProjectRecord(
project_id=project_id,
name=name,
project_dir=project_dir,
card_language=str(project_payload.get("card_language", "en")),
)
def delete_project(self, project_id: str) -> None:
project_dir = self.config.workspace_dir / "projects" / project_id
if not project_dir.exists():
raise FileNotFoundError(project_id)
shutil.rmtree(project_dir)
@staticmethod
def _write_json(path: Path, payload: object) -> None:
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")

74
src/zotero_kb/writing.py Normal file
View File

@ -0,0 +1,74 @@
from __future__ import annotations
import re
from pathlib import Path
from .projects import ProjectService
class WritingService:
def __init__(self, workspace_dir: Path) -> None:
self.project_service = ProjectService(workspace_dir)
def recommend_citations(self, project_id: str, prompt: str) -> dict[str, object]:
project_view = self.project_service.get_project_view(project_id)
prompt_terms = self._terms(prompt)
scored = []
for card in project_view.get("cards", []):
text_parts = [
str(card.get("title", "")),
str(card.get("summary", "")),
" ".join(str(value) for value in card.get("claims", [])),
" ".join(str(value) for value in card.get("writing_hints", [])),
]
combined = " ".join(text_parts)
score = self._score(prompt_terms, self._terms(combined))
if score > 0:
scored.append(
{
"item_key": card["item_key"],
"title": card["title"],
"summary": card.get("summary", ""),
"why_relevant": f"Matched {score} prompt terms in the project-scoped card.",
"claims": card.get("claims", []),
"quotable_spans": card.get("quotable_spans", []),
"rhetorical_role": "supporting evidence",
"score": score,
}
)
scored.sort(key=lambda item: (-item["score"], item["item_key"]))
return {"project_id": project_id, "results": scored}
def generate_plan(self, project_id: str, prompt: str) -> dict[str, object]:
recommendations = self.recommend_citations(project_id, prompt)["results"]
if not recommendations:
return {"project_id": project_id, "sections": []}
top = recommendations[0]
return {
"project_id": project_id,
"sections": [
{
"heading": "Core argument",
"goal": prompt,
"citations": [
{
"item_key": top["item_key"],
"title": top["title"],
"role": "primary support",
}
],
"notes": [
f"Lead with {top['title']} to ground the paragraph's main claim.",
],
}
],
}
@staticmethod
def _terms(value: str) -> set[str]:
return {part for part in re.findall(r"[a-zA-Z0-9]+", value.lower()) if len(part) > 2}
@staticmethod
def _score(prompt_terms: set[str], card_terms: set[str]) -> int:
return len(prompt_terms & card_terms)

View File

@ -0,0 +1,488 @@
from __future__ import annotations
import html
import re
import sqlite3
import subprocess
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class ZoteroItemRecord:
item_key: str
title: str
creators: list[str]
year: str | None
item_type: str
abstract: str
tags: list[str]
collection_paths: list[list[str]]
notes: list[str]
attachments: list[dict[str, object]]
attachment_texts: list[str]
class ZoteroReader:
def __init__(self, zotero_data_dir: Path) -> None:
self.zotero_data_dir = zotero_data_dir
self.db_path = zotero_data_dir / "zotero.sqlite"
def read_items(self, item_keys: list[str]) -> list[ZoteroItemRecord]:
if not item_keys:
return []
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
try:
field_lookup = self._read_field_lookup(conn)
collection_lookup = self._read_collection_lookup(conn)
items = []
for item_key in item_keys:
item_row = conn.execute(
"""
select i.itemID, i.key, it.typeName
from items i
join itemTypes it on it.itemTypeID = i.itemTypeID
where i.key = ?
""",
(item_key,),
).fetchone()
if item_row is None or item_row["typeName"] in {"attachment", "note", "annotation"}:
continue
item_id = int(item_row["itemID"])
item_data = self._read_item_data(conn, item_id, field_lookup)
attachment_records = self._read_attachment_records(conn, item_id)
items.append(
ZoteroItemRecord(
item_key=item_row["key"],
title=item_data.get("title", "Untitled"),
creators=self._read_creators(conn, item_id),
year=self._extract_year(item_data.get("date")),
item_type=item_row["typeName"],
abstract=item_data.get("abstractNote", ""),
tags=self._read_tags(conn, item_id),
collection_paths=self._read_collection_paths(conn, item_id, collection_lookup),
notes=self._read_notes(conn, item_id),
attachments=[self._public_attachment_metadata(attachment) for attachment in attachment_records],
attachment_texts=[str(attachment["text"]) for attachment in attachment_records if attachment.get("text")],
)
)
return items
finally:
conn.close()
def get_item_attachments(self, item_key: str) -> list[dict[str, object]]:
items = self.read_items([item_key])
if not items:
return []
return items[0].attachments
def search_items(self, query: str, limit: int = 20) -> list[dict[str, object]]:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
try:
field_lookup = self._read_field_lookup(conn)
sql = """
select i.itemID, i.key, it.typeName
from items i
join itemTypes it on it.itemTypeID = i.itemTypeID
where it.typeName not in ('attachment', 'note', 'annotation')
order by i.dateModified desc, i.itemID desc
limit ?
"""
rows = conn.execute(sql, (limit * 5,)).fetchall()
lowered = query.strip().lower()
results: list[dict[str, object]] = []
for row in rows:
item_id = int(row["itemID"])
item_data = self._read_item_data(conn, item_id, field_lookup)
title = item_data.get("title", "Untitled")
abstract = item_data.get("abstractNote", "")
haystack = " ".join([title, abstract]).lower()
if lowered and lowered not in haystack:
continue
results.append(
{
"item_key": str(row["key"]),
"title": title,
"item_type": str(row["typeName"]),
"year": self._extract_year(item_data.get("date")),
"abstract": abstract,
}
)
if len(results) >= limit:
break
return results
finally:
conn.close()
def get_collection_tree(self) -> list[dict[str, object]]:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
try:
collections = self._read_collection_lookup(conn)
children_lookup = self._build_children_lookup(collections)
direct_counts = self._read_direct_item_counts(conn)
root_ids = [collection_id for collection_id, row in collections.items() if row["parentCollectionID"] is None]
return [
self._build_collection_node(collection_id, collections, children_lookup, direct_counts)
for collection_id in sorted(root_ids)
]
finally:
conn.close()
def get_collection_items(
self,
collection_key: str,
include_descendants: bool = True,
) -> list[dict[str, object]]:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
try:
field_lookup = self._read_field_lookup(conn)
collections = self._read_collection_lookup(conn)
children_lookup = self._build_children_lookup(collections)
collection_ids = self._resolve_collection_ids(
collection_key,
collections,
children_lookup,
include_descendants=include_descendants,
)
item_ids = self._read_item_ids_for_collections(conn, collection_ids)
return self._read_item_summaries(conn, item_ids, field_lookup, collections)
finally:
conn.close()
@staticmethod
def _read_field_lookup(conn: sqlite3.Connection) -> dict[int, str]:
return {
int(row["fieldID"]): str(row["fieldName"])
for row in conn.execute("select fieldID, fieldName from fields")
}
@staticmethod
def _read_collection_lookup(conn: sqlite3.Connection) -> dict[int, sqlite3.Row]:
return {
int(row["collectionID"]): row
for row in conn.execute(
"select collectionID, collectionName, parentCollectionID, key from collections"
)
}
@staticmethod
def _build_children_lookup(collections: dict[int, sqlite3.Row]) -> dict[int, list[int]]:
children_lookup: dict[int, list[int]] = {}
for collection_id, row in collections.items():
parent_id = row["parentCollectionID"]
if parent_id is None:
continue
children_lookup.setdefault(int(parent_id), []).append(collection_id)
for children in children_lookup.values():
children.sort()
return children_lookup
@staticmethod
def _read_direct_item_counts(conn: sqlite3.Connection) -> dict[int, int]:
rows = conn.execute(
"""
select ci.collectionID, count(*) as item_count
from collectionItems ci
join items i on i.itemID = ci.itemID
join itemTypes it on it.itemTypeID = i.itemTypeID
where it.typeName not in ('attachment', 'note', 'annotation')
group by ci.collectionID
"""
)
return {int(row["collectionID"]): int(row["item_count"]) for row in rows}
def _build_collection_node(
self,
collection_id: int,
collections: dict[int, sqlite3.Row],
children_lookup: dict[int, list[int]],
direct_counts: dict[int, int],
) -> dict[str, object]:
row = collections[collection_id]
children = [
self._build_collection_node(child_id, collections, children_lookup, direct_counts)
for child_id in children_lookup.get(collection_id, [])
]
descendant_count = direct_counts.get(collection_id, 0) + sum(
int(child["descendant_item_count"]) for child in children
)
return {
"collection_key": str(row["key"]),
"name": str(row["collectionName"]),
"parent_key": self._parent_key(row["parentCollectionID"], collections),
"children": children,
"direct_item_count": direct_counts.get(collection_id, 0),
"descendant_item_count": descendant_count,
}
@staticmethod
def _parent_key(parent_collection_id: object, collections: dict[int, sqlite3.Row]) -> str | None:
if parent_collection_id is None:
return None
parent_row = collections.get(int(parent_collection_id))
if parent_row is None:
return None
return str(parent_row["key"])
@staticmethod
def _resolve_collection_ids(
collection_key: str,
collections: dict[int, sqlite3.Row],
children_lookup: dict[int, list[int]],
include_descendants: bool,
) -> list[int]:
collection_id = next(
(current_id for current_id, row in collections.items() if str(row["key"]) == collection_key),
None,
)
if collection_id is None:
return []
if not include_descendants:
return [collection_id]
ordered: list[int] = []
stack = [collection_id]
while stack:
current_id = stack.pop(0)
ordered.append(current_id)
stack[0:0] = children_lookup.get(current_id, [])
return ordered
@staticmethod
def _read_item_ids_for_collections(conn: sqlite3.Connection, collection_ids: list[int]) -> list[int]:
if not collection_ids:
return []
placeholders = ", ".join("?" for _ in collection_ids)
rows = conn.execute(
f"""
select distinct ci.itemID
from collectionItems ci
join items i on i.itemID = ci.itemID
join itemTypes it on it.itemTypeID = i.itemTypeID
where ci.collectionID in ({placeholders})
and it.typeName not in ('attachment', 'note', 'annotation')
order by ci.itemID asc
""",
tuple(collection_ids),
)
return [int(row["itemID"]) for row in rows]
def _read_item_summaries(
self,
conn: sqlite3.Connection,
item_ids: list[int],
field_lookup: dict[int, str],
collections: dict[int, sqlite3.Row],
) -> list[dict[str, object]]:
summaries: list[dict[str, object]] = []
for item_id in item_ids:
row = conn.execute(
"""
select i.key, it.typeName
from items i
join itemTypes it on it.itemTypeID = i.itemTypeID
where i.itemID = ?
""",
(item_id,),
).fetchone()
if row is None:
continue
item_data = self._read_item_data(conn, item_id, field_lookup)
summaries.append(
{
"item_key": str(row["key"]),
"title": item_data.get("title", "Untitled"),
"year": self._extract_year(item_data.get("date")),
"item_type": str(row["typeName"]),
"abstract": item_data.get("abstractNote", ""),
"collection_paths": self._read_collection_paths(conn, item_id, collections),
}
)
return summaries
@staticmethod
def _read_item_data(
conn: sqlite3.Connection,
item_id: int,
field_lookup: dict[int, str],
) -> dict[str, str]:
data: dict[str, str] = {}
rows = conn.execute(
"""
select fieldID, value
from itemData
join itemDataValues using (valueID)
where itemID = ?
""",
(item_id,),
)
for row in rows:
field_name = field_lookup.get(int(row["fieldID"]))
if field_name:
data[field_name] = str(row["value"])
return data
@staticmethod
def _read_creators(conn: sqlite3.Connection, item_id: int) -> list[str]:
rows = conn.execute(
"""
select firstName, lastName, fieldMode
from itemCreators
join creators using (creatorID)
where itemID = ?
order by orderIndex asc
""",
(item_id,),
)
creators = []
for row in rows:
if int(row["fieldMode"] or 0) == 1:
creators.append(str(row["lastName"]))
else:
first = str(row["firstName"] or "").strip()
last = str(row["lastName"] or "").strip()
creators.append(" ".join(part for part in (first, last) if part))
return creators
@staticmethod
def _read_tags(conn: sqlite3.Connection, item_id: int) -> list[str]:
rows = conn.execute(
"""
select tags.name
from itemTags
join tags using (tagID)
where itemTags.itemID = ?
order by lower(tags.name) asc
""",
(item_id,),
)
return [str(row["name"]) for row in rows]
def _read_collection_paths(
self,
conn: sqlite3.Connection,
item_id: int,
collection_lookup: dict[int, sqlite3.Row],
) -> list[list[str]]:
rows = conn.execute(
"select collectionID from collectionItems where itemID = ? order by orderIndex asc",
(item_id,),
)
return [self._build_collection_path(int(row["collectionID"]), collection_lookup) for row in rows]
@staticmethod
def _build_collection_path(
collection_id: int,
collection_lookup: dict[int, sqlite3.Row],
) -> list[str]:
path: list[str] = []
current = collection_id
while current in collection_lookup:
row = collection_lookup[current]
path.append(str(row["collectionName"]))
parent = row["parentCollectionID"]
current = int(parent) if parent is not None else -1
path.reverse()
return path
def _read_notes(self, conn: sqlite3.Connection, item_id: int) -> list[str]:
rows = conn.execute(
"select note from itemNotes where parentItemID = ? order by itemID asc",
(item_id,),
)
return [self._strip_markup(str(row["note"])) for row in rows]
def _read_attachment_records(self, conn: sqlite3.Connection, item_id: int) -> list[dict[str, object]]:
rows = conn.execute(
"""
select contentType, path
from itemAttachments
where parentItemID = ?
order by itemID asc
""",
(item_id,),
)
attachments: list[dict[str, object]] = []
for row in rows:
raw_path = str(row["path"] or "")
content_type = str(row["contentType"] or "")
attachment_path = self._resolve_attachment_path(raw_path)
if attachment_path is None:
continue
text = self._extract_attachment_text_from_resolved_path(attachment_path, content_type)
attachments.append(
{
"path": str(attachment_path),
"filename": attachment_path.name,
"content_type": content_type,
"is_pdf": content_type == "application/pdf",
"text": text,
}
)
return attachments
@staticmethod
def _public_attachment_metadata(attachment: dict[str, object]) -> dict[str, object]:
return {
"path": attachment["path"],
"filename": attachment["filename"],
"content_type": attachment["content_type"],
"is_pdf": attachment["is_pdf"],
}
def _extract_attachment_text(self, raw_path: str, content_type: str) -> str:
attachment_path = self._resolve_attachment_path(raw_path)
if attachment_path is None:
return ""
return self._extract_attachment_text_from_resolved_path(attachment_path, content_type)
def _extract_attachment_text_from_resolved_path(self, attachment_path: Path, content_type: str) -> str:
if not attachment_path.exists():
return ""
if content_type == "application/pdf":
return self._extract_pdf_text(attachment_path)
return attachment_path.read_text(encoding="utf-8", errors="ignore").strip()
def _resolve_attachment_path(self, raw_path: str) -> Path | None:
if raw_path.startswith("storage:"):
relative_path = raw_path.removeprefix("storage:")
return self.zotero_data_dir / "storage" / relative_path
path = Path(raw_path)
if path.is_absolute():
return path
return self.zotero_data_dir / raw_path
@staticmethod
def _extract_pdf_text(path: Path) -> str:
completed = subprocess.run(
["pdftotext", str(path), "-"],
check=False,
capture_output=True,
text=True,
)
if completed.returncode != 0:
return ""
return completed.stdout.strip()
@staticmethod
def _strip_markup(value: str) -> str:
without_tags = re.sub(r"<[^>]+>", " ", value)
normalized = html.unescape(without_tags)
return " ".join(normalized.split())
@staticmethod
def _extract_year(value: str | None) -> str | None:
if not value:
return None
match = re.search(r"(19|20)\d{2}", value)
if match:
return match.group(0)
return value[:4] if len(value) >= 4 else value

2
tests/__init__.py Normal file
View File

@ -0,0 +1,2 @@
"""Test package."""

1
tests/fixtures/__init__.py vendored Normal file
View File

@ -0,0 +1 @@
"""Test fixtures package."""

112
tests/fixtures/build_zotero_fixture.py vendored Normal file
View File

@ -0,0 +1,112 @@
from __future__ import annotations
import sqlite3
from pathlib import Path
def build_fixture_zotero_dir(zotero_dir: Path) -> None:
storage_dir = zotero_dir / "storage" / "ATTACH01"
storage_dir.mkdir(parents=True, exist_ok=True)
(storage_dir / "paper.txt").write_text(
"This paper studies card pipelines for research writing.",
encoding="utf-8",
)
conn = sqlite3.connect(zotero_dir / "zotero.sqlite")
cur = conn.cursor()
cur.executescript(
"""
create table itemTypes (itemTypeID integer primary key, typeName text, templateItemTypeID int, display int);
create table items (itemID integer primary key, itemTypeID int, dateAdded text, dateModified text, clientDateModified text, libraryID int, key text, version int, synced int);
create table fields (fieldID integer primary key, fieldName text, fieldFormatID int);
create table itemDataValues (valueID integer primary key, value text);
create table itemData (itemID int, fieldID int, valueID int);
create table creators (creatorID integer primary key, firstName text, lastName text, fieldMode int);
create table creatorTypes (creatorTypeID integer primary key, creatorType text);
create table itemCreators (itemID int, creatorID int, creatorTypeID int, orderIndex int);
create table tags (tagID integer primary key, name text);
create table itemTags (itemID int, tagID int, type int);
create table collections (collectionID integer primary key, collectionName text, parentCollectionID int, clientDateModified text, libraryID int, key text, version int, synced int);
create table collectionItems (collectionID int, itemID int, orderIndex int);
create table itemNotes (itemID integer primary key, parentItemID int, note text, title text);
create table itemAttachments (itemID integer primary key, parentItemID int, linkMode int, contentType text, charsetID int, path text, syncState int, storageModTime int, storageHash text, lastProcessedModificationTime int);
"""
)
cur.executemany(
"insert into itemTypes(itemTypeID, typeName, templateItemTypeID, display) values (?, ?, ?, ?)",
[
(1, "journalArticle", None, 1),
(2, "note", None, 1),
(3, "attachment", None, 1),
],
)
cur.executemany(
"insert into fields(fieldID, fieldName, fieldFormatID) values (?, ?, ?)",
[
(1, "title", 1),
(2, "abstractNote", 1),
(3, "date", 1),
],
)
cur.executemany(
"insert into itemDataValues(valueID, value) values (?, ?)",
[
(1, "Card Pipelines for Research Writing"),
(2, "Merged metadata and notes improve drafting support."),
(3, "2024"),
],
)
cur.execute(
"insert into items(itemID, itemTypeID, dateAdded, dateModified, clientDateModified, libraryID, key, version, synced) values (?, ?, '', '', '', 1, ?, 1, 1)",
(1, 1, "PAPER0001"),
)
cur.executemany(
"insert into itemData(itemID, fieldID, valueID) values (?, ?, ?)",
[
(1, 1, 1),
(1, 2, 2),
(1, 3, 3),
],
)
cur.execute(
"insert into creators(creatorID, firstName, lastName, fieldMode) values (1, 'Alice', 'Smith', 0)"
)
cur.execute(
"insert into creatorTypes(creatorTypeID, creatorType) values (1, 'author')"
)
cur.execute(
"insert into itemCreators(itemID, creatorID, creatorTypeID, orderIndex) values (1, 1, 1, 0)"
)
cur.executemany(
"insert into tags(tagID, name) values (?, ?)",
[
(1, "llm"),
(2, "writing"),
],
)
cur.executemany(
"insert into itemTags(itemID, tagID, type) values (?, ?, 0)",
[
(1, 1),
(1, 2),
],
)
cur.executemany(
"insert into collections(collectionID, collectionName, parentCollectionID, clientDateModified, libraryID, key, version, synced) values (?, ?, ?, '', 1, ?, 1, 1)",
[
(1, "Theory", None, "COLL0001"),
(2, "Drafting", 1, "COLL0002"),
],
)
cur.execute("insert into collectionItems(collectionID, itemID, orderIndex) values (2, 1, 0)")
cur.execute(
"insert into itemNotes(itemID, parentItemID, note, title) values (2, 1, '<div><p>Merged notes matter.</p></div>', 'note')"
)
cur.execute(
"insert into itemAttachments(itemID, parentItemID, linkMode, contentType, charsetID, path, syncState, storageModTime, storageHash, lastProcessedModificationTime) values (3, 1, 1, 'text/plain', null, 'storage:ATTACH01/paper.txt', 0, 0, '', 0)"
)
conn.commit()
conn.close()

View File

@ -1,17 +1,21 @@
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.config import AppConfig from zotero_kb.config import AppConfig
class FakeLlmClient: class FakeLlmClient:
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]: def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
title = str(source_bundle["title"]) title = str(source_bundle["title"])
language = str(source_bundle.get("card_language", "en"))
return { return {
"summary": f"Summary for {title}", "summary": f"中文摘要 {title}" if language == "zh" else f"Summary for {title}",
"core_claims": [f"{title} supports scoped retrieval."], "core_claims": [f"中文论点 {title}"] if language == "zh" else [f"{title} supports scoped retrieval."],
"methods": ["Method details."], "methods": ["Method details."],
"evidence": ["Evidence details."], "evidence": ["Evidence details."],
"quotable_passages": [f"{title} supports scoped retrieval."], "quotable_passages": [f"{title} supports scoped retrieval."],
@ -50,12 +54,81 @@ def test_create_project_endpoint(tmp_path: Path) -> None:
name="Thesis Chapter 2", name="Thesis Chapter 2",
llm_provider="openai", llm_provider="openai",
llm_model="gpt-5-mini", llm_model="gpt-5-mini",
card_language="zh",
) )
) )
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",
card_language="en",
)
)
response = rename_project("thesis-ch2", RenameProjectRequest(name="New Name", card_language="zh"))
assert response["id"] == "thesis-ch2"
assert response["name"] == "New Name"
assert list_projects()[0]["name"] == "New Name"
assert list_projects()[0]["card_language"] == "zh"
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",
"card_language": "zh",
},
)
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")
@ -67,6 +140,7 @@ def test_import_selected_items_endpoint(tmp_path: Path) -> None:
name="Thesis Chapter 2", name="Thesis Chapter 2",
llm_provider="openai", llm_provider="openai",
llm_model="gpt-5-mini", llm_model="gpt-5-mini",
card_language="zh",
) )
) )
@ -87,6 +161,7 @@ def test_recommend_citations_endpoint(tmp_path: Path) -> None:
name="Thesis Chapter 2", name="Thesis Chapter 2",
llm_provider="openai", llm_provider="openai",
llm_model="gpt-5-mini", llm_model="gpt-5-mini",
card_language="zh",
) )
) )
import_selected("thesis-ch2") import_selected("thesis-ch2")
@ -109,6 +184,7 @@ def test_create_project_persists_deepseek_provider(tmp_path: Path) -> None:
name="Thesis Chapter 2", name="Thesis Chapter 2",
llm_provider="deepseek", llm_provider="deepseek",
llm_model="deepseek-chat", llm_model="deepseek-chat",
card_language="en",
) )
) )
@ -117,6 +193,7 @@ def test_create_project_persists_deepseek_provider(tmp_path: Path) -> None:
) )
assert project_payload["llm"]["provider"] == "deepseek" assert project_payload["llm"]["provider"] == "deepseek"
assert project_payload["llm"]["model"] == "deepseek-chat" assert project_payload["llm"]["model"] == "deepseek-chat"
assert project_payload["card_language"] == "en"
def test_search_library_items_endpoint(tmp_path: Path) -> None: def test_search_library_items_endpoint(tmp_path: Path) -> None:
@ -148,6 +225,18 @@ def test_collection_items_endpoint_includes_descendants(tmp_path: Path) -> None:
assert payload["collection_key"] == "COLL0001" assert payload["collection_key"] == "COLL0001"
def test_item_attachments_endpoint_returns_attachment_metadata(tmp_path: Path) -> None:
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
endpoint = _route(app, "/api/zotero/items/{item_key}/attachments", "GET")
payload = endpoint("PAPER0001")
assert payload["item_key"] == "PAPER0001"
assert payload["attachments"][0]["filename"] == "paper.txt"
assert payload["attachments"][0]["content_type"] == "text/plain"
assert payload["attachments"][0]["is_pdf"] is False
def test_import_item_keys_endpoint_without_bridge(tmp_path: Path) -> None: def test_import_item_keys_endpoint_without_bridge(tmp_path: Path) -> None:
config = make_test_config(tmp_path) config = make_test_config(tmp_path)
config.bridge_file.unlink() config.bridge_file.unlink()
@ -196,7 +285,8 @@ def test_import_state_endpoint_shows_pending_when_no_card(tmp_path: Path) -> Non
assert payload["done_count"] == 0 assert payload["done_count"] == 0
def test_import_state_endpoint_shows_done_when_card_exists(tmp_path: Path) -> None: def test_import_state_endpoint_shows_pending_after_import(tmp_path: Path) -> None:
"""After import_item_keys, card_status should be pending (cards generated separately)."""
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")
import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST") import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST")
@ -217,6 +307,134 @@ def test_import_state_endpoint_shows_done_when_card_exists(tmp_path: Path) -> No
assert payload["project_id"] == "thesis-ch2" assert payload["project_id"] == "thesis-ch2"
assert len(payload["items"]) == 1 assert len(payload["items"]) == 1
assert payload["items"][0]["item_key"] == "PAPER0001" assert payload["items"][0]["item_key"] == "PAPER0001"
assert payload["items"][0]["card_status"] == "done" assert payload["items"][0]["card_status"] == "pending"
assert payload["pending_count"] == 0 assert payload["pending_count"] == 1
assert payload["done_count"] == 1 assert payload["done_count"] == 0
def test_import_item_keys_does_not_generate_cards(tmp_path: Path) -> None:
"""import_item_keys should only register items, not generate cards."""
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",
)
)
import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
# Verify item was added to selected-items.json
selected_items_path = tmp_path / "workspace" / "projects" / "thesis-ch2" / "selected-items.json"
selected_items = json.loads(selected_items_path.read_text(encoding="utf-8"))
assert "PAPER0001" in selected_items
# Verify item metadata was written to items.json
items_index_path = tmp_path / "workspace" / "library" / "index" / "items.json"
items_index = json.loads(items_index_path.read_text(encoding="utf-8"))
assert "PAPER0001" in items_index
assert items_index["PAPER0001"]["title"] == "Card Pipelines for Research Writing"
# Verify NO card was generated (cards.json should not have PAPER0001)
cards_index_path = tmp_path / "workspace" / "library" / "index" / "cards.json"
if cards_index_path.exists():
cards_index = json.loads(cards_index_path.read_text(encoding="utf-8"))
assert "PAPER0001" not in cards_index
else:
# File not existing means no cards were generated, which is expected
pass
def test_cards_generate_creates_cards_for_pending_items(tmp_path: Path) -> None:
"""cards/generate endpoint should generate cards for items that have pending status."""
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")
import_state = _route(app, "/api/projects/{project_id}/import-state", "GET")
generate_cards = _route(app, "/api/projects/{project_id}/cards/generate", "POST")
create_project(
CreateProjectRequest(
project_id="thesis-ch2",
name="Thesis Chapter 2",
llm_provider="openai",
llm_model="gpt-5-mini",
)
)
# Import item keys first (writes to items.json but doesn't generate cards)
import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
# Verify card_status is pending before generation
state_before = import_state("thesis-ch2")
assert state_before["items"][0]["card_status"] == "pending"
# Call generate endpoint
response = generate_cards("thesis-ch2", GenerateCardsRequest(item_keys=["PAPER0001"]))
# Verify response structure
assert response["project_id"] == "thesis-ch2"
assert response["generated"] == ["PAPER0001"]
assert response["failed"] == []
assert len(response["items"]) == 1
assert response["items"][0]["item_key"] == "PAPER0001"
assert response["items"][0]["card_status"] == "done"
def test_cards_generate_allows_regeneration_for_done_items(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")
generate_cards = _route(app, "/api/projects/{project_id}/cards/generate", "POST")
create_project(
CreateProjectRequest(
project_id="thesis-ch2",
name="Thesis Chapter 2",
llm_provider="openai",
llm_model="gpt-5-mini",
)
)
import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
first_response = generate_cards("thesis-ch2", GenerateCardsRequest(item_keys=["PAPER0001"]))
second_response = generate_cards("thesis-ch2", GenerateCardsRequest(item_keys=["PAPER0001"]))
assert first_response["generated"] == ["PAPER0001"]
assert second_response["generated"] == ["PAPER0001"]
assert second_response["failed"] == []
assert second_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"

98
tests/test_cards.py Normal file
View File

@ -0,0 +1,98 @@
import json
from pathlib import Path
from zotero_kb.cards import CardBuilder
from zotero_kb.zotero_reader import ZoteroItemRecord
class FakeLlmClient:
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
language = str(source_bundle.get("card_language", "en"))
return {
"summary": "中文卡片摘要。" if language == "zh" else "Merged metadata and attachment text improve drafting support.",
"core_claims": [
"中文卡片 claim。" if language == "zh" else "Project-scoped cards reduce irrelevant retrieval.",
],
"methods": [
"Combines notes, metadata, and full text.",
],
"evidence": [
"The merged pipeline improved citation precision.",
],
"citations": [
{
"claim": "Project-scoped cards reduce irrelevant retrieval.",
"quote": "Project-scoped card retrieval improves citation precision during drafting.",
"paraphrase": "Scoped retrieval improved citation precision during drafting.",
"quote_source": "attachment_texts",
"use_case": "direct_quote",
}
],
"quotable_passages": [
"Project-scoped card retrieval improves citation precision during drafting.",
],
"writing_hints": [
"Use when arguing for scoped retrieval during drafting.",
],
"keywords": ["retrieval", "writing"],
}
def test_build_card_writes_markdown_and_indexes(tmp_path: Path) -> None:
item = ZoteroItemRecord(
item_key="PAPER0001",
title="Card Pipelines for Research Writing",
creators=["Alice Smith", "Bob Li"],
year="2024",
item_type="journalArticle",
abstract="Merged metadata and notes improve drafting support.",
tags=["llm", "writing"],
collection_paths=[["Theory", "Drafting"]],
notes=["Merged notes matter."],
attachments=[
{
"path": str(tmp_path / "zotero" / "storage" / "ATTACH01" / "paper.txt"),
"filename": "paper.txt",
"content_type": "text/plain",
"is_pdf": False,
}
],
attachment_texts=["This paper studies card pipelines for research writing."],
)
builder = CardBuilder(workspace_dir=tmp_path, llm_client=FakeLlmClient())
result = builder.build_or_update(item, "en")
expected_path = (
tmp_path
/ "library"
/ "collections"
/ "Theory"
/ "Drafting"
/ "Card Pipelines for Research Writing [PAPER0001][en].md"
)
assert result.card_path == expected_path
card_text = result.card_path.read_text(encoding="utf-8")
assert card_text.startswith("---")
assert "# Citations" in card_text
assert "quote_source: attachment_texts" in card_text
assert "locator:" not in card_text
cards_index = json.loads((tmp_path / "library" / "index" / "cards.json").read_text(encoding="utf-8"))
assert cards_index["PAPER0001"]["en"]["title"] == "Card Pipelines for Research Writing"
assert cards_index["PAPER0001"]["en"]["language"] == "en"
assert cards_index["PAPER0001"]["en"]["citations"][0]["use_case"] == "direct_quote"
assert "locator" not in cards_index["PAPER0001"]["en"]["citations"][0]
assert cards_index["PAPER0001"]["en"]["attachments"][0]["filename"] == "paper.txt"
assert cards_index["PAPER0001"]["en"]["attachments"][0]["is_pdf"] is False
items_index = json.loads((tmp_path / "library" / "index" / "items.json").read_text(encoding="utf-8"))
assert items_index["PAPER0001"]["card_path"] == str(expected_path)
assert items_index["PAPER0001"]["card_paths"]["en"] == str(expected_path)
source_bundle = json.loads(
(tmp_path / "library" / "cache" / "source-bundles" / "PAPER0001.en.json").read_text(encoding="utf-8")
)
assert source_bundle["item_key"] == "PAPER0001"
assert source_bundle["card_language"] == "en"
assert source_bundle["attachments"][0]["filename"] == "paper.txt"

11
tests/test_config.py Normal file
View File

@ -0,0 +1,11 @@
from zotero_kb.config import AppConfig
def test_from_env_normalizes_bridge_directory_path(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("ZOTERO_KB_WORKSPACE", str(tmp_path / "workspace"))
monkeypatch.setenv("ZOTERO_DATA_DIR", str(tmp_path / "zotero"))
monkeypatch.setenv("ZOTERO_BRIDGE_FILE", "workspace/bridge/")
config = AppConfig.from_env()
assert config.bridge_file == config.workspace_dir / "bridge" / "selected-items.json"

92
tests/test_llm.py Normal file
View File

@ -0,0 +1,92 @@
import json
import pytest
from zotero_kb.llm import DeepSeekCardGenerationClient, create_card_generation_client
def test_create_card_generation_client_requires_deepseek_api_key() -> None:
with pytest.raises(ValueError, match="DEEPSEEK_API_KEY"):
create_card_generation_client("deepseek", "deepseek-chat", env={})
def test_deepseek_client_uses_env_api_key_and_parses_json() -> None:
captured = {}
def fake_request(url: str, payload: dict[str, object], headers: dict[str, str]) -> dict[str, object]:
captured["url"] = url
captured["payload"] = payload
captured["headers"] = headers
return {
"choices": [
{
"message": {
"content": json.dumps(
{
"summary": "DeepSeek summary",
"core_claims": ["Claim A"],
"methods": ["Method A"],
"evidence": ["Evidence A"],
"citations": [
{
"claim": "Claim A",
"quote": "Exact supporting quote.",
"paraphrase": "Paraphrased support.",
"quote_source": "attachment_texts",
"use_case": "direct_quote",
}
],
"quotable_passages": ["Quote A"],
"writing_hints": ["Hint A"],
"keywords": ["kw-a"],
}
)
}
}
]
}
client = create_card_generation_client(
"deepseek",
"deepseek-chat",
env={"DEEPSEEK_API_KEY": "sk-test"},
request_fn=fake_request,
)
result = client.generate_card(
{
"title": "Scoped Retrieval for Drafting",
"abstract": "Merged metadata improves drafting.",
"card_language": "zh",
"notes": ["Project scoping helps."],
"attachment_texts": ["Attachment evidence."],
"tags": ["retrieval"],
}
)
assert result["summary"] == "DeepSeek summary"
assert "locator" not in result["citations"][0]
assert captured["url"] == "https://api.deepseek.com/v1/chat/completions"
assert captured["payload"]["model"] == "deepseek-chat"
assert "Return 1-3 citations whenever the source bundle contains usable supporting text" in captured["payload"]["messages"][1]["content"]
assert "quote_source must identify where the quote came from" in captured["payload"]["messages"][1]["content"]
assert "Write summary, core_claims, methods, evidence, paraphrase fields, and writing_hints in Chinese." in captured["payload"]["messages"][1]["content"]
assert captured["headers"]["Authorization"] == "Bearer sk-test"
def test_deepseek_client_strips_markdown_fences() -> None:
client = DeepSeekCardGenerationClient(
api_key="sk-test",
request_fn=lambda *_args, **_kwargs: {
"choices": [
{
"message": {
"content": "```json\n{\"summary\":\"Fence summary\",\"core_claims\":[],\"methods\":[],\"evidence\":[],\"citations\":[],\"quotable_passages\":[],\"writing_hints\":[],\"keywords\":[]}\n```"
}
}
]
},
)
result = client.generate_card({"title": "Paper"})
assert result["summary"] == "Fence summary"

229
tests/test_projects.py Normal file
View File

@ -0,0 +1,229 @@
import json
from pathlib import Path
from zotero_kb.cards import CardBuilder
from zotero_kb.config import AppConfig
from zotero_kb.projects import ProjectService
from zotero_kb.workspace import Workspace
from zotero_kb.zotero_reader import ZoteroItemRecord
class FakeLlmClient:
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
title = str(source_bundle["title"])
language = str(source_bundle.get("card_language", "en"))
if "Scoped" in title:
summary = "项目范围检索能改善写作。" if language == "zh" else "Project-scoped retrieval improves drafting."
claims = ["项目范围限定能提升引文精度。"] if language == "zh" else ["Project scoping improves citation precision."]
else:
summary = "无关检索基线。" if language == "zh" else "Unrelated retrieval baseline."
claims = ["基线检索范围较宽。"] if language == "zh" else ["Baseline retrieval is broad."]
return {
"summary": summary,
"core_claims": claims,
"methods": ["Method details."],
"evidence": ["Evidence details."],
"quotable_passages": claims,
"writing_hints": [summary],
"keywords": ["retrieval"],
}
def _build_item(item_key: str, title: str) -> ZoteroItemRecord:
return ZoteroItemRecord(
item_key=item_key,
title=title,
creators=["Alice Smith"],
year="2024",
item_type="journalArticle",
abstract=title,
tags=["retrieval"],
collection_paths=[["Theory", "Drafting"]],
notes=[title],
attachments=[],
attachment_texts=[title],
)
def test_add_item_to_project_updates_selected_items_and_project_index(tmp_path: Path) -> None:
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
workspace = Workspace(config)
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "en")
builder = CardBuilder(config.workspace_dir, FakeLlmClient())
builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting"))
service = ProjectService(config.workspace_dir)
payload = service.add_items("thesis-ch2", ["PAPER0001"])
assert payload["selected_items"] == ["PAPER0001"]
assert payload["cards"][0]["item_key"] == "PAPER0001"
selected_items = json.loads(
(config.workspace_dir / "projects" / "thesis-ch2" / "selected-items.json").read_text(encoding="utf-8")
)
assert selected_items == ["PAPER0001"]
def test_remove_item_from_project_updates_selected_items(tmp_path: Path) -> None:
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
workspace = Workspace(config)
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "en")
builder = CardBuilder(config.workspace_dir, FakeLlmClient())
builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting"))
service = ProjectService(config.workspace_dir)
service.add_items("thesis-ch2", ["PAPER0001"])
payload = service.remove_item("thesis-ch2", "PAPER0001")
assert payload["selected_items"] == []
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", "zh")
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"
def test_project_view_treats_legacy_cards_as_english_only(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", "zh")
cards_index_path = config.workspace_dir / "library" / "index" / "cards.json"
cards_index_path.parent.mkdir(parents=True, exist_ok=True)
cards_index_path.write_text(
json.dumps(
{
"PAPER0001": {
"item_key": "PAPER0001",
"title": "Legacy Card",
"summary": "English legacy summary",
"claims": ["Legacy claim"],
"citations": [],
"quotable_spans": [],
"writing_hints": [],
"attachments": [],
}
},
ensure_ascii=False,
),
encoding="utf-8",
)
items_index_path = config.workspace_dir / "library" / "index" / "items.json"
items_index_path.write_text(
json.dumps(
{
"PAPER0001": {
"item_key": "PAPER0001",
"title": "Legacy Card",
"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["items"][0]["card_status"] == "pending"
workspace.rename_project("thesis-ch2", "Thesis Chapter 2", "en")
english_view = service.get_project_view("thesis-ch2")
assert english_view["items"][0]["card_status"] == "done"
assert english_view["items"][0]["summary"] == "English legacy summary"
def test_project_view_reads_only_current_language_card_variant(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", "zh")
cards_index_path = config.workspace_dir / "library" / "index" / "cards.json"
cards_index_path.parent.mkdir(parents=True, exist_ok=True)
cards_index_path.write_text(
json.dumps(
{
"PAPER0001": {
"zh": {
"item_key": "PAPER0001",
"language": "zh",
"title": "Bilingual Card",
"summary": "中文摘要",
"claims": ["中文 claim"],
"citations": [],
"quotable_spans": [],
"writing_hints": [],
"attachments": [],
},
"en": {
"item_key": "PAPER0001",
"language": "en",
"title": "Bilingual Card",
"summary": "English summary",
"claims": ["English claim"],
"citations": [],
"quotable_spans": [],
"writing_hints": [],
"attachments": [],
},
}
},
ensure_ascii=False,
),
encoding="utf-8",
)
items_index_path = config.workspace_dir / "library" / "index" / "items.json"
items_index_path.write_text(
json.dumps(
{
"PAPER0001": {
"item_key": "PAPER0001",
"title": "Bilingual Card",
"creators": ["Alice Smith"],
"year": "2024",
"item_type": "journalArticle",
}
},
ensure_ascii=False,
),
encoding="utf-8",
)
service = ProjectService(config.workspace_dir)
zh_view = service.add_items("thesis-ch2", ["PAPER0001"])
assert zh_view["items"][0]["summary"] == "中文摘要"
assert zh_view["cards"][0]["language"] == "zh"
workspace.rename_project("thesis-ch2", "Thesis Chapter 2", "en")
en_view = service.get_project_view("thesis-ch2")
assert en_view["items"][0]["summary"] == "English summary"
assert en_view["cards"][0]["language"] == "en"

View File

@ -0,0 +1,10 @@
from pathlib import Path
def test_skill_files_exist() -> None:
assert Path("skills/zotero-citation-recommender/SKILL.md").is_file()
assert Path("skills/zotero-citation-planner/SKILL.md").is_file()
def test_bridge_manifest_exists() -> None:
assert Path("zotero-bridge/src/manifest.json").is_file()

View File

@ -1,3 +1,5 @@
import subprocess
from zotero_kb.api import create_app from zotero_kb.api import create_app
from zotero_kb.config import AppConfig from zotero_kb.config import AppConfig
@ -24,6 +26,7 @@ def test_index_contains_base_page_forms(tmp_path) -> None:
assert 'id="create-project-form"' in html assert 'id="create-project-form"' in html
assert 'id="recommend-form"' in html assert 'id="recommend-form"' in html
assert 'id="plan-form"' in html assert 'id="plan-form"' in html
assert 'id="card-language"' in html
def test_index_contains_import_window_controls(tmp_path) -> None: def test_index_contains_import_window_controls(tmp_path) -> None:
@ -37,12 +40,14 @@ def test_index_contains_import_window_controls(tmp_path) -> None:
assert 'id="window-restore-button"' in html assert 'id="window-restore-button"' in html
assert 'id="import-window-minimized-bar"' in html assert 'id="import-window-minimized-bar"' in html
assert 'id="import-window-project-label"' in html assert 'id="import-window-project-label"' in html
assert 'id="window-collection-tree"' in html
assert 'id="window-collection-items"' in html
assert 'id="window-selected-count"' in html
assert 'id="window-clear-selection-button"' in html assert 'id="window-clear-selection-button"' in html
assert 'id="window-import-selected-items-button"' in html assert 'id="window-import-selected-items-button"' in html
assert 'id="item-preview-popover"' in html assert 'id="window-collection-status"' in html
assert 'id="window-collection-tree"' in html
assert 'id="window-collection-items"' in html
assert 'class="window-toolbar-actions"' in html
assert 'class="window-action-cluster"' in html
assert 'class="window-control-cluster"' in html
assert 'isImportWindowOpen: false' in html assert 'isImportWindowOpen: false' in html
assert "function openImportWindow()" in html assert "function openImportWindow()" in html
assert "function closeImportWindow()" in html assert "function closeImportWindow()" in html
@ -65,3 +70,164 @@ def test_index_window_frame_css(tmp_path) -> None:
assert "restoreWindow" in html assert "restoreWindow" in html
assert "initWindowFromSession" in html assert "initWindowFromSession" in html
assert "persistWindowSessionState" in html assert "persistWindowSessionState" in html
def test_index_import_window_has_collection_import_layout(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert 'class="window-pane collections"' in html
assert 'class="window-pane items"' in html
assert 'id="window-collection-status"' in html
assert 'id="window-collection-tree"' in html
assert 'id="window-collection-items"' in html
assert ".window-pane.collections" in html
assert ".window-pane.items" in html
assert ".tree-list" in html
assert ".collection-items-list" in html
def test_index_has_collection_import_api_calls(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert "function loadCollectionTree()" in html
assert "function loadCollectionItems(collectionKey)" in html
assert "/api/zotero/collections/tree" in html
assert "/api/zotero/collections/${collectionKey}/items" in html
assert "/api/projects/" in html
assert "imports/item-keys" in html
assert "function importSelectedItems()" in html
# openImportWindow calls loadCollectionTree
assert "openImportWindow()" in html
assert "await loadCollectionTree()" in html
def test_index_has_responsive_import_window_geometry_helpers(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert "function computeResponsiveWindowRect()" in html
assert "function applyResponsiveWindowRect()" in html
assert "function syncWindowToViewport()" in html
assert 'window.addEventListener("resize"' in html
def test_index_has_responsive_import_window_layout_rules(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert "@media (max-width: 980px)" in html
assert ".window-pane.items" in html
assert ".window-body {" in html
assert "grid-template-columns: 1fr;" in html
assert "border-bottom: 1px solid var(--line);" in html
def test_index_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 "function renderProjectItemCitations(citations)" in html
assert "function renderProjectItemAttachments(attachments)" in html
assert ".project-item-detail" in html
assert "<h4>Citations</h4>" in html
assert "<h4>Source Files</h4>" 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
assert "card_language" in html
assert "中文" in html
assert "English" 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 canGenerateProjectItemInBatch(status)" in html
assert "function getProjectItemGenerateButtonLabel(status)" 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
assert "重新生成" in html
def test_index_marks_already_imported_collection_items_as_checked_and_disabled(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert "function isItemInCurrentProject(itemKey)" in html
assert "function getCurrentProjectItemKeySet()" in html
assert "已在项目中" in html
assert '? "checked disabled" :' in html
def test_index_inline_script_is_valid_javascript(tmp_path) -> None:
html = _get_index_html(tmp_path)
script_start = html.rfind("<script>")
script_end = html.rfind("</script>")
assert script_start != -1
assert script_end != -1
script_path = tmp_path / "index-inline.js"
script_path.write_text(html[script_start + len("<script>") : script_end], encoding="utf-8")
result = subprocess.run(
["node", "--check", str(script_path)],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr

82
tests/test_workspace.py Normal file
View File

@ -0,0 +1,82 @@
from pathlib import Path
from zotero_kb.config import AppConfig
from zotero_kb.workspace import Workspace
def test_workspace_initialization_creates_required_directories(tmp_path: Path) -> None:
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
workspace = Workspace(config)
workspace.ensure_layout()
assert (config.workspace_dir / "library" / "collections").is_dir()
assert (config.workspace_dir / "library" / "index").is_dir()
assert (config.workspace_dir / "library" / "cache" / "source-bundles").is_dir()
assert (config.workspace_dir / "projects").is_dir()
def test_create_project_writes_project_files(tmp_path: Path) -> None:
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
workspace = Workspace(config)
workspace.ensure_layout()
project = workspace.create_project(
project_id="thesis-ch2",
name="Thesis Chapter 2",
llm_provider="openai",
llm_model="gpt-5-mini",
card_language="zh",
)
assert project.project_id == "thesis-ch2"
assert (config.workspace_dir / "projects" / "thesis-ch2" / "project.json").is_file()
assert (config.workspace_dir / "projects" / "thesis-ch2" / "selected-items.json").is_file()
assert (config.workspace_dir / "projects" / "thesis-ch2" / "project-index.json").is_file()
assert '"card_language": "zh"' in (
config.workspace_dir / "projects" / "thesis-ch2" / "project.json"
).read_text(encoding="utf-8")
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",
card_language="en",
)
project = workspace.rename_project("thesis-ch2", "New Name", "zh")
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")
assert '"card_language": "zh"' 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",
card_language="en",
)
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()

80
tests/test_writing.py Normal file
View File

@ -0,0 +1,80 @@
from pathlib import Path
from zotero_kb.cards import CardBuilder
from zotero_kb.config import AppConfig
from zotero_kb.projects import ProjectService
from zotero_kb.workspace import Workspace
from zotero_kb.writing import WritingService
from zotero_kb.zotero_reader import ZoteroItemRecord
class FakeLlmClient:
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
title = str(source_bundle["title"])
if "Scoped" in title:
summary = "Project-scoped retrieval improves drafting support."
claims = ["Project scoping improves citation precision."]
hints = ["Use for scoped retrieval arguments."]
else:
summary = "Broad retrieval baseline for comparison."
claims = ["Baseline retrieval may drift."]
hints = ["Use as a contrast only."]
return {
"summary": summary,
"core_claims": claims,
"methods": ["Method details."],
"evidence": ["Evidence details."],
"quotable_passages": claims,
"writing_hints": hints,
"keywords": ["retrieval", "writing"],
}
def _build_item(item_key: str, title: str) -> ZoteroItemRecord:
return ZoteroItemRecord(
item_key=item_key,
title=title,
creators=["Alice Smith"],
year="2024",
item_type="journalArticle",
abstract=title,
tags=["retrieval"],
collection_paths=[["Theory", "Drafting"]],
notes=[title],
attachments=[],
attachment_texts=[title],
)
def test_recommend_citations_only_reads_project_items(tmp_path: Path) -> None:
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
workspace = Workspace(config)
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "en")
builder = CardBuilder(config.workspace_dir, FakeLlmClient())
builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting"))
builder.build_or_update(_build_item("PAPER0002", "Broad Retrieval Baseline"))
project_service = ProjectService(config.workspace_dir)
project_service.add_items("thesis-ch2", ["PAPER0001"])
service = WritingService(config.workspace_dir)
result = service.recommend_citations("thesis-ch2", "support scoped retrieval during drafting")
assert [item["item_key"] for item in result["results"]] == ["PAPER0001"]
def test_generate_plan_returns_structured_sections(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", "en")
builder = CardBuilder(config.workspace_dir, FakeLlmClient())
builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting"))
project_service = ProjectService(config.workspace_dir)
project_service.add_items("thesis-ch2", ["PAPER0001"])
service = WritingService(config.workspace_dir)
plan = service.generate_plan("thesis-ch2", "argue that project scoping improves drafting")
assert "sections" in plan
assert plan["sections"][0]["citations"][0]["item_key"] == "PAPER0001"

View File

@ -0,0 +1,82 @@
from pathlib import Path
from tests.fixtures.build_zotero_fixture import build_fixture_zotero_dir
from zotero_kb.bridge import read_selected_keys
from zotero_kb.zotero_reader import ZoteroReader
def test_read_selected_items_from_fixture(tmp_path: Path) -> None:
fixture_dir = tmp_path / "zotero"
fixture_dir.mkdir()
build_fixture_zotero_dir(fixture_dir)
reader = ZoteroReader(fixture_dir)
items = reader.read_items(["PAPER0001"])
assert len(items) == 1
item = items[0]
assert item.item_key == "PAPER0001"
assert item.title == "Card Pipelines for Research Writing"
assert item.tags == ["llm", "writing"]
assert item.collection_paths == [["Theory", "Drafting"]]
assert item.notes == ["Merged notes matter."]
assert item.attachment_texts[0].startswith("This paper studies")
assert item.attachments == [
{
"path": str(fixture_dir / "storage" / "ATTACH01" / "paper.txt"),
"filename": "paper.txt",
"content_type": "text/plain",
"is_pdf": False,
}
]
def test_read_selected_keys_from_bridge_snapshot(tmp_path: Path) -> None:
bridge_file = tmp_path / "selected-items.json"
bridge_file.write_text('{"selected_keys": ["PAPER0001", "PAPER0002"]}', encoding="utf-8")
assert read_selected_keys(bridge_file) == ["PAPER0001", "PAPER0002"]
def test_search_items_returns_matching_library_records(tmp_path: Path) -> None:
fixture_dir = tmp_path / "zotero"
fixture_dir.mkdir()
build_fixture_zotero_dir(fixture_dir)
reader = ZoteroReader(fixture_dir)
results = reader.search_items("research", limit=5)
assert len(results) == 1
assert results[0]["item_key"] == "PAPER0001"
assert results[0]["title"] == "Card Pipelines for Research Writing"
def test_build_collection_tree_returns_descendant_counts(tmp_path: Path) -> None:
fixture_dir = tmp_path / "zotero"
fixture_dir.mkdir()
build_fixture_zotero_dir(fixture_dir)
reader = ZoteroReader(fixture_dir)
tree = reader.get_collection_tree()
assert len(tree) == 1
root = tree[0]
assert root["collection_key"] == "COLL0001"
assert root["name"] == "Theory"
assert root["direct_item_count"] == 0
assert root["descendant_item_count"] == 1
assert root["children"][0]["collection_key"] == "COLL0002"
assert root["children"][0]["direct_item_count"] == 1
def test_get_collection_items_includes_descendants(tmp_path: Path) -> None:
fixture_dir = tmp_path / "zotero"
fixture_dir.mkdir()
build_fixture_zotero_dir(fixture_dir)
reader = ZoteroReader(fixture_dir)
items = reader.get_collection_items("COLL0001", include_descendants=True)
assert [item["item_key"] for item in items] == ["PAPER0001"]
assert items[0]["collection_paths"] == [["Theory", "Drafting"]]
assert items[0]["title"] == "Card Pipelines for Research Writing"

561
uv.lock generated Normal file
View File

@ -0,0 +1,561 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "click"
version = "8.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "fastapi"
version = "0.135.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.27.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189, upload-time = "2024-08-27T12:54:01.334Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395, upload-time = "2024-08-27T12:53:59.653Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" },
{ url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" },
{ url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" },
{ url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" },
{ url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" },
{ url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" },
{ url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" },
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
{ url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
{ url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
{ url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
{ url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
{ url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
{ url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
{ url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "packaging"
version = "26.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/84/6b/69fd5c7194b21ebde0f8637e2a4ddc766ada29d472bfa6a5ca533d79549a/pydantic-2.13.0.tar.gz", hash = "sha256:b89b575b6e670ebf6e7448c01b41b244f471edd276cd0b6fe02e7e7aca320070", size = 843468, upload-time = "2026-04-13T10:51:35.571Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/d7/c3a52c61f5b7be648e919005820fbac33028c6149994cd64453f49951c17/pydantic-2.13.0-py3-none-any.whl", hash = "sha256:ab0078b90da5f3e2fd2e71e3d9b457ddcb35d0350854fbda93b451e28d56baaf", size = 471872, upload-time = "2026-04-13T10:51:33.343Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6f/0a/9414cddf82eda3976b14048cc0fa8f5b5d1aecb0b22e1dcd2dbfe0e139b1/pydantic_core-2.46.0.tar.gz", hash = "sha256:82d2498c96be47b47e903e1378d1d0f770097ec56ea953322f39936a7cf34977", size = 471441, upload-time = "2026-04-13T09:06:33.813Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/17/fd3ba2f035ac7b3a1ae0c55e5c0f6eb5275e87ad80a9b277cb2e70317e2c/pydantic_core-2.46.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d449eae37d6b066d8a8be0e3a7d7041712d6e9152869e7d03c203795aae44ed", size = 2122942, upload-time = "2026-04-13T09:04:32.413Z" },
{ url = "https://files.pythonhosted.org/packages/01/b5/214cb10e4050f430f383a21496087c1e51d583eec3c884b0e5f55c34eb69/pydantic_core-2.46.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4f7bfc1ffee4ddc03c2db472c7607a238dbbf76f7f64104fc6a623d47fb8e310", size = 1949068, upload-time = "2026-04-13T09:05:28.803Z" },
{ url = "https://files.pythonhosted.org/packages/b4/ab/8ab4ec2a879eead4bb51c3e9af65583e16cc504867e808909cd4f991a5ae/pydantic_core-2.46.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a30f5d1d4e1c958b44b5c777a0d1adcd930429f35101e4780281ffbe11103925", size = 1974362, upload-time = "2026-04-13T09:05:26.894Z" },
{ url = "https://files.pythonhosted.org/packages/8f/dd/dc8ef47e18ddcab169af68b3c11648e1ef85c56aa18e2f96312cc5442404/pydantic_core-2.46.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f68e12d2de32ac6313a7d3854f346d71731288184fbbfc9004e368714244d2cd", size = 2043754, upload-time = "2026-04-13T09:04:54.637Z" },
{ url = "https://files.pythonhosted.org/packages/7f/52/69195c8f6549d2b1b9ce0efbb9bf169b47dcb9a60f81ff53a67cb22d8fc7/pydantic_core-2.46.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d1a058fb5aff8a1a221e7d8a0cf5b0133d069b2f293cb05f174c61bc7cdac34", size = 2230099, upload-time = "2026-04-13T09:04:44.37Z" },
{ url = "https://files.pythonhosted.org/packages/2a/41/48c8e7709604a4230f86f77bc17e1eb575e0894831f2c3beaecb3e8f7583/pydantic_core-2.46.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbd01128431f355e309267283e37e23704f24558e9059d930e213a377b1be919", size = 2293730, upload-time = "2026-04-13T09:04:27.583Z" },
{ url = "https://files.pythonhosted.org/packages/08/ab/f3bc576d37eb3036f7b1b2721ab0f89e4684fab48e1de1d0eca0dfef7469/pydantic_core-2.46.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7747a50d9f75fe264b9e2091a2f462a7dd400add8723a87a75240106b6f4d949", size = 2095380, upload-time = "2026-04-13T09:04:45.929Z" },
{ url = "https://files.pythonhosted.org/packages/fe/69/0f6e5bd9c5594b41deb91029ad0b16ffe5a270dd412033dd1135a40bbfa3/pydantic_core-2.46.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:1d9b841e9c82a9cdf397a720bb8a4f2d6da6780204e1eb07c2d90c4b5b791b0d", size = 2140115, upload-time = "2026-04-13T09:07:00.944Z" },
{ url = "https://files.pythonhosted.org/packages/28/7c/79cfc18d352797b84a7c5b27171d6557121843729bc637a90550d08370fd/pydantic_core-2.46.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:61d0f5951b7b86ec24e24fe0c5a2cce7c360830026dfbe004954e8fac9918b95", size = 2183044, upload-time = "2026-04-13T09:03:58.106Z" },
{ url = "https://files.pythonhosted.org/packages/59/bc/701b17bf7fd375e59e03838cffe8f6893498503b7d412d577ffd92dab56c/pydantic_core-2.46.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:aec0be48d2555ceac04905ffb8f2bb7e55a56644858891196191827b6fc656b7", size = 2185277, upload-time = "2026-04-13T09:05:52.482Z" },
{ url = "https://files.pythonhosted.org/packages/c3/43/ad927b8861ab787b4189ddb2dd70ebcdc20c5a4baf52df94934d6f87d730/pydantic_core-2.46.0-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:2c1ec2ced44a8a479d71a14f5be35461360acd388987873a8e0a02f7f81c8ec2", size = 2329998, upload-time = "2026-04-13T09:05:54.803Z" },
{ url = "https://files.pythonhosted.org/packages/47/33/ad11d56b97ea986f991da998d551a7513d19c06ed05a529e86520430e10e/pydantic_core-2.46.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5e157a25eed281f5e40119078e3dbf698c28b3d88ff0176eea3dd37191447b8d", size = 2369004, upload-time = "2026-04-13T09:05:14.052Z" },
{ url = "https://files.pythonhosted.org/packages/16/d1/a9a28a122f1227dc13fdd361d77a3f2df4aee64e4ac5693d7ce74a8ecfa4/pydantic_core-2.46.0-cp310-cp310-win32.whl", hash = "sha256:311929d9bfdb9fdbaf28beb39d88a1e36ca6dc5424ceca6d3bf81c9e1da2313c", size = 1982879, upload-time = "2026-04-13T09:05:19.277Z" },
{ url = "https://files.pythonhosted.org/packages/94/9a/52988a743cf7a9d84861e380c6a5496589aebbc3592d9ecdecb13c6bd0a2/pydantic_core-2.46.0-cp310-cp310-win_amd64.whl", hash = "sha256:60edfb53b13fbe7be9bb51447016b7bcd8772beb8ca216873be33e9d11b2c8e8", size = 2068907, upload-time = "2026-04-13T09:03:59.541Z" },
{ url = "https://files.pythonhosted.org/packages/ce/43/9bc38d43a6a48794209e4eb6d61e9c68395f69b7949f66842854b0cd1344/pydantic_core-2.46.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0027da787ae711f7fbd5a76cb0bb8df526acba6c10c1e44581de1b838db10b7b", size = 2121004, upload-time = "2026-04-13T09:05:17.531Z" },
{ url = "https://files.pythonhosted.org/packages/8c/1d/f43342b7107939b305b5e4efeef7d54e267a5ef51515570a5c1d77726efb/pydantic_core-2.46.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:63e288fc18d7eaeef5f16c73e65c4fd0ad95b25e7e21d8a5da144977b35eb997", size = 1947505, upload-time = "2026-04-13T09:04:48.975Z" },
{ url = "https://files.pythonhosted.org/packages/4a/cd/ccf48cbbcaf0d99ba65969459ebfbf7037600b2cfdcca3062084dd83a008/pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:080a3bdc6807089a1fe1fbc076519cea287f1a964725731d80b49d8ecffaa217", size = 1973301, upload-time = "2026-04-13T09:05:42.149Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ff/a7bb1e7a762fb1f40ad5ef4e6a92c012864a017b7b1fdfb71cf91faa8b73/pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c065f1c3e54c3e79d909927a8cb48ccbc17b68733552161eba3e0628c38e5d19", size = 2042208, upload-time = "2026-04-13T09:05:32.591Z" },
{ url = "https://files.pythonhosted.org/packages/ea/64/d3f11c6f6ace71526f3b03646df95eaab3f21edd13e00daae3f20f4e5a09/pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7e2db58ab46cfe602d4255381cce515585998c3b6699d5b1f909f519bc44a5aa", size = 2229046, upload-time = "2026-04-13T09:04:18.59Z" },
{ url = "https://files.pythonhosted.org/packages/d0/64/93db9a63cce71630c58b376d63de498aa93cb341c72cd5f189b5c08f5c28/pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c660974890ec1e4c65cff93f5670a5f451039f65463e9f9c03ad49746b49fc78", size = 2292138, upload-time = "2026-04-13T09:04:13.816Z" },
{ url = "https://files.pythonhosted.org/packages/e9/96/936fccce22f1f2ae8b2b694de651c2c929847be5f701c927a0bb3b1eb679/pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3be91482a8db77377c902cca87697388a4fb68addeb3e943ac74f425201a099", size = 2093333, upload-time = "2026-04-13T09:05:15.729Z" },
{ url = "https://files.pythonhosted.org/packages/75/76/c325e7fda69d589e26e772272044fe704c7e525c47d0d32a74f8345ac657/pydantic_core-2.46.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:1c72de82115233112d70d07f26a48cf6996eb86f7e143423ec1a182148455a9d", size = 2138802, upload-time = "2026-04-13T09:03:51.142Z" },
{ url = "https://files.pythonhosted.org/packages/c0/6f/ccaa2ff7d53a017b66841e2d38edd1f38d19ae1a2d0c5efee17f2d432229/pydantic_core-2.46.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7904e58768cd79304b992868d7710bfc85dc6c7ed6163f0f68dbc1dcd72dc231", size = 2181358, upload-time = "2026-04-13T09:04:30.737Z" },
{ url = "https://files.pythonhosted.org/packages/6c/71/0c4b6303e92d63edcb81f5301695cdf70bb351775b4733eea65acdac8384/pydantic_core-2.46.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1af8d88718005f57bb4768f92f4ff16bf31a747d39dfc919b22211b84e72c053", size = 2183985, upload-time = "2026-04-13T09:04:06.792Z" },
{ url = "https://files.pythonhosted.org/packages/71/eb/f6bf255de38a4393aaa10bff224e882b630576bc26ebfb401e42bb965092/pydantic_core-2.46.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:a5b891301b02770a5852253f4b97f8bd192e5710067bc129e20d43db5403ede2", size = 2328559, upload-time = "2026-04-13T09:06:14.143Z" },
{ url = "https://files.pythonhosted.org/packages/f2/71/93895a1545f50823a24b21d7761c2bd1b1afea7a6ddc019787caec237361/pydantic_core-2.46.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:48b671fe59031fd9754c7384ac05b3ed47a0cccb7d4db0ec56121f0e6a541b90", size = 2367466, upload-time = "2026-04-13T09:05:59.613Z" },
{ url = "https://files.pythonhosted.org/packages/78/39/62331b3e71f41fb13d486621e2aec49900ba56567fb3a0ae5999fded0005/pydantic_core-2.46.0-cp311-cp311-win32.whl", hash = "sha256:0a52b7262b6cc67033823e9549a41bb77580ac299dc964baae4e9c182b2e335c", size = 1981367, upload-time = "2026-04-13T09:07:37.563Z" },
{ url = "https://files.pythonhosted.org/packages/9f/51/caac70958420e2d6115962f550676df59647c11f96a44c2fcb61662fcd16/pydantic_core-2.46.0-cp311-cp311-win_amd64.whl", hash = "sha256:4103fea1beeef6b3a9fed8515f27d4fa30c929a1973655adf8f454dc49ee0662", size = 2065942, upload-time = "2026-04-13T09:06:37.873Z" },
{ url = "https://files.pythonhosted.org/packages/b2/cf/576b2a4eb5500a1a5da485613b1ea8bc0d7279b27e0426801574b284ae65/pydantic_core-2.46.0-cp311-cp311-win_arm64.whl", hash = "sha256:3137cd88938adb8e567c5e938e486adc7e518ffc96b4ae1ec268e6a4275704d7", size = 2052532, upload-time = "2026-04-13T09:06:03.697Z" },
{ url = "https://files.pythonhosted.org/packages/a7/d2/206c72ad47071559142a35f71efc29eb16448a4a5ae9487230ab8e4e292b/pydantic_core-2.46.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:66ccedb02c934622612448489824955838a221b3a35875458970521ef17b2f9c", size = 2117060, upload-time = "2026-04-13T09:04:47.443Z" },
{ url = "https://files.pythonhosted.org/packages/17/2c/7a53b33f91c8b77e696b1a6aa3bed609bf9374bdc0f8dcda681bc7d922b8/pydantic_core-2.46.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a44f27f4d2788ef9876ec47a43739b118c5904d74f418f53398f6ced3bbcacf2", size = 1951802, upload-time = "2026-04-13T09:05:34.591Z" },
{ url = "https://files.pythonhosted.org/packages/fc/20/90e548c1f6d38800ef11c915881525770ce270d8e5e887563ff046a08674/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26a1032bcce6ca4b4670eb3f7d8195bd0a8b8f255f1307823e217ca3cfa7c27", size = 1976621, upload-time = "2026-04-13T09:04:03.909Z" },
{ url = "https://files.pythonhosted.org/packages/20/3c/9c5810ca70b60c623488cdd80f7e9ee1a0812df81e97098b64788719860f/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b8d1412f725060527e56675904b17a2d421dddcf861eecf7c75b9dda47921a4", size = 2056721, upload-time = "2026-04-13T09:04:40.992Z" },
{ url = "https://files.pythonhosted.org/packages/1a/a3/d6e5f4cdec84278431c75540f90838c9d0a4dfe9402a8f3902073660ff28/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc3d1569edd859cabaa476cabce9eecd05049a7966af7b4a33b541bfd4ca1104", size = 2239634, upload-time = "2026-04-13T09:03:52.478Z" },
{ url = "https://files.pythonhosted.org/packages/46/42/ef58aacf330d8de6e309d62469aa1f80e945eaf665929b4037ac1bfcebc1/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:38108976f2d8afaa8f5067fd1390a8c9f5cc580175407cda636e76bc76e88054", size = 2315739, upload-time = "2026-04-13T09:05:04.971Z" },
{ url = "https://files.pythonhosted.org/packages/8b/86/c63b12fafa2d86a515bfd1840b39c23a49302f02b653161bf9c3a0566c50/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5a06d8ed01dad5575056b5187e5959b336793c6047920a3441ee5b03533836", size = 2098169, upload-time = "2026-04-13T09:07:27.151Z" },
{ url = "https://files.pythonhosted.org/packages/76/19/b5b33a2f6be4755b21a20434293c4364be255f4c1a108f125d101d4cc4ee/pydantic_core-2.46.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:04017ace142da9ce27cafd423a480872571b5c7e80382aec22f7d715ca8eb870", size = 2170830, upload-time = "2026-04-13T09:04:39.448Z" },
{ url = "https://files.pythonhosted.org/packages/99/ae/7559f99a29b7d440012ddb4da897359304988a881efaca912fd2f655652e/pydantic_core-2.46.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2629ad992ed1b1c012e6067f5ffafd3336fcb9b54569449fabb85621f1444ed3", size = 2203901, upload-time = "2026-04-13T09:04:01.048Z" },
{ url = "https://files.pythonhosted.org/packages/dd/0e/b0ef945a39aeb4ac58da316813e1106b7fbdfbf20ac141c1c27904355ac5/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3068b1e7bd986aebc88f6859f8353e72072538dcf92a7fb9cf511a0f61c5e729", size = 2191789, upload-time = "2026-04-13T09:06:39.915Z" },
{ url = "https://files.pythonhosted.org/packages/90/f4/830484e07188c1236b013995818888ab93bab8fd88aa9689b1d8fd22220d/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:1e366916ff69ff700aa9326601634e688581bc24c5b6b4f8738d809ec7d72611", size = 2344423, upload-time = "2026-04-13T09:05:12.252Z" },
{ url = "https://files.pythonhosted.org/packages/fd/ba/e455c18cbdc333177af754e740be4fe9d1de173d65bbe534daf88da02ac0/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:485a23e8f4618a1b8e23ac744180acde283fffe617f96923d25507d5cade62ec", size = 2384037, upload-time = "2026-04-13T09:06:24.503Z" },
{ url = "https://files.pythonhosted.org/packages/78/1f/b35d20d73144a41e78de0ae398e60fdd8bed91667daa1a5a92ab958551ba/pydantic_core-2.46.0-cp312-cp312-win32.whl", hash = "sha256:520940e1b702fe3b33525d0351777f25e9924f1818ca7956447dabacf2d339fd", size = 1967068, upload-time = "2026-04-13T09:05:23.374Z" },
{ url = "https://files.pythonhosted.org/packages/d1/84/4b6252e9606e8295647b848233cc4137ee0a04ebba8f0f9fb2977655b38c/pydantic_core-2.46.0-cp312-cp312-win_amd64.whl", hash = "sha256:90d2048e0339fa365e5a66aefe760ddd3b3d0a45501e088bc5bc7f4ed9ff9571", size = 2071008, upload-time = "2026-04-13T09:05:21.392Z" },
{ url = "https://files.pythonhosted.org/packages/39/95/d08eb508d4d5560ccbd226ee5971e5ef9b749aba9b413c0c4ed6e406d4f6/pydantic_core-2.46.0-cp312-cp312-win_arm64.whl", hash = "sha256:a70247649b7dffe36648e8f34be5ce8c5fa0a27ff07b071ea780c20a738c05ce", size = 2036634, upload-time = "2026-04-13T09:05:48.299Z" },
{ url = "https://files.pythonhosted.org/packages/df/05/ab3b0742bad1d51822f1af0c4232208408902bdcfc47601f3b812e09e6c2/pydantic_core-2.46.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a05900c37264c070c683c650cbca8f83d7cbb549719e645fcd81a24592eac788", size = 2116814, upload-time = "2026-04-13T09:04:12.41Z" },
{ url = "https://files.pythonhosted.org/packages/98/08/30b43d9569d69094a0899a199711c43aa58fce6ce80f6a8f7693673eb995/pydantic_core-2.46.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de8e482fd4f1e3f36c50c6aac46d044462615d8f12cfafc6bebeaa0909eea22", size = 1951867, upload-time = "2026-04-13T09:04:02.364Z" },
{ url = "https://files.pythonhosted.org/packages/db/a0/bf9a1ba34537c2ed3872a48195291138fdec8fe26c4009776f00d63cf0c8/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c525ecf8a4cdf198327b65030a7d081867ad8e60acb01a7214fff95cf9832d47", size = 1977040, upload-time = "2026-04-13T09:06:16.088Z" },
{ url = "https://files.pythonhosted.org/packages/71/70/0ba03c20e1e118219fc18c5417b008b7e880f0e3fb38560ec4465984d471/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f14581aeb12e61542ce73b9bfef2bca5439d65d9ab3efe1a4d8e346b61838f9b", size = 2055284, upload-time = "2026-04-13T09:05:25.125Z" },
{ url = "https://files.pythonhosted.org/packages/58/cf/1e320acefbde7fb7158a9e5def55e0adf9a4634636098ce28dc6b978e0d3/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c108067f2f7e190d0dbd81247d789ec41f9ea50ccd9265a3a46710796ac60530", size = 2238896, upload-time = "2026-04-13T09:05:01.345Z" },
{ url = "https://files.pythonhosted.org/packages/df/f5/ea8ba209756abe9eba891bb0ef3772b4c59a894eb9ad86cd5bd0dd4e3e52/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ac10967e9a7bb1b96697374513f9a1a90a59e2fb41566b5e00ee45392beac59", size = 2314353, upload-time = "2026-04-13T09:06:07.942Z" },
{ url = "https://files.pythonhosted.org/packages/e8/f8/5885350203b72e96438eee7f94de0d8f0442f4627237ca8ef75de34db1cd/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7897078fe8a13b73623c0955dfb2b3d2c9acb7177aac25144758c9e5a5265aaa", size = 2098522, upload-time = "2026-04-13T09:04:23.239Z" },
{ url = "https://files.pythonhosted.org/packages/bf/88/5930b0e828e371db5a556dd3189565417ddc3d8316bb001058168aadcf5f/pydantic_core-2.46.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:e69ce405510a419a082a78faed65bb4249cfb51232293cc675645c12f7379bf7", size = 2168757, upload-time = "2026-04-13T09:07:12.46Z" },
{ url = "https://files.pythonhosted.org/packages/da/75/63d563d3035a0548e721c38b5b69fd5626fdd51da0f09ff4467503915b82/pydantic_core-2.46.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd28d13eea0d8cf351dc1fe274b5070cc8e1cca2644381dee5f99de629e77cf3", size = 2202518, upload-time = "2026-04-13T09:05:44.418Z" },
{ url = "https://files.pythonhosted.org/packages/a7/53/1958eacbfddc41aadf5ae86dd85041bf054b675f34a2fa76385935f96070/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:ee1547a6b8243e73dd10f585555e5a263395e55ce6dea618a078570a1e889aef", size = 2190148, upload-time = "2026-04-13T09:06:56.151Z" },
{ url = "https://files.pythonhosted.org/packages/c7/17/098cc6d3595e4623186f2bc6604a6195eb182e126702a90517236391e9ce/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:c3dc68dcf62db22a18ddfc3ad4960038f72b75908edc48ae014d7ac8b391d57a", size = 2342925, upload-time = "2026-04-13T09:04:17.286Z" },
{ url = "https://files.pythonhosted.org/packages/71/a7/abdb924620b1ac535c690b36ad5b8871f376104090f8842c08625cecf1d3/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:004a2081c881abfcc6854a4623da6a09090a0d7c1398a6ae7133ca1256cee70b", size = 2383167, upload-time = "2026-04-13T09:04:52.643Z" },
{ url = "https://files.pythonhosted.org/packages/d7/c9/2ddd10f50e4b7350d2574629a0f53d8d4eb6573f9c19a6b43e6b1487a31d/pydantic_core-2.46.0-cp313-cp313-win32.whl", hash = "sha256:59d24ec8d5eaabad93097525a69d0f00f2667cb353eb6cda578b1cfff203ceef", size = 1965660, upload-time = "2026-04-13T09:06:05.877Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e7/1efc38ed6f2680c032bcefa0e3ebd496a8c77e92dfdb86b07d0f2fc632b1/pydantic_core-2.46.0-cp313-cp313-win_amd64.whl", hash = "sha256:71186dad5ac325c64d68fe0e654e15fd79802e7cc42bc6f0ff822d5ad8b1ab25", size = 2069563, upload-time = "2026-04-13T09:07:14.738Z" },
{ url = "https://files.pythonhosted.org/packages/c3/1e/a325b4989e742bf7e72ed35fa124bc611fd76539c9f8cd2a9a7854473533/pydantic_core-2.46.0-cp313-cp313-win_arm64.whl", hash = "sha256:8e4503f3213f723842c9a3b53955c88a9cfbd0b288cbd1c1ae933aebeec4a1b4", size = 2034966, upload-time = "2026-04-13T09:04:21.629Z" },
{ url = "https://files.pythonhosted.org/packages/36/3b/914891d384cdbf9a6f464eb13713baa22ea1e453d4da80fb7da522079370/pydantic_core-2.46.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4fc801c290342350ffc82d77872054a934b2e24163727263362170c1db5416ca", size = 2113349, upload-time = "2026-04-13T09:04:59.407Z" },
{ url = "https://files.pythonhosted.org/packages/35/95/3a0c6f65e231709fb3463e32943c69d10285cb50203a2130a4732053a06d/pydantic_core-2.46.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0a36f2cc88170cc177930afcc633a8c15907ea68b59ac16bd180c2999d714940", size = 1949170, upload-time = "2026-04-13T09:06:09.935Z" },
{ url = "https://files.pythonhosted.org/packages/d1/63/d845c36a608469fe7bee226edeff0984c33dbfe7aecd755b0e7ab5a275c4/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a3912e0c568a1f99d4d6d3e41def40179d61424c0ca1c8c87c4877d7f6fd7fb", size = 1977914, upload-time = "2026-04-13T09:04:56.16Z" },
{ url = "https://files.pythonhosted.org/packages/08/6f/f2e7a7f85931fb31671f5378d1c7fc70606e4b36d59b1b48e1bd1ef5d916/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3534c3415ed1a19ab23096b628916a827f7858ec8db49ad5d7d1e44dc13c0d7b", size = 2050538, upload-time = "2026-04-13T09:05:06.789Z" },
{ url = "https://files.pythonhosted.org/packages/8c/97/f4aa7181dd9a16dd9059a99fc48fdab0c2aab68307283a5c04cf56de68c4/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21067396fc285609323a4db2f63a87570044abe0acddfcca8b135fc7948e3db7", size = 2236294, upload-time = "2026-04-13T09:07:03.2Z" },
{ url = "https://files.pythonhosted.org/packages/24/c1/6a5042fc32765c87101b500f394702890af04239c318b6002cfd627b710d/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2afd85b7be186e2fe7cdbb09a3d964bcc2042f65bbcc64ad800b3c7915032655", size = 2312954, upload-time = "2026-04-13T09:06:11.919Z" },
{ url = "https://files.pythonhosted.org/packages/cb/e4/566101a561492ce8454f0844ca29c3b675a6b3a7b3ff577db85ed05c8c50/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67e2c2e171b78db8154da602de72ffdc473c6ee51de8a9d80c0f1cd4051abfc7", size = 2102533, upload-time = "2026-04-13T09:06:58.664Z" },
{ url = "https://files.pythonhosted.org/packages/3e/ac/adc11ee1646a5c4dd9abb09a00e7909e6dc25beddc0b1310ca734bb9b48e/pydantic_core-2.46.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c16ae1f3170267b1a37e16dba5c297bdf60c8b5657b147909ca8774ce7366644", size = 2169447, upload-time = "2026-04-13T09:04:11.143Z" },
{ url = "https://files.pythonhosted.org/packages/26/73/408e686b45b82d28ac19e8229e07282254dbee6a5d24c5c7cf3cf3716613/pydantic_core-2.46.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:133b69e1c1ba34d3702eed73f19f7f966928f9aa16663b55c2ebce0893cca42e", size = 2200672, upload-time = "2026-04-13T09:03:54.056Z" },
{ url = "https://files.pythonhosted.org/packages/0a/3b/807d5b035ec891b57b9079ce881f48263936c37bd0d154a056e7fd152afb/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:15ed8e5bde505133d96b41702f31f06829c46b05488211a5b1c7877e11de5eb5", size = 2188293, upload-time = "2026-04-13T09:07:07.614Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ed/719b307516285099d1196c52769fdbe676fd677da007b9c349ae70b7226d/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:8cfc29a1c66a7f0fcb36262e92f353dd0b9c4061d558fceb022e698a801cb8ae", size = 2335023, upload-time = "2026-04-13T09:04:05.176Z" },
{ url = "https://files.pythonhosted.org/packages/8d/90/8718e4ae98c4e8a7325afdc079be82be1e131d7a47cb6c098844a9531ffe/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e1155708540f13845bf68d5ac511a55c76cfe2e057ed12b4bf3adac1581fc5c2", size = 2377155, upload-time = "2026-04-13T09:06:18.081Z" },
{ url = "https://files.pythonhosted.org/packages/dd/dc/7172789283b963f81da2fc92b186e22de55687019079f71c4d570822502b/pydantic_core-2.46.0-cp314-cp314-win32.whl", hash = "sha256:de5635a48df6b2eef161d10ea1bc2626153197333662ba4cd700ee7ec1aba7f5", size = 1963078, upload-time = "2026-04-13T09:05:30.615Z" },
{ url = "https://files.pythonhosted.org/packages/e0/69/03a7ea4b6264def3a44eabf577528bcec2f49468c5698b2044dea54dc07e/pydantic_core-2.46.0-cp314-cp314-win_amd64.whl", hash = "sha256:f07a5af60c5e7cf53dd1ff734228bd72d0dc9938e64a75b5bb308ca350d9681e", size = 2068439, upload-time = "2026-04-13T09:04:57.729Z" },
{ url = "https://files.pythonhosted.org/packages/f5/eb/1c3afcfdee2ab6634b802ab0a0f1966df4c8b630028ec56a1cb0a710dc58/pydantic_core-2.46.0-cp314-cp314-win_arm64.whl", hash = "sha256:e7a77eca3c7d5108ff509db20aae6f80d47c7ed7516d8b96c387aacc42f3ce0f", size = 2026470, upload-time = "2026-04-13T09:05:08.654Z" },
{ url = "https://files.pythonhosted.org/packages/5c/30/1177dde61b200785c4739665e3aa03a9d4b2c25d2d0408b07d585e633965/pydantic_core-2.46.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5e7cdd4398bee1aaeafe049ac366b0f887451d9ae418fd8785219c13fea2f928", size = 2107447, upload-time = "2026-04-13T09:05:46.314Z" },
{ url = "https://files.pythonhosted.org/packages/b1/60/4e0f61f99bdabbbc309d364a2791e1ba31e778a4935bc43391a7bdec0744/pydantic_core-2.46.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5c2c92d82808e27cef3f7ab3ed63d657d0c755e0dbe5b8a58342e37bdf09bd2e", size = 1926927, upload-time = "2026-04-13T09:06:20.371Z" },
{ url = "https://files.pythonhosted.org/packages/1d/d0/67f89a8269152c1d6eaa81f04e75a507372ebd8ca7382855a065222caa80/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bab80af91cd7014b45d1089303b5f844a9d91d7da60eabf3d5f9694b32a6655", size = 1966613, upload-time = "2026-04-13T09:07:05.389Z" },
{ url = "https://files.pythonhosted.org/packages/cd/07/8dfdc3edc78f29a80fb31f366c50203ec904cff6a4c923599bf50ac0d0ff/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1e49ffdb714bc990f00b39d1ad1d683033875b5af15582f60c1f34ad3eeccfaa", size = 2032902, upload-time = "2026-04-13T09:06:42.47Z" },
{ url = "https://files.pythonhosted.org/packages/b0/2a/111c5e8fe24f99c46bcad7d3a82a8f6dbc738066e2c72c04c71f827d8c78/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca877240e8dbdeef3a66f751dc41e5a74893767d510c22a22fc5c0199844f0ce", size = 2244456, upload-time = "2026-04-13T09:05:36.484Z" },
{ url = "https://files.pythonhosted.org/packages/6b/7c/cfc5d11c15a63ece26e148572c77cfbb2c7f08d315a7b63ef0fe0711d753/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87e6843f89ecd2f596d7294e33196c61343186255b9880c4f1b725fde8b0e20d", size = 2294535, upload-time = "2026-04-13T09:06:01.689Z" },
{ url = "https://files.pythonhosted.org/packages/c4/2c/f0d744e3dab7bd026a3f4670a97a295157cff923a2666d30a15a70a7e3d0/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e20bc5add1dd9bc3b9a3600d40632e679376569098345500799a6ad7c5d46c72", size = 2104621, upload-time = "2026-04-13T09:04:34.388Z" },
{ url = "https://files.pythonhosted.org/packages/a7/64/e7cc4698dc024264d214b51d5a47a2404221b12060dd537d76f831b2120a/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:ee6ff79a5f0289d64a9d6696a3ce1f98f925b803dd538335a118231e26d6d827", size = 2130718, upload-time = "2026-04-13T09:04:26.23Z" },
{ url = "https://files.pythonhosted.org/packages/0b/a8/224e655fec21f7d4441438ad2ecaccb33b5a3876ce7bb2098c74a49efc14/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:52d35cfb58c26323101c7065508d7bb69bb56338cda9ea47a7b32be581af055d", size = 2180738, upload-time = "2026-04-13T09:05:50.253Z" },
{ url = "https://files.pythonhosted.org/packages/32/7b/b3025618ed4c4e4cbaa9882731c19625db6669896b621760ea95bc1125ef/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d14cc5a6f260fa78e124061eebc5769af6534fc837e9a62a47f09a2c341fa4ea", size = 2171222, upload-time = "2026-04-13T09:07:29.929Z" },
{ url = "https://files.pythonhosted.org/packages/7b/e3/68170aa1d891920af09c1f2f34df61dc5ff3a746400027155523e3400e89/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:4f7ff859d663b6635f6307a10803d07f0d09487e16c3d36b1744af51dbf948b2", size = 2320040, upload-time = "2026-04-13T09:06:35.732Z" },
{ url = "https://files.pythonhosted.org/packages/67/1b/5e65807001b84972476300c1f49aea2b4971b7e9fffb5c2654877dadd274/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8ef749be6ed0d69dba31902aaa8255a9bb269ae50c93888c4df242d8bb7acd9e", size = 2377062, upload-time = "2026-04-13T09:07:39.945Z" },
{ url = "https://files.pythonhosted.org/packages/75/03/48caa9dd5f28f7662bd52bff454d9a451f6b7e5e4af95e289e5e170749c9/pydantic_core-2.46.0-cp314-cp314t-win32.whl", hash = "sha256:d93ca72870133f86360e4bb0c78cd4e6ba2a0f9f3738a6486909ffc031463b32", size = 1951028, upload-time = "2026-04-13T09:04:20.224Z" },
{ url = "https://files.pythonhosted.org/packages/87/ed/e97ff55fe28c0e6e3cba641d622b15e071370b70e5f07c496b07b65db7c9/pydantic_core-2.46.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6ebb2668afd657e2127cb40f2ceb627dd78e74e9dfde14d9bf6cdd532a29ff59", size = 2048519, upload-time = "2026-04-13T09:05:10.464Z" },
{ url = "https://files.pythonhosted.org/packages/b6/51/e0db8267a287994546925f252e329eeae4121b1e77e76353418da5a3adf0/pydantic_core-2.46.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4864f5bbb7993845baf9209bae1669a8a76769296a018cb569ebda9dcb4241f5", size = 2026791, upload-time = "2026-04-13T09:04:37.724Z" },
{ url = "https://files.pythonhosted.org/packages/2d/f1/6731c2d6caf03efe822101edb4783eb3f212f34b7b005a34f039f67e76e1/pydantic_core-2.46.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:ce2e38e27de73ff6a0312a9e3304c398577c418d90bbde97f0ba1ee3ab7ac39f", size = 2121259, upload-time = "2026-04-13T09:07:34.845Z" },
{ url = "https://files.pythonhosted.org/packages/72/fd/ac34d4c92e739e37a040be9e7ea84d116afec5f983a7db856c27135fba77/pydantic_core-2.46.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:f0d34ba062396de0be7421e6e69c9a6821bf6dc73a0ab9959a48a5a6a1e24754", size = 1945798, upload-time = "2026-04-13T09:04:24.729Z" },
{ url = "https://files.pythonhosted.org/packages/b6/a4/f413a522c4047c46b109be6805a3095d35e5a4882fd5b4fdc0909693dfc0/pydantic_core-2.46.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4c0a12147b4026dd68789fb9f22f1a8769e457f9562783c181880848bbd6412", size = 1986062, upload-time = "2026-04-13T09:05:57.177Z" },
{ url = "https://files.pythonhosted.org/packages/91/2e/9760025ea8b0f49903c0ceebdfc2d8ef839da872426f2b03cae9de036a7c/pydantic_core-2.46.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a99896d9db56df901ab4a63cd6a36348a569cff8e05f049db35f4016a817a3d9", size = 2145344, upload-time = "2026-04-13T09:03:56.924Z" },
{ url = "https://files.pythonhosted.org/packages/74/0c/106ed5cc50393d90523f09adcc50d05e42e748eb107dc06aea971137f02d/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:bc0e2fefe384152d7da85b5c2fe8ce2bf24752f68a58e3f3ea42e28a29dfdeb2", size = 2104968, upload-time = "2026-04-13T09:06:26.967Z" },
{ url = "https://files.pythonhosted.org/packages/f5/71/b494cef3165e3413ee9bbbb5a9eedc9af0ea7b88d8638beef6c2061b110e/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a2ab0e785548be1b4362a62c4004f9217598b7ee465f1f420fc2123e2a5b5b02", size = 1940442, upload-time = "2026-04-13T09:06:29.332Z" },
{ url = "https://files.pythonhosted.org/packages/7e/3e/a4d578c8216c443e26a1124f8c1e07c0654264ce5651143d3883d85ff140/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16d45aecb18b8cba1c68eeb17c2bb2d38627ceed04c5b30b882fc9134e01f187", size = 1999672, upload-time = "2026-04-13T09:04:42.798Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c1/9114560468685525a21770138382fd0cb849aaf351ff2c7b97f760d121e0/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5078f6c377b002428e984259ac327ef8902aacae6c14b7de740dd4869a491501", size = 2154533, upload-time = "2026-04-13T09:04:50.868Z" },
{ url = "https://files.pythonhosted.org/packages/09/ed/fbd8127e4a19c4fdbb2f4983cf72c7b3534086df640c813c5c0ec4218177/pydantic_core-2.46.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:be3e04979ba4d68183f247202c7f4f483f35df57690b3f875c06340a1579b47c", size = 2119951, upload-time = "2026-04-13T09:04:35.923Z" },
{ url = "https://files.pythonhosted.org/packages/ec/77/df8711ebb45910412f90d75198430fa1120f5618336b71fa00303601c5a4/pydantic_core-2.46.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b1eae8d7d9b8c2a90b34d3d9014804dca534f7f40180197062634499412ea14e", size = 1953812, upload-time = "2026-04-13T09:05:40.293Z" },
{ url = "https://files.pythonhosted.org/packages/12/fe/14b35df69112bd812d6818a395eeab22eeaa2befc6f85bc54ed648430186/pydantic_core-2.46.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a95a2773680dd4b6b999d4eccdd1b577fd71c31739fb4849f6ada47eabb9c56", size = 2139585, upload-time = "2026-04-13T09:06:46.94Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f0/4fea4c14ebbdeb87e5f6edd2620735fcbd384865f06707fe229c021ce041/pydantic_core-2.46.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25988c3159bb097e06abfdf7b21b1fcaf90f187c74ca6c7bb842c1f72ce74fa8", size = 2179154, upload-time = "2026-04-13T09:04:15.639Z" },
{ url = "https://files.pythonhosted.org/packages/5c/36/6329aa79ba32b73560e6e453164fb29702b115fd3b2b650e796e1dc27862/pydantic_core-2.46.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:747d89bd691854c719a3381ba46b6124ef916ae85364c79e11db9c84995d8d03", size = 2182917, upload-time = "2026-04-13T09:07:24.483Z" },
{ url = "https://files.pythonhosted.org/packages/92/61/edbf7aea71052d410347846a2ea43394f74651bf6822b8fad8703ca00575/pydantic_core-2.46.0-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:909a7327b83ca93b372f7d48df0ebc7a975a5191eb0b6e024f503f4902c24124", size = 2327716, upload-time = "2026-04-13T09:06:31.681Z" },
{ url = "https://files.pythonhosted.org/packages/a4/11/aa5089b941e85294b1d5d526840b18f0d4464f842d43d8999ce50ef881c1/pydantic_core-2.46.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:2f7e6a3752378a69fadf3f5ee8bc5fa082f623703eec0f4e854b12c548322de0", size = 2365925, upload-time = "2026-04-13T09:05:38.338Z" },
{ url = "https://files.pythonhosted.org/packages/0c/75/e187b0ea247f71f2009d156df88b7d8449c52a38810c9a1bd55dd4871206/pydantic_core-2.46.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ef47ee0a3ac4c2bb25a083b3acafb171f65be4a0ac1e84edef79dd0016e25eaa", size = 2193856, upload-time = "2026-04-13T09:05:03.114Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "starlette"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.44.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" },
]
[[package]]
name = "zotero-kb"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "jinja2" },
{ name = "pydantic" },
{ name = "uvicorn" },
]
[package.optional-dependencies]
dev = [
{ name = "httpx" },
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.115,<1" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27,<0.28" },
{ name = "jinja2", specifier = ">=3.1,<4" },
{ name = "pydantic", specifier = ">=2.8,<3" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3,<9" },
{ name = "uvicorn", specifier = ">=0.30,<1" },
]
provides-extras = ["dev"]

33
zotero-bridge/src/bootstrap.js vendored Normal file
View File

@ -0,0 +1,33 @@
var bridgeConfig = {
outputPath: "/tmp/zotero-kb-selected-items.json",
};
async function install() {}
async function startup() {
Zotero.debug("Zotero KB Bridge started.");
}
async function shutdown() {}
async function uninstall() {}
async function exportSelectedItems() {
const selectedItems = Zotero.getMainWindow().ZoteroPane.getSelectedItems() || [];
const selectedKeys = selectedItems
.filter((item) => item && item.isRegularItem && item.isRegularItem())
.map((item) => item.key);
const payload = JSON.stringify({ selected_keys: selectedKeys }, null, 2);
const file = Components.classes["@mozilla.org/file/local;1"]
.createInstance(Components.interfaces.nsIFile);
file.initWithPath(bridgeConfig.outputPath);
const ostream = Components.classes["@mozilla.org/network/file-output-stream;1"]
.createInstance(Components.interfaces.nsIFileOutputStream);
ostream.init(file, 0x02 | 0x08 | 0x20, 0o644, 0);
ostream.write(payload, payload.length);
ostream.close();
Zotero.debug(`Zotero KB Bridge exported ${selectedKeys.length} item keys.`);
}

View File

@ -0,0 +1,12 @@
{
"manifest_version": 2,
"name": "Zotero KB Bridge",
"version": "0.1.0",
"description": "Export selected Zotero item keys for Zotero KB imports.",
"applications": {
"zotero": {
"id": "zotero-kb-bridge@local",
"strict_min_version": "7.0"
}
}
}