initiation
This commit is contained in:
parent
55edb5522a
commit
4d45602c45
8
.claude/settings.json
Normal file
8
.claude/settings.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git check-ignore:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal 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
1
.python-version
Normal file
@ -0,0 +1 @@
|
|||||||
|
3.10
|
||||||
@ -0,0 +1 @@
|
|||||||
|
{"reason":"idle timeout","timestamp":1776244824573}
|
||||||
@ -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"}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
31479
|
||||||
76
README.md
Normal file
76
README.md
Normal 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
|
||||||
|
```
|
||||||
705
docs/superpowers/plans/2026-04-15-import-floating-window.md
Normal file
705
docs/superpowers/plans/2026-04-15-import-floating-window.md
Normal 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 147–154):
|
||||||
|
```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 155–173):
|
||||||
|
```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:342–370`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the modal HTML with window shell + window frame + minimized bar**
|
||||||
|
|
||||||
|
Old (lines 342–370):
|
||||||
|
```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:419–498`
|
||||||
|
|
||||||
|
- [ ] **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 ~467–498):
|
||||||
|
```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 1–3
|
||||||
|
- Minimize/maximize/close buttons → Task 4 Steps 4–5
|
||||||
|
- 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 9–12
|
||||||
|
|
||||||
|
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.
|
||||||
@ -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`
|
||||||
@ -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`
|
||||||
378
docs/superpowers/plans/2026-04-15-zotero-collection-import.md
Normal file
378
docs/superpowers/plans/2026-04-15-zotero-collection-import.md
Normal 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"
|
||||||
|
```
|
||||||
519
docs/superpowers/plans/2026-04-15-zotero-kb-v1.md
Normal file
519
docs/superpowers/plans/2026-04-15-zotero-kb-v1.md
Normal 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"
|
||||||
|
```
|
||||||
627
docs/superpowers/specs/2026-04-15-zotero-kb-design.md
Normal file
627
docs/superpowers/specs/2026-04-15-zotero-kb-design.md
Normal 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
5
main.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from zotero_kb.main import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
25
pyproject.toml
Normal file
25
pyproject.toml
Normal 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"]
|
||||||
25
skills/zotero-citation-planner/SKILL.md
Normal file
25
skills/zotero-citation-planner/SKILL.md
Normal 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
|
||||||
|
|
||||||
26
skills/zotero-citation-recommender/SKILL.md
Normal file
26
skills/zotero-citation-recommender/SKILL.md
Normal 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
|
||||||
|
|
||||||
2
src/zotero_kb/__init__.py
Normal file
2
src/zotero_kb/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
"""Zotero KB package."""
|
||||||
|
|
||||||
10
src/zotero_kb/bridge.py
Normal file
10
src/zotero_kb/bridge.py
Normal 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
176
src/zotero_kb/cards.py
Normal 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
38
src/zotero_kb/config.py
Normal 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
127
src/zotero_kb/llm.py
Normal 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
15
src/zotero_kb/main.py
Normal 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()
|
||||||
@ -173,6 +173,9 @@
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
.window-frame.minimized {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.window-frame.maximized {
|
.window-frame.maximized {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@ -228,35 +231,65 @@
|
|||||||
.window-toolbar-actions {
|
.window-toolbar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
justify-content: flex-end;
|
||||||
|
gap: 0.9rem;
|
||||||
flex-wrap: wrap;
|
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 {
|
.window-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 22rem minmax(0, 1fr);
|
||||||
grid-template-rows: 1fr auto;
|
|
||||||
min-height: 0;
|
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 {
|
.window-pane {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
@ -265,6 +298,10 @@
|
|||||||
}
|
}
|
||||||
.window-pane.collections {
|
.window-pane.collections {
|
||||||
border-right: 1px solid var(--line);
|
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 {
|
.window-scroll {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@ -317,9 +354,23 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: minmax(12rem, 1fr) minmax(12rem, 1fr);
|
grid-template-rows: minmax(12rem, 1fr) minmax(12rem, 1fr);
|
||||||
}
|
}
|
||||||
.modal-header,
|
.window-frame {
|
||||||
.modal-action-bar {
|
min-width: 0;
|
||||||
flex-wrap: wrap;
|
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>
|
</style>
|
||||||
@ -371,36 +422,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="window-toolbar-actions">
|
<div class="window-toolbar-actions">
|
||||||
<div id="window-selected-count" class="meta">已选 0 篇</div>
|
<div class="window-action-cluster">
|
||||||
<button id="window-clear-selection-button" type="button" class="danger">清空选择</button>
|
<div id="window-selected-count" class="window-selection-pill meta">已选 0 篇</div>
|
||||||
<button id="window-import-selected-items-button" type="button">导入所选</button>
|
<button id="window-clear-selection-button" type="button" class="danger window-toolbar-button">清空选择</button>
|
||||||
<button id="window-minimize-button" type="button" class="secondary" title="最小化" style="width:auto;padding:0.55rem 0.75rem">_</button>
|
<button id="window-import-selected-items-button" type="button" class="window-toolbar-button">导入所选</button>
|
||||||
<button id="window-maximize-button" type="button" class="secondary" title="最大化" style="width:auto;padding:0.55rem 0.75rem">□</button>
|
</div>
|
||||||
<button id="window-close-button" type="button" class="secondary" title="关闭" style="width:auto;padding:0.55rem 0.75rem">×</button>
|
<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>
|
</div>
|
||||||
<div class="window-body">
|
<div class="window-body">
|
||||||
<div class="import-split">
|
<div class="window-pane collections">
|
||||||
<div class="import-left">
|
<small>Collections</small>
|
||||||
<div class="import-list-header">
|
<div id="window-collection-status" class="status"></div>
|
||||||
<span>Pending (<span id="pending-count">0</span>)</span>
|
<div id="window-collection-tree" class="window-scroll tree-list"></div>
|
||||||
<button type="button" id="select-all-pending" class="link-btn">Select All</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="import-list" id="import-items-list">
|
<div class="window-pane items">
|
||||||
<!-- populated by JS -->
|
<small>Collection Items</small>
|
||||||
</div>
|
<div id="window-collection-items" class="window-scroll collection-items-list"></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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="item-preview-popover" class="preview-popover" hidden></div>
|
<div id="item-preview-popover" class="preview-popover" hidden></div>
|
||||||
@ -528,10 +570,6 @@
|
|||||||
hoveredItemKey: null,
|
hoveredItemKey: null,
|
||||||
hoverTimerId: null,
|
hoverTimerId: null,
|
||||||
previewItemKey: null,
|
previewItemKey: null,
|
||||||
importItems: [],
|
|
||||||
selectedPendingKeys: new Set(),
|
|
||||||
isGenerating: false,
|
|
||||||
generatingProgress: { current: 0, total: 0 },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const elements = {
|
const elements = {
|
||||||
@ -613,6 +651,61 @@
|
|||||||
fw.style.height = state.windowHeight !== null ? `${state.windowHeight}px` : "";
|
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() {
|
function initWindowFromSession() {
|
||||||
const fw = elements.importWindow;
|
const fw = elements.importWindow;
|
||||||
const bar = elements.importWindowMinimizedBar;
|
const bar = elements.importWindowMinimizedBar;
|
||||||
@ -1139,97 +1232,6 @@
|
|||||||
closeImportWindow();
|
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() {
|
async function openImportWindow() {
|
||||||
state.isImportWindowOpen = true;
|
state.isImportWindowOpen = true;
|
||||||
elements.importWindowShell.hidden = false;
|
elements.importWindowShell.hidden = false;
|
||||||
@ -1237,18 +1239,38 @@
|
|||||||
document.body.classList.toggle("modal-open", true);
|
document.body.classList.toggle("modal-open", true);
|
||||||
cancelAbstractPreview();
|
cancelAbstractPreview();
|
||||||
updateImportActionState();
|
updateImportActionState();
|
||||||
|
state.isWindowMinimized = false;
|
||||||
|
state.isWindowMaximized = false;
|
||||||
initWindowFromSession();
|
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() {
|
function closeImportWindow() {
|
||||||
state.isImportWindowOpen = false;
|
state.isImportWindowOpen = false;
|
||||||
elements.importWindowShell.hidden = true;
|
elements.importWindowShell.hidden = true;
|
||||||
elements.importWindowShell.setAttribute("aria-hidden", "true");
|
elements.importWindowShell.setAttribute("aria-hidden", "true");
|
||||||
|
elements.importWindowMinimizedBar.hidden = true;
|
||||||
document.body.classList.toggle("modal-open", false);
|
document.body.classList.toggle("modal-open", false);
|
||||||
cancelAbstractPreview();
|
cancelAbstractPreview();
|
||||||
state.importItems = [];
|
|
||||||
state.selectedPendingKeys.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadProjects() {
|
async function loadProjects() {
|
||||||
@ -1328,21 +1350,18 @@
|
|||||||
elements.windowToolbar.addEventListener("pointerdown", startWindowDrag);
|
elements.windowToolbar.addEventListener("pointerdown", startWindowDrag);
|
||||||
document.addEventListener("pointermove", moveWindowDrag);
|
document.addEventListener("pointermove", moveWindowDrag);
|
||||||
document.addEventListener("pointerup", endWindowDrag);
|
document.addEventListener("pointerup", endWindowDrag);
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
if (!state.isImportWindowOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncWindowToViewport();
|
||||||
|
});
|
||||||
elements.windowClearSelectionButton.addEventListener("click", clearSelectedItems);
|
elements.windowClearSelectionButton.addEventListener("click", clearSelectedItems);
|
||||||
elements.windowImportSelectedItemsButton.addEventListener("click", () => {
|
elements.windowImportSelectedItemsButton.addEventListener("click", () => {
|
||||||
importSelectedItems().catch((error) => {
|
importSelectedItems().catch((error) => {
|
||||||
setStatus(elements.windowCollectionStatus, error.message, true);
|
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) => {
|
document.addEventListener("keydown", (event) => {
|
||||||
if (event.key === "Escape" && state.isImportWindowOpen) {
|
if (event.key === "Escape" && state.isImportWindowOpen) {
|
||||||
closeImportWindow();
|
closeImportWindow();
|
||||||
|
|||||||
64
src/zotero_kb/workspace.py
Normal file
64
src/zotero_kb/workspace.py
Normal 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
74
src/zotero_kb/writing.py
Normal 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)
|
||||||
453
src/zotero_kb/zotero_reader.py
Normal file
453
src/zotero_kb/zotero_reader.py
Normal 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
68
tests/test_cards.py
Normal 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
11
tests/test_config.py
Normal 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
78
tests/test_llm.py
Normal 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
76
tests/test_projects.py
Normal 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"] == []
|
||||||
10
tests/test_skill_assets.py
Normal file
10
tests/test_skill_assets.py
Normal 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()
|
||||||
116
tests/test_ui.py
116
tests/test_ui.py
@ -1,3 +1,5 @@
|
|||||||
|
import subprocess
|
||||||
|
|
||||||
from zotero_kb.api import create_app
|
from zotero_kb.api import create_app
|
||||||
from zotero_kb.config import AppConfig
|
from zotero_kb.config import AppConfig
|
||||||
|
|
||||||
@ -37,13 +39,14 @@ def test_index_contains_import_window_controls(tmp_path) -> None:
|
|||||||
assert 'id="window-restore-button"' in html
|
assert 'id="window-restore-button"' in html
|
||||||
assert 'id="import-window-minimized-bar"' in html
|
assert 'id="import-window-minimized-bar"' in html
|
||||||
assert 'id="import-window-project-label"' in html
|
assert 'id="import-window-project-label"' in html
|
||||||
# New two-column import layout
|
assert 'id="window-clear-selection-button"' in html
|
||||||
assert 'id="import-items-list"' in html
|
assert 'id="window-import-selected-items-button"' in html
|
||||||
assert 'id="item-preview"' in html
|
assert 'id="window-collection-status"' in html
|
||||||
assert 'id="pending-count"' in html
|
assert 'id="window-collection-tree"' in html
|
||||||
assert 'id="done-count"' in html
|
assert 'id="window-collection-items"' in html
|
||||||
assert 'id="select-all-pending"' in html
|
assert 'class="window-toolbar-actions"' in html
|
||||||
assert 'id="generate-cards-btn"' in html
|
assert 'class="window-action-cluster"' in html
|
||||||
|
assert 'class="window-control-cluster"' in html
|
||||||
assert 'isImportWindowOpen: false' in html
|
assert 'isImportWindowOpen: false' in html
|
||||||
assert "function openImportWindow()" in html
|
assert "function openImportWindow()" in html
|
||||||
assert "function closeImportWindow()" in html
|
assert "function closeImportWindow()" in html
|
||||||
@ -68,54 +71,73 @@ def test_index_window_frame_css(tmp_path) -> None:
|
|||||||
assert "persistWindowSessionState" in html
|
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)
|
html = _get_index_html(tmp_path)
|
||||||
|
|
||||||
# Two-column split layout structure
|
assert 'class="window-pane collections"' in html
|
||||||
assert 'class="import-split"' in html
|
assert 'class="window-pane items"' in html
|
||||||
assert 'class="import-left"' in html
|
|
||||||
assert 'class="import-right"' in html
|
|
||||||
|
|
||||||
# Left column: list header, list, footer
|
assert 'id="window-collection-status"' in html
|
||||||
assert 'class="import-list-header"' in html
|
assert 'id="window-collection-tree"' in html
|
||||||
assert 'id="import-items-list"' in html
|
assert 'id="window-collection-items"' in html
|
||||||
assert 'class="import-list-footer"' in html
|
assert ".window-pane.collections" in html
|
||||||
assert 'id="pending-count"' in html
|
assert ".window-pane.items" in html
|
||||||
assert 'id="done-count"' in html
|
assert ".tree-list" in html
|
||||||
assert 'id="select-all-pending"' in html
|
assert ".collection-items-list" 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
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
html = _get_index_html(tmp_path)
|
||||||
|
|
||||||
# loadImportState function fetches import-state
|
assert "function loadCollectionTree()" in html
|
||||||
assert "function loadImportState()" 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 "/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
|
# openImportWindow calls loadCollectionTree
|
||||||
assert "function generateSelectedCards()" in html
|
|
||||||
assert "/cards/generate" in html
|
|
||||||
|
|
||||||
# openImportWindow calls loadImportState
|
|
||||||
assert "openImportWindow()" in html
|
assert "openImportWindow()" in html
|
||||||
# Check loadImportState is called inside openImportWindow (await statement appears)
|
assert "await loadCollectionTree()" in html
|
||||||
assert "await loadImportState()" in html
|
|
||||||
|
|
||||||
# generate-cards-btn has click listener for generateSelectedCards
|
|
||||||
assert 'generate-cards-btn' in html
|
def test_index_has_responsive_import_window_geometry_helpers(tmp_path) -> None:
|
||||||
assert 'generateSelectedCards' in html
|
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
34
tests/test_workspace.py
Normal 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
79
tests/test_writing.py
Normal 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"
|
||||||
74
tests/test_zotero_reader.py
Normal file
74
tests/test_zotero_reader.py
Normal 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
561
uv.lock
generated
Normal 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
33
zotero-bridge/src/bootstrap.js
vendored
Normal 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.`);
|
||||||
|
}
|
||||||
12
zotero-bridge/src/manifest.json
Normal file
12
zotero-bridge/src/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user