initiation

This commit is contained in:
Saberlve 2026-04-16 13:34:39 +08:00
parent 55edb5522a
commit 4d45602c45
40 changed files with 5964 additions and 206 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

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

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,25 @@
---
name: zotero-citation-planner
description: Read one Zotero KB project and generate a first-pass citation plan from that project only.
---
# Zotero Citation Planner
Use this skill when the user has a writing intent and needs a project-bounded citation plan.
## Workflow
1. Read `projects/<project-id>/project.json`
2. Read `projects/<project-id>/project-index.json`
3. Open only the cards referenced by that project
4. Build a paragraph or section plan using those cards only
5. Return structure, citation order, and citation roles
## Output
- section heading
- section goal
- recommended citations
- role of each citation
- notes about how to sequence the paragraph

View File

@ -0,0 +1,26 @@
---
name: zotero-citation-recommender
description: Read one Zotero KB project and recommend relevant citations from that project only.
---
# Zotero Citation Recommender
Use this skill when you need citations from a specific Zotero KB project.
## Workflow
1. Read `projects/<project-id>/project.json`
2. Read `projects/<project-id>/project-index.json`
3. Open only the card files referenced by that project index
4. Recommend citations from those cards only
5. Do not read unrelated projects unless the user asks
## Output
- item key
- title
- why it is relevant
- supporting claims
- quotable passages
- suggested rhetorical role

View File

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

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", [])]

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

@ -0,0 +1,176 @@
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from pathlib import Path
from .llm import CardGenerationClient
from .zotero_reader import ZoteroItemRecord
@dataclass(frozen=True)
class CardBuildResult:
item_key: str
card_path: Path
source_hash: 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) -> CardBuildResult:
source_bundle = self._build_source_bundle(item)
source_hash = self._hash_payload(source_bundle)
source_bundle["source_hash"] = source_hash
bundle_path = self.bundle_dir / f"{item.item_key}.json"
self._write_json(bundle_path, source_bundle)
card_data = self.llm_client.generate_card(source_bundle)
card_path = self._card_path(item)
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)
return CardBuildResult(item_key=item.item_key, card_path=card_path, source_hash=source_hash)
def _build_source_bundle(self, item: ZoteroItemRecord) -> dict[str, object]:
return {
"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,
"notes": item.notes,
"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) -> Path:
collection_path = item.collection_paths[0] if item.collection_paths else ["Unsorted"]
filename = f"{self._sanitize_filename(item.title)} [{item.item_key}].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", [])]),
("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"
def _update_indexes(
self,
item: ZoteroItemRecord,
card_path: Path,
source_hash: str,
card_data: dict[str, object],
) -> 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),
"source_hash": source_hash,
}
cards_index[item.item_key] = {
"item_key": item.item_key,
"card_path": str(card_path),
"title": item.title,
"summary": str(card_data.get("summary", "")),
"keywords": [str(value) for value in card_data.get("keywords", [])],
"claims": [str(value) for value in card_data.get("core_claims", [])],
"quotable_spans": [str(value) for value in card_data.get("quotable_passages", [])],
"writing_hints": [str(value) for value in card_data.get("writing_hints", [])],
}
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

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

@ -0,0 +1,127 @@
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()
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,
"core_claims": claims,
"methods": [],
"evidence": attachment_texts[:3],
"quotable_passages": attachment_texts[:2],
"writing_hints": [
"Use this card when the writing intent overlaps with its summary or notes.",
],
"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]:
payload = {
"model": self.model,
"response_format": {"type": "json_object"},
"messages": [
{
"role": "system",
"content": (
"You convert research source bundles into structured JSON cards. "
"Return a JSON object with keys: summary, core_claims, methods, evidence, "
"quotable_passages, writing_hints, keywords."
),
},
{
"role": "user",
"content": 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", [])],
"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()

View File

@ -173,6 +173,9 @@
background: var(--surface);
border: 1px solid var(--line);
}
.window-frame.minimized {
display: none;
}
.window-frame.maximized {
width: 100vw;
height: 100vh;
@ -228,35 +231,65 @@
.window-toolbar-actions {
display: flex;
align-items: center;
gap: 0.75rem;
justify-content: flex-end;
gap: 0.9rem;
flex-wrap: wrap;
}
.window-toolbar-actions button {
width: auto;
}
.window-action-cluster,
.window-control-cluster {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 251, 244, 0.92);
box-shadow: 0 10px 26px rgba(68, 58, 36, 0.08);
}
.window-action-cluster {
padding-left: 0.75rem;
}
.window-control-cluster {
gap: 0.25rem;
}
.window-selection-pill {
display: inline-flex;
align-items: center;
min-height: 2.4rem;
padding: 0 0.25rem 0 0.1rem;
color: var(--accent-strong);
font-weight: 600;
white-space: nowrap;
}
.window-toolbar-button {
min-height: 2.4rem;
padding: 0.65rem 1rem;
white-space: nowrap;
}
.window-control-button {
width: 2.4rem;
min-width: 2.4rem;
height: 2.4rem;
padding: 0;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
line-height: 1;
}
.window-control-button.close {
background: rgba(139, 58, 42, 0.14);
color: var(--danger);
}
.window-body {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr auto;
grid-template-columns: 22rem minmax(0, 1fr);
min-height: 0;
}
.import-split { display: grid; grid-template-columns: 40% 60%; height: 100%; gap: 1px; background: var(--line); }
.import-left, .import-right { background: var(--surface); overflow-y: auto; padding: 1rem; }
.import-list-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; font-weight: 600; }
.import-list { display: flex; flex-direction: column; gap: 0.25rem; }
.import-item { display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.5rem; border-radius: 6px; cursor: pointer; }
.import-item.pending { background: var(--panel); }
.import-item.done { background: transparent; opacity: 0.7; }
.import-item.selected { outline: 2px solid var(--accent, #4a90d9); }
.import-item input[type="checkbox"] { margin-top: 0.25rem; }
.import-item.done input[type="checkbox"] { display: none; }
.import-item .item-title { font-size: 0.875rem; font-weight: 500; }
.import-item .item-meta { font-size: 0.75rem; color: var(--ink-dim, #666); }
.import-list-footer { margin-top: 0.75rem; font-weight: 600; font-size: 0.875rem; }
.import-actions { padding: 0.75rem 1rem; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; }
#generate-cards-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.link-btn { background: none; border: none; color: var(--accent, #4a90d9); cursor: pointer; font-size: 0.8rem; }
#item-preview .preview-title { font-size: 1rem; font-weight: 600; margin-bottom: 0.5rem; }
#item-preview .preview-meta { font-size: 0.8rem; color: var(--ink-dim); margin-bottom: 0.75rem; }
#item-preview .preview-abstract { font-size: 0.85rem; line-height: 1.5; }
.hint { color: var(--ink-dim); font-size: 0.875rem; }
.window-pane {
display: grid;
gap: 0.6rem;
@ -265,6 +298,10 @@
}
.window-pane.collections {
border-right: 1px solid var(--line);
background: rgba(249, 243, 232, 0.55);
}
.window-pane.items {
background: rgba(255, 253, 248, 0.96);
}
.window-scroll {
min-height: 0;
@ -317,9 +354,23 @@
grid-template-columns: 1fr;
grid-template-rows: minmax(12rem, 1fr) minmax(12rem, 1fr);
}
.modal-header,
.modal-action-bar {
flex-wrap: wrap;
.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);
}
}
</style>
@ -371,36 +422,27 @@
</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 class="window-action-cluster">
<div id="window-selected-count" class="window-selection-pill meta">已选 0 篇</div>
<button id="window-clear-selection-button" type="button" class="danger window-toolbar-button">清空选择</button>
<button id="window-import-selected-items-button" type="button" class="window-toolbar-button">导入所选</button>
</div>
<div class="window-control-cluster" aria-label="窗口控制">
<button id="window-minimize-button" type="button" class="secondary window-control-button" title="最小化"></button>
<button id="window-maximize-button" type="button" class="secondary window-control-button" title="最大化"></button>
<button id="window-close-button" type="button" class="window-control-button close" title="关闭">×</button>
</div>
</div>
</div>
<div class="window-body">
<div class="import-split">
<div class="import-left">
<div class="import-list-header">
<span>Pending (<span id="pending-count">0</span>)</span>
<button type="button" id="select-all-pending" class="link-btn">Select All</button>
<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="import-list" id="import-items-list">
<!-- populated by JS -->
</div>
<div class="import-list-footer">
Done (<span id="done-count">0</span>)
</div>
</div>
<div class="import-right">
<div id="item-preview">
<p class="hint">Click an item to preview</p>
</div>
</div>
</div>
<div class="import-actions">
<button type="button" id="generate-cards-btn" class="primary" disabled>生成选中卡片 (0)</button>
<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>
@ -528,10 +570,6 @@
hoveredItemKey: null,
hoverTimerId: null,
previewItemKey: null,
importItems: [],
selectedPendingKeys: new Set(),
isGenerating: false,
generatingProgress: { current: 0, total: 0 },
};
const elements = {
@ -613,6 +651,61 @@
fw.style.height = state.windowHeight !== null ? `${state.windowHeight}px` : "";
}
function computeResponsiveWindowRect() {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const margin = viewportWidth <= 980 ? 8 : 24;
const preferredWidth = Math.round(viewportWidth * 0.88);
const preferredHeight = Math.round(viewportHeight * 0.82);
const targetMinWidth = viewportWidth <= 980 ? 320 : 640;
const targetMinHeight = viewportHeight <= 980 ? 420 : 480;
const availableWidth = Math.max(260, viewportWidth - margin * 2);
const availableHeight = Math.max(320, viewportHeight - margin * 2);
const minWidth = Math.min(targetMinWidth, availableWidth);
const minHeight = Math.min(targetMinHeight, availableHeight);
const width = Math.max(minWidth, Math.min(preferredWidth, availableWidth));
const height = Math.max(minHeight, Math.min(preferredHeight, availableHeight));
const left = Math.max(margin, Math.round((viewportWidth - width) / 2));
const top = Math.max(margin, Math.round((viewportHeight - height) / 2));
return { width, height, left, top, margin };
}
function applyResponsiveWindowRect() {
const fw = elements.importWindow;
if (!fw || state.isWindowMinimized || state.isWindowMaximized) return;
const rect = computeResponsiveWindowRect();
fw.style.width = `${rect.width}px`;
fw.style.height = `${rect.height}px`;
fw.style.left = `${rect.left}px`;
fw.style.top = `${rect.top}px`;
state.windowWidth = rect.width;
state.windowHeight = rect.height;
state.windowX = rect.left;
state.windowY = rect.top;
}
function syncWindowToViewport() {
const fw = elements.importWindow;
if (!fw || state.isWindowMinimized || state.isWindowMaximized) return;
const rect = computeResponsiveWindowRect();
const width = Math.min(parseFloat(fw.style.width) || rect.width, rect.width);
const height = Math.min(parseFloat(fw.style.height) || rect.height, rect.height);
const maxLeft = Math.max(rect.margin, window.innerWidth - width - rect.margin);
const maxTop = Math.max(rect.margin, window.innerHeight - height - rect.margin);
const left = Math.min(Math.max(parseFloat(fw.style.left) || rect.left, rect.margin), maxLeft);
const top = Math.min(Math.max(parseFloat(fw.style.top) || rect.top, rect.margin), maxTop);
fw.style.width = `${width}px`;
fw.style.height = `${height}px`;
fw.style.left = `${left}px`;
fw.style.top = `${top}px`;
state.windowWidth = width;
state.windowHeight = height;
state.windowX = left;
state.windowY = top;
}
function initWindowFromSession() {
const fw = elements.importWindow;
const bar = elements.importWindowMinimizedBar;
@ -1139,97 +1232,6 @@
closeImportWindow();
}
async function loadImportState() {
const data = await api(`/api/projects/${state.currentProjectId}/import-state`);
state.importItems = data.items;
renderImportItemList();
updateImportCounts(data.pending_count, data.done_count);
}
function renderImportItemList() {
const list = document.getElementById('import-items-list');
list.innerHTML = '';
state.importItems.forEach(item => {
const isDone = item.card_status === 'done';
const div = document.createElement('div');
div.className = `import-item ${item.card_status}`;
div.dataset.key = item.item_key;
div.innerHTML = `
<input type="checkbox" ${isDone ? 'disabled' : ''} data-key="${item.item_key}">
<div>
<div class="item-title">${escapeHtml(item.title)}</div>
<div class="item-meta">${escapeHtml(item.creators.join(', '))} · ${escapeHtml(item.year || 'n.d.')}</div>
</div>
`;
if (!isDone) {
div.querySelector('input').addEventListener('change', (e) => {
if (e.target.checked) state.selectedPendingKeys.add(item.item_key);
else state.selectedPendingKeys.delete(item.item_key);
updateGenerateButton();
div.classList.toggle('selected', e.target.checked);
});
}
div.addEventListener('click', (e) => { if (e.target.tagName !== 'INPUT') showItemPreview(item); });
list.appendChild(div);
});
}
function showItemPreview(item) {
document.getElementById('item-preview').innerHTML = `
<div class="preview-title">${escapeHtml(item.title)}</div>
<div class="preview-meta">${escapeHtml(item.creators.join(', '))} · ${escapeHtml(item.year || 'n.d.')} · ${escapeHtml(item.item_type)}</div>
<div class="preview-abstract">${escapeHtml(item.abstract || 'No abstract available.')}</div>
`;
}
function updateGenerateButton() {
const btn = document.getElementById('generate-cards-btn');
const count = state.selectedPendingKeys.size;
btn.disabled = count === 0 || state.isGenerating;
btn.textContent = state.isGenerating
? `生成中 ${state.generatingProgress.current}/${state.generatingProgress.total}`
: `生成选中卡片 (${count})`;
}
async function generateSelectedCards() {
const keys = Array.from(state.selectedPendingKeys);
if (!keys.length) return;
state.isGenerating = true;
state.generatingProgress = { current: 0, total: keys.length };
updateGenerateButton();
try {
const data = await api(`/api/projects/${state.currentProjectId}/cards/generate`, {
method: 'POST',
body: JSON.stringify({ item_keys: keys }),
});
data.items.forEach(updated => {
const item = state.importItems.find(i => i.item_key === updated.item_key);
if (item) item.card_status = updated.card_status;
});
state.selectedPendingKeys.clear();
renderImportItemList();
const counts = {
pending: state.importItems.filter(i => i.card_status === 'pending').length,
done: state.importItems.filter(i => i.card_status === 'done').length,
};
updateImportCounts(counts.pending, counts.done);
} catch (error) {
setStatus(elements.windowCollectionStatus, error.message, true);
} finally {
state.isGenerating = false;
updateGenerateButton();
}
}
function updateImportCounts(pending, done) {
document.getElementById('pending-count').textContent = pending;
document.getElementById('done-count').textContent = done;
}
async function openImportWindow() {
state.isImportWindowOpen = true;
elements.importWindowShell.hidden = false;
@ -1237,18 +1239,38 @@
document.body.classList.toggle("modal-open", true);
cancelAbstractPreview();
updateImportActionState();
state.isWindowMinimized = false;
state.isWindowMaximized = false;
initWindowFromSession();
await loadImportState();
applyResponsiveWindowRect();
if (!state.collectionTree.length) {
try {
await loadCollectionTree();
} catch (error) {
setStatus(elements.windowCollectionStatus, error.message, true);
}
} else if (
state.selectedCollectionKey &&
(!state.collectionItemsLoaded && state.loadingCollectionKey !== state.selectedCollectionKey)
) {
try {
await loadCollectionItems(state.selectedCollectionKey);
} catch (error) {
setStatus(elements.windowCollectionStatus, error.message, true);
}
} else {
renderCollectionTree();
renderCollectionItems();
}
}
function closeImportWindow() {
state.isImportWindowOpen = false;
elements.importWindowShell.hidden = true;
elements.importWindowShell.setAttribute("aria-hidden", "true");
elements.importWindowMinimizedBar.hidden = true;
document.body.classList.toggle("modal-open", false);
cancelAbstractPreview();
state.importItems = [];
state.selectedPendingKeys.clear();
}
async function loadProjects() {
@ -1328,21 +1350,18 @@
elements.windowToolbar.addEventListener("pointerdown", startWindowDrag);
document.addEventListener("pointermove", moveWindowDrag);
document.addEventListener("pointerup", endWindowDrag);
window.addEventListener("resize", () => {
if (!state.isImportWindowOpen) {
return;
}
syncWindowToViewport();
});
elements.windowClearSelectionButton.addEventListener("click", clearSelectedItems);
elements.windowImportSelectedItemsButton.addEventListener("click", () => {
importSelectedItems().catch((error) => {
setStatus(elements.windowCollectionStatus, error.message, true);
});
});
document.getElementById('select-all-pending').addEventListener('click', () => {
document.querySelectorAll('.import-item.pending input[type="checkbox"]').forEach(cb => {
cb.checked = true;
state.selectedPendingKeys.add(cb.dataset.key);
cb.closest('.import-item').classList.add('selected');
});
updateGenerateButton();
});
document.getElementById('generate-cards-btn').addEventListener('click', generateSelectedCards);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.isImportWindowOpen) {
closeImportWindow();

View File

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

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

68
tests/test_cards.py Normal file
View File

@ -0,0 +1,68 @@
import json
from pathlib import Path
from zotero_kb.cards import CardBuilder
from zotero_kb.zotero_reader import ZoteroItemRecord
class FakeLlmClient:
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
return {
"summary": "Merged metadata and attachment text improve drafting support.",
"core_claims": [
"Project-scoped cards reduce irrelevant retrieval.",
],
"methods": [
"Combines notes, metadata, and full text.",
],
"evidence": [
"The merged pipeline improved citation precision.",
],
"quotable_passages": [
"Project-scoped card retrieval improves citation precision during drafting.",
],
"writing_hints": [
"Use when arguing for scoped retrieval during drafting.",
],
"keywords": ["retrieval", "writing"],
}
def test_build_card_writes_markdown_and_indexes(tmp_path: Path) -> None:
item = ZoteroItemRecord(
item_key="PAPER0001",
title="Card Pipelines for Research Writing",
creators=["Alice Smith", "Bob Li"],
year="2024",
item_type="journalArticle",
abstract="Merged metadata and notes improve drafting support.",
tags=["llm", "writing"],
collection_paths=[["Theory", "Drafting"]],
notes=["Merged notes matter."],
attachment_texts=["This paper studies card pipelines for research writing."],
)
builder = CardBuilder(workspace_dir=tmp_path, llm_client=FakeLlmClient())
result = builder.build_or_update(item)
expected_path = (
tmp_path
/ "library"
/ "collections"
/ "Theory"
/ "Drafting"
/ "Card Pipelines for Research Writing [PAPER0001].md"
)
assert result.card_path == expected_path
assert result.card_path.read_text(encoding="utf-8").startswith("---")
cards_index = json.loads((tmp_path / "library" / "index" / "cards.json").read_text(encoding="utf-8"))
assert cards_index["PAPER0001"]["title"] == "Card Pipelines for Research Writing"
items_index = json.loads((tmp_path / "library" / "index" / "items.json").read_text(encoding="utf-8"))
assert items_index["PAPER0001"]["card_path"] == str(expected_path)
source_bundle = json.loads(
(tmp_path / "library" / "cache" / "source-bundles" / "PAPER0001.json").read_text(encoding="utf-8")
)
assert source_bundle["item_key"] == "PAPER0001"

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"

78
tests/test_llm.py Normal file
View File

@ -0,0 +1,78 @@
import json
import pytest
from zotero_kb.llm import DeepSeekCardGenerationClient, create_card_generation_client
def test_create_card_generation_client_requires_deepseek_api_key() -> None:
with pytest.raises(ValueError, match="DEEPSEEK_API_KEY"):
create_card_generation_client("deepseek", "deepseek-chat", env={})
def test_deepseek_client_uses_env_api_key_and_parses_json() -> None:
captured = {}
def fake_request(url: str, payload: dict[str, object], headers: dict[str, str]) -> dict[str, object]:
captured["url"] = url
captured["payload"] = payload
captured["headers"] = headers
return {
"choices": [
{
"message": {
"content": json.dumps(
{
"summary": "DeepSeek summary",
"core_claims": ["Claim A"],
"methods": ["Method A"],
"evidence": ["Evidence A"],
"quotable_passages": ["Quote A"],
"writing_hints": ["Hint A"],
"keywords": ["kw-a"],
}
)
}
}
]
}
client = create_card_generation_client(
"deepseek",
"deepseek-chat",
env={"DEEPSEEK_API_KEY": "sk-test"},
request_fn=fake_request,
)
result = client.generate_card(
{
"title": "Scoped Retrieval for Drafting",
"abstract": "Merged metadata improves drafting.",
"notes": ["Project scoping helps."],
"attachment_texts": ["Attachment evidence."],
"tags": ["retrieval"],
}
)
assert result["summary"] == "DeepSeek summary"
assert captured["url"] == "https://api.deepseek.com/v1/chat/completions"
assert captured["payload"]["model"] == "deepseek-chat"
assert captured["headers"]["Authorization"] == "Bearer sk-test"
def test_deepseek_client_strips_markdown_fences() -> None:
client = DeepSeekCardGenerationClient(
api_key="sk-test",
request_fn=lambda *_args, **_kwargs: {
"choices": [
{
"message": {
"content": "```json\n{\"summary\":\"Fence summary\",\"core_claims\":[],\"methods\":[],\"evidence\":[],\"quotable_passages\":[],\"writing_hints\":[],\"keywords\":[]}\n```"
}
}
]
},
)
result = client.generate_card({"title": "Paper"})
assert result["summary"] == "Fence summary"

76
tests/test_projects.py Normal file
View File

@ -0,0 +1,76 @@
import json
from pathlib import Path
from zotero_kb.cards import CardBuilder
from zotero_kb.config import AppConfig
from zotero_kb.projects import ProjectService
from zotero_kb.workspace import Workspace
from zotero_kb.zotero_reader import ZoteroItemRecord
class FakeLlmClient:
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
title = str(source_bundle["title"])
if "Scoped" in title:
summary = "Project-scoped retrieval improves drafting."
claims = ["Project scoping improves citation precision."]
else:
summary = "Unrelated retrieval baseline."
claims = ["Baseline retrieval is broad."]
return {
"summary": summary,
"core_claims": claims,
"methods": ["Method details."],
"evidence": ["Evidence details."],
"quotable_passages": claims,
"writing_hints": [summary],
"keywords": ["retrieval"],
}
def _build_item(item_key: str, title: str) -> ZoteroItemRecord:
return ZoteroItemRecord(
item_key=item_key,
title=title,
creators=["Alice Smith"],
year="2024",
item_type="journalArticle",
abstract=title,
tags=["retrieval"],
collection_paths=[["Theory", "Drafting"]],
notes=[title],
attachment_texts=[title],
)
def test_add_item_to_project_updates_selected_items_and_project_index(tmp_path: Path) -> None:
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
workspace = Workspace(config)
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini")
builder = CardBuilder(config.workspace_dir, FakeLlmClient())
builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting"))
service = ProjectService(config.workspace_dir)
payload = service.add_items("thesis-ch2", ["PAPER0001"])
assert payload["selected_items"] == ["PAPER0001"]
assert payload["cards"][0]["item_key"] == "PAPER0001"
selected_items = json.loads(
(config.workspace_dir / "projects" / "thesis-ch2" / "selected-items.json").read_text(encoding="utf-8")
)
assert selected_items == ["PAPER0001"]
def test_remove_item_from_project_updates_selected_items(tmp_path: Path) -> None:
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
workspace = Workspace(config)
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini")
builder = CardBuilder(config.workspace_dir, FakeLlmClient())
builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting"))
service = ProjectService(config.workspace_dir)
service.add_items("thesis-ch2", ["PAPER0001"])
payload = service.remove_item("thesis-ch2", "PAPER0001")
assert payload["selected_items"] == []

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.config import AppConfig
@ -37,13 +39,14 @@ def test_index_contains_import_window_controls(tmp_path) -> None:
assert 'id="window-restore-button"' in html
assert 'id="import-window-minimized-bar"' in html
assert 'id="import-window-project-label"' in html
# New two-column import layout
assert 'id="import-items-list"' in html
assert 'id="item-preview"' in html
assert 'id="pending-count"' in html
assert 'id="done-count"' in html
assert 'id="select-all-pending"' in html
assert 'id="generate-cards-btn"' in html
assert 'id="window-clear-selection-button"' in html
assert 'id="window-import-selected-items-button"' in html
assert 'id="window-collection-status"' in html
assert 'id="window-collection-tree"' in html
assert 'id="window-collection-items"' in html
assert 'class="window-toolbar-actions"' in html
assert 'class="window-action-cluster"' in html
assert 'class="window-control-cluster"' in html
assert 'isImportWindowOpen: false' in html
assert "function openImportWindow()" in html
assert "function closeImportWindow()" in html
@ -68,54 +71,73 @@ def test_index_window_frame_css(tmp_path) -> None:
assert "persistWindowSessionState" in html
def test_index_import_window_has_two_column_layout(tmp_path) -> None:
def test_index_import_window_has_collection_import_layout(tmp_path) -> None:
html = _get_index_html(tmp_path)
# Two-column split layout structure
assert 'class="import-split"' in html
assert 'class="import-left"' in html
assert 'class="import-right"' in html
assert 'class="window-pane collections"' in html
assert 'class="window-pane items"' in html
# Left column: list header, list, footer
assert 'class="import-list-header"' in html
assert 'id="import-items-list"' in html
assert 'class="import-list-footer"' in html
assert 'id="pending-count"' in html
assert 'id="done-count"' in html
assert 'id="select-all-pending"' in html
# Right column: preview
assert 'id="item-preview"' in html
# Action bar
assert 'class="import-actions"' in html
assert 'id="generate-cards-btn"' in html
# CSS: import-split grid layout
assert ".import-split" in html
# CSS: import-item classes
assert ".import-item" in html
assert ".import-item.pending" in html
assert ".import-item.done" in html
assert 'id="window-collection-status"' in html
assert 'id="window-collection-tree"' in html
assert 'id="window-collection-items"' in html
assert ".window-pane.collections" in html
assert ".window-pane.items" in html
assert ".tree-list" in html
assert ".collection-items-list" in html
def test_index_has_import_state_and_cards_generate_api_calls(tmp_path) -> None:
def test_index_has_collection_import_api_calls(tmp_path) -> None:
html = _get_index_html(tmp_path)
# loadImportState function fetches import-state
assert "function loadImportState()" in html
assert "function loadCollectionTree()" in html
assert "function loadCollectionItems(collectionKey)" in html
assert "/api/zotero/collections/tree" in html
assert "/api/zotero/collections/${collectionKey}/items" in html
assert "/api/projects/" in html
assert "import-state" in html
assert "imports/item-keys" in html
assert "function importSelectedItems()" in html
# generateSelectedCards function calls cards/generate
assert "function generateSelectedCards()" in html
assert "/cards/generate" in html
# openImportWindow calls loadImportState
# openImportWindow calls loadCollectionTree
assert "openImportWindow()" in html
# Check loadImportState is called inside openImportWindow (await statement appears)
assert "await loadImportState()" in html
assert "await loadCollectionTree()" in html
# generate-cards-btn has click listener for generateSelectedCards
assert 'generate-cards-btn' in html
assert 'generateSelectedCards' in html
def test_index_has_responsive_import_window_geometry_helpers(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert "function computeResponsiveWindowRect()" in html
assert "function applyResponsiveWindowRect()" in html
assert "function syncWindowToViewport()" in html
assert 'window.addEventListener("resize"' in html
def test_index_has_responsive_import_window_layout_rules(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert "@media (max-width: 980px)" in html
assert ".window-pane.items" in html
assert ".window-body {" in html
assert "grid-template-columns: 1fr;" in html
assert "border-bottom: 1px solid var(--line);" in html
def test_index_inline_script_is_valid_javascript(tmp_path) -> None:
html = _get_index_html(tmp_path)
script_start = html.rfind("<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

34
tests/test_workspace.py Normal file
View File

@ -0,0 +1,34 @@
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()

79
tests/test_writing.py Normal file
View File

@ -0,0 +1,79 @@
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],
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")
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")
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,74 @@
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")
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"
}
}
}