Compare commits
No commits in common. "aaafa7888374cd0b27ec917f32ed649b37914e4c" and "63de030789c1eb0d23a77718c6830151cd9d1acb" have entirely different histories.
aaafa78883
...
63de030789
@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(git add:*)",
|
|
||||||
"Bash(git check-ignore:*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
@ -1,11 +0,0 @@
|
|||||||
# Python-generated files
|
|
||||||
__pycache__/
|
|
||||||
*.py[oc]
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
wheels/
|
|
||||||
*.egg-info
|
|
||||||
|
|
||||||
# Virtual environments
|
|
||||||
.venv
|
|
||||||
workspace/
|
|
||||||
@ -1 +0,0 @@
|
|||||||
3.10
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{"reason":"idle timeout","timestamp":1776244824573}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
{"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"}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
31479
|
|
||||||
130
CLAUDE.md
130
CLAUDE.md
@ -1,130 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Zotero KB is a local FastAPI web service that reads from a Zotero SQLite database and generates structured Markdown knowledge cards for academic writing workflows. It provides project management, collection browsing, batch card generation via LLM (DeepSeek), and citation recommendation APIs.
|
|
||||||
|
|
||||||
## Development Commands
|
|
||||||
|
|
||||||
Package manager is `uv`. All commands run through `uv run`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
UV_CACHE_DIR=/tmp/uv-cache uv sync --extra dev
|
|
||||||
|
|
||||||
# Run the server
|
|
||||||
UV_CACHE_DIR=/tmp/uv-cache uv run python main.py
|
|
||||||
# Serves on http://127.0.0.1:8000
|
|
||||||
|
|
||||||
# Run all tests
|
|
||||||
UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q
|
|
||||||
|
|
||||||
# Run a single test file
|
|
||||||
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py -q
|
|
||||||
|
|
||||||
# Run a single test
|
|
||||||
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py::test_create_project_endpoint -q
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
|
||||||
|----------|---------|---------|
|
|
||||||
| `ZOTERO_DATA_DIR` | `/mnt/c/Users/WSX/Zotero` | Path to Zotero data directory (must contain `zotero.sqlite`) |
|
|
||||||
| `ZOTERO_KB_WORKSPACE` | `workspace` | Workspace directory for projects, indexes, and cards |
|
|
||||||
| `ZOTERO_BRIDGE_FILE` | `workspace/bridge/selected-items.json` | Bridge file for Zotero-to-project item import |
|
|
||||||
| `DEEPSEEK_API_KEY` | — | Required when projects use `llm_provider=deepseek` |
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Layer Overview
|
|
||||||
|
|
||||||
`api.py` → FastAPI routes → `Workspace` / `ProjectService` / `WritingService` / `ZoteroReader` / `CardBuilder`
|
|
||||||
|
|
||||||
All business logic lives in `src/zotero_kb/`. There is no database migration system; the app creates directories on demand.
|
|
||||||
|
|
||||||
### ZoteroReader (`zotero_reader.py`)
|
|
||||||
|
|
||||||
Reads directly from `zotero.sqlite` via `sqlite3`. Key capabilities:
|
|
||||||
- `read_items(item_keys)` — full record with metadata, creators, tags, notes, attachment texts
|
|
||||||
- `get_collection_tree()` / `get_collection_items(collection_key)` — browse collections hierarchically
|
|
||||||
- `search_items(query)` — title/abstract substring search
|
|
||||||
- PDF text extraction delegates to `pdftotext` CLI; other attachments read as plain text
|
|
||||||
- Attachment paths resolve from `storage:<key>` relative to `ZOTERO_DATA_DIR/storage/`
|
|
||||||
|
|
||||||
### Workspace & Project Model (`workspace.py`, `projects.py`)
|
|
||||||
|
|
||||||
A **project** is a JSON file plus two companion files under `workspace/projects/<project_id>/`:
|
|
||||||
- `project.json` — metadata including `llm.provider`, `llm.model`, `card_language`
|
|
||||||
- `selected-items.json` — ordered list of Zotero item keys belonging to the project
|
|
||||||
- `project-index.json` — cached denormalized view rebuilt on every read
|
|
||||||
|
|
||||||
`Workspace` creates/renames/deletes projects. `ProjectService` manages item selection and rebuilds `project-index.json` by reading three shared indexes:
|
|
||||||
- `workspace/library/index/items.json` — item metadata
|
|
||||||
- `workspace/library/index/cards.json` — generated card data (supports per-language variants)
|
|
||||||
- `workspace/library/index/collections.json` — collection metadata
|
|
||||||
|
|
||||||
### Card Generation (`cards.py`, `llm.py`)
|
|
||||||
|
|
||||||
`CardBuilder.build_or_update(item, language)`:
|
|
||||||
1. Builds a **source bundle** from the item record (metadata + notes + attachment texts)
|
|
||||||
2. Hashes and caches the bundle to `library/cache/source-bundles/`
|
|
||||||
3. Calls `llm_client.generate_card(source_bundle)`
|
|
||||||
4. Renders a Markdown file to `library/collections/<collection_path>/<title> [<key>][<lang>].md`
|
|
||||||
5. Updates `items.json`, `cards.json`, and `collections.json`
|
|
||||||
|
|
||||||
The LLM layer supports two providers:
|
|
||||||
- `deepseek` — calls DeepSeek Chat API, requires `DEEPSEEK_API_KEY`
|
|
||||||
- `deterministic` (fallback) — derives card fields from abstract/notes without any API call
|
|
||||||
|
|
||||||
Cards support **language variants**: `cards.json` stores a map `item_key -> {language -> card_data}`. The `project-index.json` selects the variant matching the project's `card_language`.
|
|
||||||
|
|
||||||
### Writing Support (`writing.py`)
|
|
||||||
|
|
||||||
- `recommend_citations(project_id, prompt)` — simple term-overlap scoring across project cards
|
|
||||||
- `generate_plan(project_id, prompt)` — returns a single-section plan using the top-scored card
|
|
||||||
|
|
||||||
### Bridge Import (`bridge.py`)
|
|
||||||
|
|
||||||
The `ZOTERO_BRIDGE_FILE` (JSON with `selected_keys` array) is read by the `/api/projects/{id}/imports/selected-items` endpoint to import items that were pre-selected in Zotero. This is separate from the UI-driven `/imports/item-keys` endpoint.
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
A single `index.html` (Jinja2 template rendered as static HTML) provides the full UI. It uses vanilla JS to call the REST API. No build step or JS bundler.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Tests use `fastapi.testclient.TestClient` with a `FakeLlmClient` injected via `create_app(config, llm_client=...)`. The fixture builder (`tests/fixtures/build_zotero_fixture.py`) creates an in-memory Zotero SQLite schema with sample data. Most tests extract route handlers directly from the FastAPI app rather than using HTTP-level client calls.
|
|
||||||
|
|
||||||
## Workspace Directory Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
workspace/
|
|
||||||
bridge/
|
|
||||||
selected-items.json # bridge file from Zotero
|
|
||||||
library/
|
|
||||||
index/
|
|
||||||
items.json # item metadata index
|
|
||||||
cards.json # card data index (language variants)
|
|
||||||
collections.json # collection metadata index
|
|
||||||
collections/
|
|
||||||
<collection_path>/
|
|
||||||
<title> [<key>][<lang>].md # generated Markdown cards
|
|
||||||
cache/
|
|
||||||
source-bundles/
|
|
||||||
<key>.<lang>.json # cached source bundles
|
|
||||||
projects/
|
|
||||||
<project_id>/
|
|
||||||
project.json # project config
|
|
||||||
selected-items.json # item keys in this project
|
|
||||||
project-index.json # denormalized project view
|
|
||||||
```
|
|
||||||
|
|
||||||
## Related Subdirectories
|
|
||||||
|
|
||||||
- `zotcard/` — Zotero plugin (separate JS project, not part of the Python service)
|
|
||||||
- `zotero-rag/` — Standalone RAG search service (separate project)
|
|
||||||
- `zotero-bridge/` — Zotero plugin bridge component
|
|
||||||
- `skills/zotero-citation-planner/` — Claude skill for reading project indexes and generating citation plans
|
|
||||||
76
README.md
76
README.md
@ -1,76 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@ -1,705 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,601 +0,0 @@
|
|||||||
# 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`
|
|
||||||
@ -1,711 +0,0 @@
|
|||||||
# 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`
|
|
||||||
@ -1,378 +0,0 @@
|
|||||||
# 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"
|
|
||||||
```
|
|
||||||
@ -1,519 +0,0 @@
|
|||||||
# 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"
|
|
||||||
```
|
|
||||||
@ -1,254 +0,0 @@
|
|||||||
# Import Window Responsive Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Make the floating import window recompute a viewport-appropriate size and position every time it opens, and stack collection/item panes vertically on narrower viewports.
|
|
||||||
|
|
||||||
**Architecture:** Keep the existing floating-window shell and collection importer flow, but replace reopen-time geometry reuse with viewport-derived geometry helpers. Add a narrow-layout breakpoint in the template CSS and a resize handler that constrains normal-mode geometry without overriding minimized or maximized state.
|
|
||||||
|
|
||||||
**Tech Stack:** FastAPI template rendering, inline HTML/CSS/JavaScript, pytest, Node syntax checking
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Update UI Contract Tests For Responsive Window Behavior
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `tests/test_ui.py`
|
|
||||||
- Test: `tests/test_ui.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_index_has_responsive_import_window_geometry_helpers(tmp_path) -> None:
|
|
||||||
html = _get_index_html(tmp_path)
|
|
||||||
|
|
||||||
assert "function computeResponsiveWindowRect()" in html
|
|
||||||
assert "function applyResponsiveWindowRect()" in html
|
|
||||||
assert "function syncWindowToViewport()" in html
|
|
||||||
assert "window.addEventListener(\"resize\"" in html
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_index_has_responsive_import_window_layout_rules(tmp_path) -> None:
|
|
||||||
html = _get_index_html(tmp_path)
|
|
||||||
|
|
||||||
assert ".window-pane.items" in html
|
|
||||||
assert "@media (max-width: 980px)" in html
|
|
||||||
assert ".window-body {" in html
|
|
||||||
assert "grid-template-columns: 1fr;" in html
|
|
||||||
assert "border-bottom: 1px solid var(--line);" in html
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q`
|
|
||||||
Expected: FAIL because the new responsive geometry helper names are not present yet.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Keep the existing syntax guard test**
|
|
||||||
|
|
||||||
Do not remove `test_index_inline_script_is_valid_javascript`; it remains the regression guard for the inline script.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run test to verify the contract file still parses**
|
|
||||||
|
|
||||||
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_inline_script_is_valid_javascript -q`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add tests/test_ui.py
|
|
||||||
git commit -m "test: define responsive import window contract"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task 2: Implement Viewport-Derived Window Geometry
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/zotero_kb/templates/index.html`
|
|
||||||
- Test: `tests/test_ui.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Use the Task 1 tests as the failing contract. Do not add production code first.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the failing test**
|
|
||||||
|
|
||||||
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q`
|
|
||||||
Expected: FAIL with missing responsive helper function names or missing layout assertions.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Write minimal implementation**
|
|
||||||
|
|
||||||
Add responsive geometry helpers to the inline script in `src/zotero_kb/templates/index.html`:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
function computeResponsiveWindowRect() {
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
const margin = viewportWidth <= 980 ? 8 : 24;
|
|
||||||
const minWidth = viewportWidth <= 980 ? 320 : 640;
|
|
||||||
const minHeight = viewportHeight <= 980 ? 420 : 480;
|
|
||||||
const maxWidth = Math.max(minWidth, viewportWidth - margin * 2);
|
|
||||||
const maxHeight = Math.max(minHeight, viewportHeight - margin * 2);
|
|
||||||
const width = Math.min(maxWidth, Math.max(minWidth, Math.round(viewportWidth * 0.88)));
|
|
||||||
const height = Math.min(maxHeight, Math.max(minHeight, Math.round(viewportHeight * 0.82)));
|
|
||||||
const left = Math.max(margin, Math.round((viewportWidth - width) / 2));
|
|
||||||
const top = Math.max(margin, Math.round((viewportHeight - height) / 2));
|
|
||||||
return { width, height, left, top };
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyResponsiveWindowRect() {
|
|
||||||
const fw = elements.importWindow;
|
|
||||||
if (!fw || state.isWindowMinimized || state.isWindowMaximized) return;
|
|
||||||
const rect = computeResponsiveWindowRect();
|
|
||||||
fw.style.width = `${rect.width}px`;
|
|
||||||
fw.style.height = `${rect.height}px`;
|
|
||||||
fw.style.left = `${rect.left}px`;
|
|
||||||
fw.style.top = `${rect.top}px`;
|
|
||||||
state.windowWidth = rect.width;
|
|
||||||
state.windowHeight = rect.height;
|
|
||||||
state.windowX = rect.left;
|
|
||||||
state.windowY = rect.top;
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncWindowToViewport() {
|
|
||||||
const fw = elements.importWindow;
|
|
||||||
if (!fw || state.isWindowMinimized || state.isWindowMaximized) return;
|
|
||||||
const margin = window.innerWidth <= 980 ? 8 : 24;
|
|
||||||
const rect = computeResponsiveWindowRect();
|
|
||||||
const currentWidth = parseFloat(fw.style.width) || rect.width;
|
|
||||||
const currentHeight = parseFloat(fw.style.height) || rect.height;
|
|
||||||
const width = Math.min(currentWidth, rect.width);
|
|
||||||
const height = Math.min(currentHeight, rect.height);
|
|
||||||
const maxLeft = Math.max(margin, window.innerWidth - width - margin);
|
|
||||||
const maxTop = Math.max(margin, window.innerHeight - height - margin);
|
|
||||||
const left = Math.min(Math.max(parseFloat(fw.style.left) || rect.left, margin), maxLeft);
|
|
||||||
const top = Math.min(Math.max(parseFloat(fw.style.top) || rect.top, margin), maxTop);
|
|
||||||
|
|
||||||
fw.style.width = `${width}px`;
|
|
||||||
fw.style.height = `${height}px`;
|
|
||||||
fw.style.left = `${left}px`;
|
|
||||||
fw.style.top = `${top}px`;
|
|
||||||
state.windowWidth = width;
|
|
||||||
state.windowHeight = height;
|
|
||||||
state.windowX = left;
|
|
||||||
state.windowY = top;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Update `openImportWindow()` to call `applyResponsiveWindowRect()` after `initWindowFromSession()` when the window is not minimized or maximized.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the focused tests**
|
|
||||||
|
|
||||||
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/zotero_kb/templates/index.html tests/test_ui.py
|
|
||||||
git commit -m "feat: derive import window geometry from viewport"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task 3: Add Responsive Layout Switching And Resize Wiring
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/zotero_kb/templates/index.html`
|
|
||||||
- Test: `tests/test_ui.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Use the layout assertions from Task 1 as the failing contract. No new production code before running them.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the failing test**
|
|
||||||
|
|
||||||
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q`
|
|
||||||
Expected: FAIL if the resize listener or stacked layout rules are still missing.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Write minimal implementation**
|
|
||||||
|
|
||||||
Extend the existing media query and event wiring in `src/zotero_kb/templates/index.html`:
|
|
||||||
|
|
||||||
```css
|
|
||||||
@media (max-width: 980px) {
|
|
||||||
.window-frame {
|
|
||||||
min-width: 0;
|
|
||||||
width: calc(100vw - 1rem);
|
|
||||||
height: calc(100vh - 1rem);
|
|
||||||
left: 0.5rem;
|
|
||||||
top: 0.5rem;
|
|
||||||
}
|
|
||||||
.window-toolbar,
|
|
||||||
.window-toolbar-actions {
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
.window-body {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.window-pane.collections {
|
|
||||||
border-right: 0;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Wire the resize handler:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
window.addEventListener("resize", () => {
|
|
||||||
syncWindowToViewport();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the focused tests**
|
|
||||||
|
|
||||||
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/zotero_kb/templates/index.html tests/test_ui.py
|
|
||||||
git commit -m "feat: make import window layout responsive"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task 4: End-To-End Verification
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: none
|
|
||||||
- Test: `tests/test_ui.py`, `tests/test_api.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Run the UI test suite**
|
|
||||||
|
|
||||||
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q`
|
|
||||||
Expected: PASS with all UI contract tests green.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the API regression suite**
|
|
||||||
|
|
||||||
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py -q`
|
|
||||||
Expected: PASS with all API tests green.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run fresh JS syntax verification through pytest**
|
|
||||||
|
|
||||||
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_inline_script_is_valid_javascript -q`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 4: Manual verification**
|
|
||||||
|
|
||||||
Run the app:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
UV_CACHE_DIR=/tmp/uv-cache uv run python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Manual checks:
|
|
||||||
|
|
||||||
- open the import window at one browser zoom level and note the size
|
|
||||||
- close it, change browser zoom, reopen it, confirm it recenters and resizes
|
|
||||||
- shrink the viewport below the narrow breakpoint, reopen it, confirm the panes stack vertically
|
|
||||||
- maximize and minimize the window, confirm those modes still behave correctly
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/zotero_kb/templates/index.html tests/test_ui.py docs/superpowers/specs/2026-04-16-import-window-responsive-design.md docs/superpowers/plans/2026-04-16-import-window-responsive.md
|
|
||||||
git commit -m "feat: make import window adapt to viewport changes"
|
|
||||||
```
|
|
||||||
@ -1,406 +0,0 @@
|
|||||||
# Project Items And Inline Card Detail Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Show imported project items immediately after import, improve collection row readability, and render item detail inline under the active entry instead of in a fixed detail area.
|
|
||||||
|
|
||||||
**Architecture:** Normalize project view data around a single `items` list in `ProjectService`, with each item carrying `card_status` plus card fields when available. Update the center panel renderer in the inline template script to render both pending and done items, and expand one item’s detail inline at a time.
|
|
||||||
|
|
||||||
**Tech Stack:** Python, FastAPI template rendering, inline HTML/CSS/JavaScript, pytest
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Define Pending-Item Project View Contract
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `tests/test_projects.py`
|
|
||||||
- Modify: `tests/test_api.py`
|
|
||||||
- Test: `tests/test_projects.py`
|
|
||||||
- Test: `tests/test_api.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Add to `tests/test_projects.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_add_item_to_project_returns_pending_item_when_card_not_generated(tmp_path: Path) -> None:
|
|
||||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
|
||||||
workspace = Workspace(config)
|
|
||||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini")
|
|
||||||
|
|
||||||
items_index_path = config.workspace_dir / "library" / "index" / "items.json"
|
|
||||||
items_index_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
items_index_path.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"PAPER0001": {
|
|
||||||
"item_key": "PAPER0001",
|
|
||||||
"title": "Pending Item",
|
|
||||||
"creators": ["Alice Smith"],
|
|
||||||
"year": "2024",
|
|
||||||
"item_type": "journalArticle",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
service = ProjectService(config.workspace_dir)
|
|
||||||
payload = service.add_items("thesis-ch2", ["PAPER0001"])
|
|
||||||
|
|
||||||
assert payload["selected_items"] == ["PAPER0001"]
|
|
||||||
assert len(payload["items"]) == 1
|
|
||||||
assert payload["items"][0]["item_key"] == "PAPER0001"
|
|
||||||
assert payload["items"][0]["card_status"] == "pending"
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to `tests/test_api.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_import_item_keys_endpoint_returns_pending_item_in_project_view(tmp_path: Path) -> None:
|
|
||||||
config = make_test_config(tmp_path)
|
|
||||||
config.bridge_file.unlink()
|
|
||||||
app = create_app(config, llm_client=FakeLlmClient())
|
|
||||||
create_project = _route(app, "/api/projects", "POST")
|
|
||||||
import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST")
|
|
||||||
|
|
||||||
create_project(
|
|
||||||
CreateProjectRequest(
|
|
||||||
project_id="thesis-ch2",
|
|
||||||
name="Thesis Chapter 2",
|
|
||||||
llm_provider="openai",
|
|
||||||
llm_model="gpt-5-mini",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
|
|
||||||
|
|
||||||
assert payload["project_view"]["selected_items"] == ["PAPER0001"]
|
|
||||||
assert payload["project_view"]["items"][0]["item_key"] == "PAPER0001"
|
|
||||||
assert payload["project_view"]["items"][0]["card_status"] == "pending"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py -q`
|
|
||||||
Expected: FAIL because project view currently only returns `cards`, not normalized `items`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Write minimal implementation**
|
|
||||||
|
|
||||||
Modify `src/zotero_kb/projects.py` so `_rebuild_project_index()` returns normalized items for both pending and done entries:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _rebuild_project_index(self, project_id: str, selected_items: list[str]) -> dict[str, object]:
|
|
||||||
cards_index = self._read_json(self.index_dir / "cards.json")
|
|
||||||
items_index = self._read_json(self.index_dir / "items.json")
|
|
||||||
collections_index = self._read_json(self.index_dir / "collections.json")
|
|
||||||
|
|
||||||
project_items: list[dict[str, object]] = []
|
|
||||||
project_cards: list[dict[str, object]] = []
|
|
||||||
for item_key in selected_items:
|
|
||||||
item_data = items_index.get(item_key, {})
|
|
||||||
card_data = cards_index.get(item_key)
|
|
||||||
item_payload = {
|
|
||||||
"item_key": item_key,
|
|
||||||
"title": item_data.get("title", "Untitled"),
|
|
||||||
"creators": item_data.get("creators", []),
|
|
||||||
"year": item_data.get("year"),
|
|
||||||
"item_type": item_data.get("item_type", "unknown"),
|
|
||||||
"card_status": "done" if card_data else "pending",
|
|
||||||
"summary": card_data.get("summary") if card_data else None,
|
|
||||||
"claims": card_data.get("claims", []) if card_data else [],
|
|
||||||
"quotable_spans": card_data.get("quotable_spans", []) if card_data else [],
|
|
||||||
}
|
|
||||||
project_items.append(item_payload)
|
|
||||||
if card_data:
|
|
||||||
project_cards.append(card_data)
|
|
||||||
|
|
||||||
project_collections = [
|
|
||||||
payload
|
|
||||||
for payload in collections_index.values()
|
|
||||||
if set(payload.get("item_keys", [])) & set(selected_items)
|
|
||||||
]
|
|
||||||
payload = {
|
|
||||||
"project_id": project_id,
|
|
||||||
"selected_items": selected_items,
|
|
||||||
"items": project_items,
|
|
||||||
"cards": project_cards,
|
|
||||||
"collections": project_collections,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run test to verify it passes**
|
|
||||||
|
|
||||||
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py -q`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add tests/test_projects.py tests/test_api.py src/zotero_kb/projects.py
|
|
||||||
git commit -m "feat: add pending project items to project view"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task 2: Define UI Contract For Inline Project Item Detail
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `tests/test_ui.py`
|
|
||||||
- Test: `tests/test_ui.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Add to `tests/test_ui.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_index_has_project_item_list_and_inline_detail_hooks(tmp_path) -> None:
|
|
||||||
html = _get_index_html(tmp_path)
|
|
||||||
|
|
||||||
assert 'id="project-item-list"' in html
|
|
||||||
assert "expandedProjectItemKey" in html
|
|
||||||
assert "function renderProjectItems(items)" in html
|
|
||||||
assert "function toggleProjectItemDetail(itemKey)" in html
|
|
||||||
assert 'class="project-item-detail"' in html
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_index_collection_rows_use_readable_unselected_text(tmp_path) -> None:
|
|
||||||
html = _get_index_html(tmp_path)
|
|
||||||
|
|
||||||
assert ".project-item strong" in html
|
|
||||||
assert ".project-item .meta" in html
|
|
||||||
assert ".collection-row-toggle" in html
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q`
|
|
||||||
Expected: FAIL because the page still renders only cards and uses the fixed detail region.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Write minimal implementation**
|
|
||||||
|
|
||||||
Update `src/zotero_kb/templates/index.html`:
|
|
||||||
|
|
||||||
- replace the center list container with:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div id="project-item-list" class="card-list"></div>
|
|
||||||
```
|
|
||||||
|
|
||||||
- replace the fixed detail surface with a passive note:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="section surface">
|
|
||||||
<h3>项目状态</h3>
|
|
||||||
<div class="empty">导入后,项目条目会立即出现在上方列表;点击条目可就地展开详情。</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
- add state:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
expandedProjectItemKey: null,
|
|
||||||
```
|
|
||||||
|
|
||||||
- add renderers:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
function toggleProjectItemDetail(itemKey) {
|
|
||||||
state.expandedProjectItemKey = state.expandedProjectItemKey === itemKey ? null : itemKey;
|
|
||||||
renderProjectItems(state.currentProjectItems);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
function renderProjectItems(items) {
|
|
||||||
state.currentProjectItems = items || [];
|
|
||||||
if (!state.currentProjectItems.length) {
|
|
||||||
elements.projectItemList.innerHTML = '<div class="empty">当前项目还没有条目。先导入 Zotero 文献。</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.projectItemList.innerHTML = "";
|
|
||||||
for (const item of state.currentProjectItems) {
|
|
||||||
const card = document.createElement("div");
|
|
||||||
card.className = "card-item";
|
|
||||||
const statusLabel = item.card_status === "done" ? "已生成卡片" : "未生成卡片";
|
|
||||||
card.innerHTML = `
|
|
||||||
<strong>${escapeHtml(item.title)}</strong>
|
|
||||||
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
|
|
||||||
<p>${item.card_status === "done" ? (item.summary || "暂无摘要") : "已导入项目,尚未生成卡片。"}</p>
|
|
||||||
<div class="row">
|
|
||||||
<button type="button" class="secondary">查看详情</button>
|
|
||||||
<button type="button" class="${item.card_status === "done" ? "secondary" : ""}">${statusLabel}</button>
|
|
||||||
<button type="button" class="danger">移出项目</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
const [detailButton, statusButton, removeButton] = card.querySelectorAll("button");
|
|
||||||
detailButton.addEventListener("click", () => toggleProjectItemDetail(item.item_key));
|
|
||||||
statusButton.disabled = true;
|
|
||||||
removeButton.addEventListener("click", () => removeCard(item.item_key));
|
|
||||||
|
|
||||||
if (state.expandedProjectItemKey === item.item_key) {
|
|
||||||
const detail = document.createElement("div");
|
|
||||||
detail.className = "project-item-detail";
|
|
||||||
detail.innerHTML = item.card_status === "done"
|
|
||||||
? `
|
|
||||||
<div class="meta">${item.item_key}</div>
|
|
||||||
<p>${item.summary || "暂无摘要"}</p>
|
|
||||||
<h4>Claims</h4>
|
|
||||||
<ul>${(item.claims || []).map((claim) => `<li>${escapeHtml(claim)}</li>`).join("") || "<li>暂无 claims</li>"}</ul>
|
|
||||||
<h4>Quotable</h4>
|
|
||||||
<ul>${(item.quotable_spans || []).map((quote) => `<li>${escapeHtml(quote)}</li>`).join("") || "<li>暂无可引用片段</li>"}</ul>
|
|
||||||
`
|
|
||||||
: `
|
|
||||||
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
|
|
||||||
<p>这篇文献已经导入到当前项目,但还没有生成卡片内容。</p>
|
|
||||||
`;
|
|
||||||
card.appendChild(detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.projectItemList.appendChild(card);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- add CSS:
|
|
||||||
|
|
||||||
```css
|
|
||||||
.project-item strong,
|
|
||||||
.project-item .meta {
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
.project-item.active strong,
|
|
||||||
.project-item.active .meta {
|
|
||||||
color: #f5f7ef;
|
|
||||||
}
|
|
||||||
.collection-row-toggle {
|
|
||||||
color: var(--accent-strong);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.project-item-detail {
|
|
||||||
margin-top: 0.85rem;
|
|
||||||
padding-top: 0.85rem;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run test to verify it passes**
|
|
||||||
|
|
||||||
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add tests/test_ui.py src/zotero_kb/templates/index.html
|
|
||||||
git commit -m "feat: render project items with inline detail"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task 3: Wire Project Item Rendering Into Import And Project Selection Flows
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/zotero_kb/templates/index.html`
|
|
||||||
- Test: `tests/test_api.py`
|
|
||||||
- Test: `tests/test_ui.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Reuse the UI/API failures from Tasks 1 and 2. No new production code first.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify the current behavior fails**
|
|
||||||
|
|
||||||
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py tests/test_ui.py -q`
|
|
||||||
Expected: FAIL until the project list renderer uses `items`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Write minimal implementation**
|
|
||||||
|
|
||||||
Update the inline script in `src/zotero_kb/templates/index.html`:
|
|
||||||
|
|
||||||
- state:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
currentProjectItems: [],
|
|
||||||
```
|
|
||||||
|
|
||||||
- elements:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
projectItemList: document.getElementById("project-item-list"),
|
|
||||||
```
|
|
||||||
|
|
||||||
- remove `showCardDetail()` and stop using `elements.cardDetail`
|
|
||||||
|
|
||||||
- change empty-state and load paths:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
async function selectProject(projectId, rerender = true) {
|
|
||||||
state.currentProjectId = projectId;
|
|
||||||
updateImportActionState();
|
|
||||||
if (rerender) {
|
|
||||||
renderProjects();
|
|
||||||
}
|
|
||||||
const payload = await api(`/api/projects/${projectId}/cards`);
|
|
||||||
renderProjectItems(payload.items || []);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
renderProjectItems(payload.project_view?.items || []);
|
|
||||||
```
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
renderProjectItems([]);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run full targeted tests**
|
|
||||||
|
|
||||||
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py tests/test_ui.py -q`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/zotero_kb/templates/index.html tests/test_projects.py tests/test_api.py tests/test_ui.py
|
|
||||||
git commit -m "feat: show imported project items before card generation"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task 4: Final Verification
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: none
|
|
||||||
- Test: `tests/test_projects.py`
|
|
||||||
- Test: `tests/test_api.py`
|
|
||||||
- Test: `tests/test_ui.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Run full test suite**
|
|
||||||
|
|
||||||
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run JS syntax guard**
|
|
||||||
|
|
||||||
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_inline_script_is_valid_javascript -q`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 3: Manual verification**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
UV_CACHE_DIR=/tmp/uv-cache uv run python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Manual checks:
|
|
||||||
|
|
||||||
- unselected collections remain readable in the import window
|
|
||||||
- importing an item into a project makes it appear immediately in the center panel
|
|
||||||
- pending item detail expands inline below the clicked item
|
|
||||||
- generated item detail expands inline below the clicked item
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/zotero_kb/projects.py src/zotero_kb/templates/index.html tests/test_projects.py tests/test_api.py tests/test_ui.py docs/superpowers/specs/2026-04-16-project-items-and-inline-card-detail-design.md docs/superpowers/plans/2026-04-16-project-items-and-inline-card-detail.md
|
|
||||||
git commit -m "feat: show project items inline before card generation"
|
|
||||||
```
|
|
||||||
@ -1,627 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
# 2026-04-16 Import Window Responsive Design
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Make the floating import window adapt to the current browser viewport automatically so the user does not need to manually resize it after changing browser zoom or window size.
|
|
||||||
|
|
||||||
The floating window shell remains:
|
|
||||||
|
|
||||||
- draggable
|
|
||||||
- minimizable
|
|
||||||
- maximizable
|
|
||||||
- closable
|
|
||||||
|
|
||||||
The import content remains the restored collection importer:
|
|
||||||
|
|
||||||
- left pane: Zotero collection tree
|
|
||||||
- right pane: collection items
|
|
||||||
|
|
||||||
## User-Approved Behavior
|
|
||||||
|
|
||||||
### Window sizing policy
|
|
||||||
|
|
||||||
- Each time the import window opens, it recalculates its size and position from the current viewport.
|
|
||||||
- Previously saved manual width, height, and position are not reused on reopen.
|
|
||||||
- The window opens centered in the viewport.
|
|
||||||
- The default size is viewport-relative, using a large but bounded footprint.
|
|
||||||
|
|
||||||
Recommended sizing rule:
|
|
||||||
|
|
||||||
- width: about `88vw`
|
|
||||||
- height: about `82vh`
|
|
||||||
- clamp width and height to safe min/max values so content remains usable on smaller screens
|
|
||||||
|
|
||||||
### Resize and zoom behavior
|
|
||||||
|
|
||||||
- Browser zoom changes are treated the same as viewport changes.
|
|
||||||
- On `resize`, if the window is in normal mode, its dimensions and position are adjusted to remain visible within the viewport.
|
|
||||||
- If the window is maximized, existing maximize behavior remains authoritative.
|
|
||||||
- If the window is minimized, existing minimize behavior remains authoritative.
|
|
||||||
|
|
||||||
### Layout adaptation
|
|
||||||
|
|
||||||
- Wide viewport: keep the current two-pane horizontal layout.
|
|
||||||
- Narrow viewport: switch the import window body to a vertical stack.
|
|
||||||
- In stacked mode:
|
|
||||||
- top pane: collections
|
|
||||||
- bottom pane: collection items
|
|
||||||
|
|
||||||
This avoids crushed side-by-side panes and avoids requiring horizontal scrolling.
|
|
||||||
|
|
||||||
## Implementation Design
|
|
||||||
|
|
||||||
### State model
|
|
||||||
|
|
||||||
Keep the current floating-window state structure, but change how normal-mode geometry is derived:
|
|
||||||
|
|
||||||
- persisted maximize/minimize flags can remain
|
|
||||||
- persisted normal-mode width/height/left/top are no longer the source of truth on reopen
|
|
||||||
- on open, recompute width, height, left, and top from the current viewport
|
|
||||||
|
|
||||||
### New geometry helpers
|
|
||||||
|
|
||||||
Add small helpers in the template script:
|
|
||||||
|
|
||||||
- `computeResponsiveWindowRect()`
|
|
||||||
- derives width/height/left/top from `window.innerWidth` and `window.innerHeight`
|
|
||||||
- clamps to minimum and maximum bounds
|
|
||||||
- returns a centered rect
|
|
||||||
- `applyResponsiveWindowRect()`
|
|
||||||
- applies the computed rect to the floating window when in normal mode
|
|
||||||
- `syncWindowToViewport()`
|
|
||||||
- runs on resize
|
|
||||||
- keeps the window inside the visible viewport
|
|
||||||
- does nothing destructive when minimized or maximized
|
|
||||||
|
|
||||||
### Layout switching
|
|
||||||
|
|
||||||
Use CSS plus a narrow-width breakpoint for `.window-body`:
|
|
||||||
|
|
||||||
- default: `grid-template-columns: 22rem minmax(0, 1fr)`
|
|
||||||
- narrow mode: `grid-template-columns: 1fr`
|
|
||||||
|
|
||||||
The existing `.window-pane.collections` separator changes from right border to bottom border in stacked mode.
|
|
||||||
|
|
||||||
### Interaction rules
|
|
||||||
|
|
||||||
- Opening the window always recomputes the normal-mode rect.
|
|
||||||
- Manual dragging still works during the current open session.
|
|
||||||
- If the viewport changes while the window is open, normal mode is re-constrained to the viewport.
|
|
||||||
- Closing and reopening discards the session’s manual geometry and recomputes from the viewport again.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Add or update UI tests to verify:
|
|
||||||
|
|
||||||
- the responsive helper logic is present in the inline script
|
|
||||||
- opening the window uses viewport-based sizing instead of reopening from stale manual geometry
|
|
||||||
- the template contains the narrow-layout CSS for stacked panes
|
|
||||||
- the inline script remains valid JavaScript
|
|
||||||
|
|
||||||
Manual verification target:
|
|
||||||
|
|
||||||
- open import window at normal zoom
|
|
||||||
- change browser zoom or viewport size
|
|
||||||
- close and reopen
|
|
||||||
- confirm the window opens centered and proportionate to the new viewport
|
|
||||||
- confirm narrow viewport stacks collections above items
|
|
||||||
|
|
||||||
## Risks and Mitigations
|
|
||||||
|
|
||||||
### Risk: resize fights user drag
|
|
||||||
|
|
||||||
Mitigation:
|
|
||||||
|
|
||||||
- only recompute automatically on open
|
|
||||||
- on live resize, constrain only enough to keep the window visible
|
|
||||||
|
|
||||||
### Risk: minimized/maximized modes get overwritten
|
|
||||||
|
|
||||||
Mitigation:
|
|
||||||
|
|
||||||
- gate responsive normal-mode logic behind checks for non-minimized and non-maximized state
|
|
||||||
|
|
||||||
### Risk: small screens become unusable
|
|
||||||
|
|
||||||
Mitigation:
|
|
||||||
|
|
||||||
- stack panes vertically below the chosen breakpoint
|
|
||||||
- clamp dimensions and leave a small viewport margin
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
# 2026-04-16 Project Items And Inline Card Detail Design
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Fix three related UI/data issues:
|
|
||||||
|
|
||||||
- collection 列表里未选中项文字太浅,保证未选中时也清晰可读
|
|
||||||
- 文献导入项目后,项目主区要立刻显示这些条目,而不是等生成卡片后才出现
|
|
||||||
- 卡片/条目详情改成在当前条目下方展开,不再集中显示在固定详情区域
|
|
||||||
|
|
||||||
## Approved Direction
|
|
||||||
|
|
||||||
Use a single project-scoped item list as the main source of truth in the center panel.
|
|
||||||
|
|
||||||
Each project item carries a `card_status`:
|
|
||||||
|
|
||||||
- `pending`: 已导入项目,但还没生成卡片
|
|
||||||
- `done`: 已生成卡片
|
|
||||||
|
|
||||||
The center panel renders all project items, not only completed cards.
|
|
||||||
|
|
||||||
## Data Model
|
|
||||||
|
|
||||||
### Backend project view
|
|
||||||
|
|
||||||
`ProjectService.get_project_view()` should return:
|
|
||||||
|
|
||||||
- `project_id`
|
|
||||||
- `selected_items`
|
|
||||||
- `items`
|
|
||||||
- `collections`
|
|
||||||
|
|
||||||
Each `items[]` element should contain:
|
|
||||||
|
|
||||||
- `item_key`
|
|
||||||
- `title`
|
|
||||||
- `creators`
|
|
||||||
- `year`
|
|
||||||
- `item_type`
|
|
||||||
- `card_status`
|
|
||||||
- card fields when available:
|
|
||||||
- `summary`
|
|
||||||
- `claims`
|
|
||||||
- `quotable_spans`
|
|
||||||
|
|
||||||
This makes imported-but-not-generated items visible immediately after import.
|
|
||||||
|
|
||||||
### Status semantics
|
|
||||||
|
|
||||||
- imported via `imports/item-keys` and not yet generated => `pending`
|
|
||||||
- present in `cards.json` => `done`
|
|
||||||
|
|
||||||
## UI Behavior
|
|
||||||
|
|
||||||
### Collection tree readability
|
|
||||||
|
|
||||||
Unselected collection cards keep the current light theme, but their text contrast must be increased:
|
|
||||||
|
|
||||||
- title remains readable on light background
|
|
||||||
- meta line remains readable without relying on hover or selection state
|
|
||||||
|
|
||||||
### Center panel item list
|
|
||||||
|
|
||||||
Replace the current “only cards” rendering with a project item list:
|
|
||||||
|
|
||||||
- `pending` items render as project entries with a clear “未生成卡片” state
|
|
||||||
- `done` items render as completed card entries
|
|
||||||
- remove the assumption that invisible means “not imported”
|
|
||||||
|
|
||||||
### Inline detail expansion
|
|
||||||
|
|
||||||
Clicking “查看详情” or the item itself expands detail directly below that same entry.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- only one item detail is expanded at a time
|
|
||||||
- clicking the same item again collapses it
|
|
||||||
- clicking another item moves the expanded detail to that item
|
|
||||||
|
|
||||||
### Expanded detail content
|
|
||||||
|
|
||||||
For `pending`:
|
|
||||||
|
|
||||||
- title
|
|
||||||
- item key / year / type
|
|
||||||
- a short message that the item is already in the project but card generation has not happened yet
|
|
||||||
|
|
||||||
For `done`:
|
|
||||||
|
|
||||||
- existing summary
|
|
||||||
- claims
|
|
||||||
- quotable spans
|
|
||||||
|
|
||||||
## Frontend Structure
|
|
||||||
|
|
||||||
### State
|
|
||||||
|
|
||||||
Add a current expanded item key in the page script, e.g.:
|
|
||||||
|
|
||||||
- `expandedProjectItemKey`
|
|
||||||
|
|
||||||
### Rendering
|
|
||||||
|
|
||||||
Replace the current `renderCards()` + fixed `showCardDetail()` pattern with:
|
|
||||||
|
|
||||||
- a renderer for all project items
|
|
||||||
- inline expansion markup inserted under the active item
|
|
||||||
|
|
||||||
The fixed side detail area can be removed or converted into a passive empty state, because detail no longer lives there.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Add or update tests to cover:
|
|
||||||
|
|
||||||
- project view includes imported pending items
|
|
||||||
- index template still contains the main page and valid inline script
|
|
||||||
- center panel contract no longer depends on a fixed detail-only area for current item detail
|
|
||||||
- inline project item detail hooks exist in the template script
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
### Risk: center panel mixes two incompatible data shapes
|
|
||||||
|
|
||||||
Mitigation:
|
|
||||||
|
|
||||||
- make backend project view emit a single normalized `items` list
|
|
||||||
|
|
||||||
### Risk: pending items have no detail data
|
|
||||||
|
|
||||||
Mitigation:
|
|
||||||
|
|
||||||
- pending inline detail shows metadata plus explicit status messaging, not empty summary sections
|
|
||||||
|
|
||||||
### Risk: old fixed detail panel becomes misleading
|
|
||||||
|
|
||||||
Mitigation:
|
|
||||||
|
|
||||||
- remove dependence on it from the item interaction flow
|
|
||||||
5
main.py
5
main.py
@ -1,5 +0,0 @@
|
|||||||
from zotero_kb.main import main
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
[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"]
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
---
|
|
||||||
name: zotero-cite
|
|
||||||
description: >
|
|
||||||
从指定的 Zotero KB 项目中检索知识卡片,为论文写作推荐最合适的引用文献。
|
|
||||||
只能访问用户明确指定的项目,严禁读取其他项目。
|
|
||||||
---
|
|
||||||
|
|
||||||
# Zotero Citation Skill
|
|
||||||
|
|
||||||
Use this skill when the user needs citations from a specific Zotero KB project while writing.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- **ONLY** read the project explicitly specified by the user.
|
|
||||||
- NEVER guess a project_id. If the user does not provide one, ask for it.
|
|
||||||
- NEVER read other projects' indexes unless the user explicitly asks.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
1. **Get project_id** from the user if not provided.
|
|
||||||
2. **Read** `workspace/projects/{project_id}/project.json` to confirm the project exists and get its name.
|
|
||||||
3. **Read** `workspace/projects/{project_id}/project-index.json` to get all cards in the project.
|
|
||||||
4. **Build context** from the cards:
|
|
||||||
- title, summary, claims, citations, quotable_spans, attachments
|
|
||||||
- creator names and publication year (from `items` array)
|
|
||||||
5. **Analyze the user's writing context** (the sentence/paragraph/section they are working on).
|
|
||||||
6. **Recommend 1–5 most relevant citations** from the project's cards only.
|
|
||||||
7. **Format the response** in Chinese (matching the card language if it is zh).
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
|
|
||||||
For each recommended citation, provide:
|
|
||||||
|
|
||||||
```
|
|
||||||
### 推荐引用 {n}
|
|
||||||
- **文献标题**: {title}
|
|
||||||
- **作者**: {creators}
|
|
||||||
- **年份**: {year}
|
|
||||||
- **item_key**: {item_key}(用于插入引用标记)
|
|
||||||
- **相关性说明**: {why this card matches the writing context}
|
|
||||||
- **可用论点**: {1–2 key claims from the card}
|
|
||||||
- **可直接引用的原文**: {quotable_spans if any}
|
|
||||||
- **建议的修辞角色**: {supporting evidence / counterpoint / background / methodological reference / etc.}
|
|
||||||
- **引用建议**: {a concrete suggestion for how to weave this citation into the user's text}
|
|
||||||
```
|
|
||||||
|
|
||||||
If no relevant card is found, state clearly that the current project does not contain matching literature, and suggest either:
|
|
||||||
- broadening the writing topic, or
|
|
||||||
- importing more items into the project.
|
|
||||||
|
|
||||||
## Cardinal Rule
|
|
||||||
|
|
||||||
> The user has curated this project for a reason. Do NOT suggest citations from outside the project index, even if you know the literature personally.
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
"""Zotero KB package."""
|
|
||||||
|
|
||||||
@ -21,12 +21,6 @@ class CreateProjectRequest(BaseModel):
|
|||||||
name: str = Field(min_length=1)
|
name: str = Field(min_length=1)
|
||||||
llm_provider: str = "openai"
|
llm_provider: str = "openai"
|
||||||
llm_model: str = "gpt-5-mini"
|
llm_model: str = "gpt-5-mini"
|
||||||
card_language: str = "zh"
|
|
||||||
|
|
||||||
|
|
||||||
class RenameProjectRequest(BaseModel):
|
|
||||||
name: str = Field(min_length=1)
|
|
||||||
card_language: str = "zh"
|
|
||||||
|
|
||||||
|
|
||||||
class WritingPromptRequest(BaseModel):
|
class WritingPromptRequest(BaseModel):
|
||||||
@ -41,10 +35,6 @@ class ImportItemKeysRequest(BaseModel):
|
|||||||
item_keys: list[str] = Field(min_length=1)
|
item_keys: list[str] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
class GenerateCardsRequest(BaseModel):
|
|
||||||
item_keys: list[str] = Field(min_length=1)
|
|
||||||
|
|
||||||
|
|
||||||
def create_app(
|
def create_app(
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
*,
|
*,
|
||||||
@ -67,7 +57,6 @@ def create_app(
|
|||||||
name=payload.name,
|
name=payload.name,
|
||||||
llm_provider=payload.llm_provider,
|
llm_provider=payload.llm_provider,
|
||||||
llm_model=payload.llm_model,
|
llm_model=payload.llm_model,
|
||||||
card_language=payload.card_language,
|
|
||||||
)
|
)
|
||||||
return {"id": project.project_id, "name": project.name}
|
return {"id": project.project_id, "name": project.name}
|
||||||
|
|
||||||
@ -83,26 +72,6 @@ def create_app(
|
|||||||
result.append(_read_json(project_file))
|
result.append(_read_json(project_file))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@app.patch("/api/projects/{project_id}")
|
|
||||||
def rename_project(project_id: str, payload: RenameProjectRequest) -> dict[str, object]:
|
|
||||||
try:
|
|
||||||
project = workspace.rename_project(project_id, payload.name, payload.card_language)
|
|
||||||
except FileNotFoundError as exc:
|
|
||||||
raise HTTPException(status_code=404, detail="Project not found") from exc
|
|
||||||
return {"id": project.project_id, "name": project.name}
|
|
||||||
|
|
||||||
@app.delete("/api/projects/{project_id}")
|
|
||||||
def delete_project(project_id: str) -> dict[str, object]:
|
|
||||||
try:
|
|
||||||
workspace.delete_project(project_id)
|
|
||||||
except FileNotFoundError as exc:
|
|
||||||
raise HTTPException(status_code=404, detail="Project not found") from exc
|
|
||||||
return {"deleted": project_id}
|
|
||||||
|
|
||||||
@app.post("/api/projects/{project_id}/delete")
|
|
||||||
def delete_project_via_post(project_id: str) -> dict[str, object]:
|
|
||||||
return delete_project(project_id)
|
|
||||||
|
|
||||||
@app.get("/api/zotero/collections/tree")
|
@app.get("/api/zotero/collections/tree")
|
||||||
def zotero_collection_tree() -> dict[str, object]:
|
def zotero_collection_tree() -> dict[str, object]:
|
||||||
return {"collections": reader.get_collection_tree()}
|
return {"collections": reader.get_collection_tree()}
|
||||||
@ -118,13 +87,6 @@ def create_app(
|
|||||||
def search_zotero_items(query: str = "", limit: int = 20) -> dict[str, object]:
|
def search_zotero_items(query: str = "", limit: int = 20) -> dict[str, object]:
|
||||||
return {"items": reader.search_items(query, limit=limit)}
|
return {"items": reader.search_items(query, limit=limit)}
|
||||||
|
|
||||||
@app.get("/api/zotero/items/{item_key}/attachments")
|
|
||||||
def zotero_item_attachments(item_key: str) -> dict[str, object]:
|
|
||||||
attachments = reader.get_item_attachments(item_key)
|
|
||||||
if not attachments:
|
|
||||||
raise HTTPException(status_code=404, detail="Item not found")
|
|
||||||
return {"item_key": item_key, "attachments": attachments}
|
|
||||||
|
|
||||||
@app.get("/api/projects/{project_id}")
|
@app.get("/api/projects/{project_id}")
|
||||||
def get_project(project_id: str) -> dict[str, object]:
|
def get_project(project_id: str) -> dict[str, object]:
|
||||||
project_dir = config.workspace_dir / "projects" / project_id
|
project_dir = config.workspace_dir / "projects" / project_id
|
||||||
@ -155,10 +117,9 @@ def create_app(
|
|||||||
builder = CardBuilder(config.workspace_dir, resolved_client)
|
builder = CardBuilder(config.workspace_dir, resolved_client)
|
||||||
selected_keys = read_selected_keys(config.bridge_file)
|
selected_keys = read_selected_keys(config.bridge_file)
|
||||||
items = reader.read_items(selected_keys)
|
items = reader.read_items(selected_keys)
|
||||||
card_language = str(project_payload.get("card_language", "en"))
|
|
||||||
imported_keys = []
|
imported_keys = []
|
||||||
for item in items:
|
for item in items:
|
||||||
builder.build_or_update(item, card_language)
|
builder.build_or_update(item)
|
||||||
imported_keys.append(item.item_key)
|
imported_keys.append(item.item_key)
|
||||||
project_view = project_service.add_items(project_id, imported_keys)
|
project_view = project_service.add_items(project_id, imported_keys)
|
||||||
return {"project_id": project_id, "imported_item_keys": imported_keys, "project_view": project_view}
|
return {"project_id": project_id, "imported_item_keys": imported_keys, "project_view": project_view}
|
||||||
@ -174,28 +135,22 @@ def create_app(
|
|||||||
else:
|
else:
|
||||||
item_keys = [str(value) for value in payload.item_keys]
|
item_keys = [str(value) for value in payload.item_keys]
|
||||||
|
|
||||||
|
project_payload = _read_json(project_file)
|
||||||
|
llm_payload = project_payload.get("llm", {})
|
||||||
|
try:
|
||||||
|
resolved_client = llm_client or create_card_generation_client(
|
||||||
|
str(llm_payload.get("provider", "deterministic")),
|
||||||
|
str(llm_payload.get("model", "deterministic")),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
builder = CardBuilder(config.workspace_dir, resolved_client)
|
||||||
items = reader.read_items(item_keys)
|
items = reader.read_items(item_keys)
|
||||||
|
imported_keys = []
|
||||||
# Write item metadata to items.json without generating cards
|
|
||||||
items_index_path = config.workspace_dir / "library" / "index" / "items.json"
|
|
||||||
items_index = project_service._read_json(items_index_path)
|
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
items_index[item.item_key] = {
|
builder.build_or_update(item)
|
||||||
"item_key": item.item_key,
|
imported_keys.append(item.item_key)
|
||||||
"title": item.title,
|
|
||||||
"creators": item.creators,
|
|
||||||
"year": item.year,
|
|
||||||
"item_type": item.item_type,
|
|
||||||
"abstract": item.abstract,
|
|
||||||
"tags": item.tags,
|
|
||||||
"collection_paths": item.collection_paths,
|
|
||||||
"attachment_status": "ok" if item.attachment_texts else "missing",
|
|
||||||
}
|
|
||||||
|
|
||||||
project_service._write_json(items_index_path, items_index)
|
|
||||||
|
|
||||||
imported_keys = [item.item_key for item in items]
|
|
||||||
project_view = project_service.add_items(project_id, imported_keys)
|
project_view = project_service.add_items(project_id, imported_keys)
|
||||||
return {"project_id": project_id, "imported_item_keys": imported_keys, "project_view": project_view}
|
return {"project_id": project_id, "imported_item_keys": imported_keys, "project_view": project_view}
|
||||||
|
|
||||||
@ -240,71 +195,6 @@ def create_app(
|
|||||||
)
|
)
|
||||||
return {"project_id": project_id, "items": items, "pending_count": pending_count, "done_count": done_count}
|
return {"project_id": project_id, "items": items, "pending_count": pending_count, "done_count": done_count}
|
||||||
|
|
||||||
@app.post("/api/projects/{project_id}/cards/generate")
|
|
||||||
def generate_cards(project_id: str, payload: GenerateCardsRequest | dict[str, object]) -> dict[str, object]:
|
|
||||||
project_file = config.workspace_dir / "projects" / project_id / "project.json"
|
|
||||||
if not project_file.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
|
||||||
|
|
||||||
if isinstance(payload, dict):
|
|
||||||
item_keys = [str(value) for value in payload.get("item_keys", [])]
|
|
||||||
else:
|
|
||||||
item_keys = [str(value) for value in payload.item_keys]
|
|
||||||
|
|
||||||
project_payload = _read_json(project_file)
|
|
||||||
llm_payload = project_payload.get("llm", {})
|
|
||||||
try:
|
|
||||||
resolved_client = llm_client or create_card_generation_client(
|
|
||||||
str(llm_payload.get("provider", "deterministic")),
|
|
||||||
str(llm_payload.get("model", "deterministic")),
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
builder = CardBuilder(config.workspace_dir, resolved_client)
|
|
||||||
items = reader.read_items(item_keys)
|
|
||||||
card_language = str(project_payload.get("card_language", "en"))
|
|
||||||
|
|
||||||
items_index_path = config.workspace_dir / "library" / "index" / "items.json"
|
|
||||||
items_index = project_service._read_json(items_index_path)
|
|
||||||
cards_index = project_service._read_json(config.workspace_dir / "library" / "index" / "cards.json")
|
|
||||||
|
|
||||||
generated: list[str] = []
|
|
||||||
failed: list[str] = []
|
|
||||||
response_items: list[dict[str, object]] = []
|
|
||||||
|
|
||||||
for item in items:
|
|
||||||
item_key = item.item_key
|
|
||||||
item_data = items_index.get(item_key, {})
|
|
||||||
try:
|
|
||||||
builder.build_or_update(item, card_language)
|
|
||||||
generated.append(item_key)
|
|
||||||
card_status = "done"
|
|
||||||
except Exception:
|
|
||||||
failed.append(item_key)
|
|
||||||
card_status = "failed"
|
|
||||||
|
|
||||||
response_items.append(
|
|
||||||
{
|
|
||||||
"item_key": item_key,
|
|
||||||
"title": item_data.get("title", item.title if hasattr(item, 'title') else "Untitled"),
|
|
||||||
"creators": item_data.get("creators", item.creators if hasattr(item, 'creators') else []),
|
|
||||||
"year": item_data.get("year", item.year if hasattr(item, 'year') else None),
|
|
||||||
"item_type": item_data.get("item_type", item.item_type if hasattr(item, 'item_type') else "unknown"),
|
|
||||||
"card_status": card_status,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if generated:
|
|
||||||
project_service.add_items(project_id, generated)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"project_id": project_id,
|
|
||||||
"generated": generated,
|
|
||||||
"failed": failed,
|
|
||||||
"items": response_items,
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.delete("/api/projects/{project_id}/items/{item_key}")
|
@app.delete("/api/projects/{project_id}/items/{item_key}")
|
||||||
def remove_project_item(project_id: str, item_key: str) -> dict[str, object]:
|
def remove_project_item(project_id: str, item_key: str) -> dict[str, object]:
|
||||||
return project_service.remove_item(project_id, item_key)
|
return project_service.remove_item(project_id, item_key)
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
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", [])]
|
|
||||||
|
|
||||||
@ -1,238 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .llm import CardGenerationClient
|
|
||||||
from .zotero_reader import ZoteroItemRecord
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CardBuildResult:
|
|
||||||
item_key: str
|
|
||||||
card_path: Path
|
|
||||||
source_hash: str
|
|
||||||
language: str
|
|
||||||
|
|
||||||
|
|
||||||
class CardBuilder:
|
|
||||||
def __init__(self, workspace_dir: Path, llm_client: CardGenerationClient) -> None:
|
|
||||||
self.workspace_dir = workspace_dir
|
|
||||||
self.llm_client = llm_client
|
|
||||||
self.library_dir = workspace_dir / "library"
|
|
||||||
self.collections_dir = self.library_dir / "collections"
|
|
||||||
self.index_dir = self.library_dir / "index"
|
|
||||||
self.bundle_dir = self.library_dir / "cache" / "source-bundles"
|
|
||||||
for path in (self.collections_dir, self.index_dir, self.bundle_dir):
|
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
def build_or_update(self, item: ZoteroItemRecord, language: str = "en") -> CardBuildResult:
|
|
||||||
source_bundle = self._build_source_bundle(item, language)
|
|
||||||
source_hash = self._hash_payload(source_bundle)
|
|
||||||
source_bundle["source_hash"] = source_hash
|
|
||||||
|
|
||||||
bundle_path = self.bundle_dir / f"{item.item_key}.{language}.json"
|
|
||||||
self._write_json(bundle_path, source_bundle)
|
|
||||||
|
|
||||||
card_data = self.llm_client.generate_card(source_bundle)
|
|
||||||
card_path = self._card_path(item, language)
|
|
||||||
card_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
card_path.write_text(self._render_card(item, source_hash, card_data), encoding="utf-8")
|
|
||||||
|
|
||||||
self._update_indexes(item, card_path, source_hash, card_data, language)
|
|
||||||
return CardBuildResult(item_key=item.item_key, card_path=card_path, source_hash=source_hash, language=language)
|
|
||||||
|
|
||||||
def _build_source_bundle(self, item: ZoteroItemRecord, language: str) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"item_key": item.item_key,
|
|
||||||
"title": item.title,
|
|
||||||
"card_language": language,
|
|
||||||
"creators": item.creators,
|
|
||||||
"year": item.year,
|
|
||||||
"item_type": item.item_type,
|
|
||||||
"abstract": item.abstract,
|
|
||||||
"tags": item.tags,
|
|
||||||
"collection_paths": item.collection_paths,
|
|
||||||
"notes": item.notes,
|
|
||||||
"attachments": item.attachments,
|
|
||||||
"attachment_texts": item.attachment_texts,
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _hash_payload(payload: dict[str, object]) -> str:
|
|
||||||
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
|
||||||
return f"sha256:{hashlib.sha256(raw).hexdigest()}"
|
|
||||||
|
|
||||||
def _card_path(self, item: ZoteroItemRecord, language: str) -> Path:
|
|
||||||
collection_path = item.collection_paths[0] if item.collection_paths else ["Unsorted"]
|
|
||||||
filename = f"{self._sanitize_filename(item.title)} [{item.item_key}][{language}].md"
|
|
||||||
return self.collections_dir.joinpath(*collection_path, filename)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _sanitize_filename(value: str) -> str:
|
|
||||||
value = re.sub(r"[\\\\/:*?\"<>|]", "", value).strip()
|
|
||||||
return value or "Untitled"
|
|
||||||
|
|
||||||
def _render_card(
|
|
||||||
self,
|
|
||||||
item: ZoteroItemRecord,
|
|
||||||
source_hash: str,
|
|
||||||
card_data: dict[str, object],
|
|
||||||
) -> str:
|
|
||||||
collections = item.collection_paths or [["Unsorted"]]
|
|
||||||
frontmatter_lines = [
|
|
||||||
"---",
|
|
||||||
f"item_key: {item.item_key}",
|
|
||||||
f"title: {item.title}",
|
|
||||||
f"year: {item.year or ''}",
|
|
||||||
"collections:",
|
|
||||||
]
|
|
||||||
frontmatter_lines.extend(f" - {'/'.join(path)}" for path in collections)
|
|
||||||
frontmatter_lines.append("authors:")
|
|
||||||
frontmatter_lines.extend(f" - {author}" for author in item.creators)
|
|
||||||
frontmatter_lines.append("tags:")
|
|
||||||
frontmatter_lines.extend(f" - {tag}" for tag in item.tags)
|
|
||||||
frontmatter_lines.append(f"attachment_status: {'ok' if item.attachment_texts else 'missing'}")
|
|
||||||
frontmatter_lines.append(f"source_hash: \"{source_hash}\"")
|
|
||||||
frontmatter_lines.append("---")
|
|
||||||
|
|
||||||
sections = [
|
|
||||||
("Summary", [str(card_data.get("summary", ""))]),
|
|
||||||
("Core Claims", [str(value) for value in card_data.get("core_claims", [])]),
|
|
||||||
("Methods", [str(value) for value in card_data.get("methods", [])]),
|
|
||||||
("Evidence", [str(value) for value in card_data.get("evidence", [])]),
|
|
||||||
("Source Files", self._render_attachments(item.attachments)),
|
|
||||||
("Citations", self._render_citations([value for value in card_data.get("citations", []) if isinstance(value, dict)])),
|
|
||||||
("Quotable Passages", [str(value) for value in card_data.get("quotable_passages", [])]),
|
|
||||||
("Writing Hints", [str(value) for value in card_data.get("writing_hints", [])]),
|
|
||||||
]
|
|
||||||
|
|
||||||
lines = frontmatter_lines + [""]
|
|
||||||
for heading, values in sections:
|
|
||||||
lines.append(f"# {heading}")
|
|
||||||
if heading == "Summary":
|
|
||||||
lines.append(values[0] if values and values[0] else "")
|
|
||||||
else:
|
|
||||||
lines.extend(f"- {value}" for value in values if value)
|
|
||||||
lines.append("")
|
|
||||||
return "\n".join(lines).strip() + "\n"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_citations(raw_citations: list[dict[str, Any]]) -> list[dict[str, object]]:
|
|
||||||
citations: list[dict[str, object]] = []
|
|
||||||
for citation in raw_citations:
|
|
||||||
citations.append(
|
|
||||||
{
|
|
||||||
"claim": str(citation.get("claim", "")),
|
|
||||||
"quote": str(citation.get("quote", "")),
|
|
||||||
"paraphrase": str(citation.get("paraphrase", "")),
|
|
||||||
"quote_source": str(citation.get("quote_source", "")),
|
|
||||||
"use_case": str(citation.get("use_case", "")),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return citations
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _render_attachments(cls, raw_attachments: list[dict[str, Any]]) -> list[str]:
|
|
||||||
lines: list[str] = []
|
|
||||||
for attachment in raw_attachments:
|
|
||||||
lines.append(f"- filename: {attachment.get('filename', '')}")
|
|
||||||
lines.append(f" path: {attachment.get('path', '')}")
|
|
||||||
lines.append(f" content_type: {attachment.get('content_type', '')}")
|
|
||||||
lines.append(f" is_pdf: {attachment.get('is_pdf', False)}")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _render_citations(cls, raw_citations: list[dict[str, Any]]) -> list[str]:
|
|
||||||
citations = cls._normalize_citations(raw_citations)
|
|
||||||
lines: list[str] = []
|
|
||||||
for citation in citations:
|
|
||||||
lines.append(f"- claim: {citation['claim']}")
|
|
||||||
lines.append(f" quote: {citation['quote']}")
|
|
||||||
lines.append(f" paraphrase: {citation['paraphrase']}")
|
|
||||||
lines.append(f" quote_source: {citation['quote_source']}")
|
|
||||||
lines.append(f" use_case: {citation['use_case']}")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
def _update_indexes(
|
|
||||||
self,
|
|
||||||
item: ZoteroItemRecord,
|
|
||||||
card_path: Path,
|
|
||||||
source_hash: str,
|
|
||||||
card_data: dict[str, object],
|
|
||||||
language: str,
|
|
||||||
) -> None:
|
|
||||||
items_index = self._read_json(self.index_dir / "items.json")
|
|
||||||
cards_index = self._read_json(self.index_dir / "cards.json")
|
|
||||||
collections_index = self._read_json(self.index_dir / "collections.json")
|
|
||||||
|
|
||||||
items_index[item.item_key] = {
|
|
||||||
"item_key": item.item_key,
|
|
||||||
"title": item.title,
|
|
||||||
"creators": item.creators,
|
|
||||||
"year": item.year,
|
|
||||||
"item_type": item.item_type,
|
|
||||||
"abstract": item.abstract,
|
|
||||||
"tags": item.tags,
|
|
||||||
"collection_paths": item.collection_paths,
|
|
||||||
"attachment_status": "ok" if item.attachment_texts else "missing",
|
|
||||||
"card_path": str(card_path),
|
|
||||||
"card_paths": {
|
|
||||||
**{
|
|
||||||
str(key): str(value)
|
|
||||||
for key, value in dict(items_index.get(item.item_key, {}).get("card_paths", {})).items()
|
|
||||||
},
|
|
||||||
language: str(card_path),
|
|
||||||
},
|
|
||||||
"source_hash": source_hash,
|
|
||||||
}
|
|
||||||
existing_variants = cards_index.get(item.item_key, {})
|
|
||||||
if not isinstance(existing_variants, dict) or any(
|
|
||||||
key in existing_variants for key in ("summary", "claims", "citations", "attachments")
|
|
||||||
):
|
|
||||||
existing_variants = {"en": existing_variants} if existing_variants else {}
|
|
||||||
existing_variants[language] = {
|
|
||||||
"item_key": item.item_key,
|
|
||||||
"language": language,
|
|
||||||
"card_path": str(card_path),
|
|
||||||
"title": item.title,
|
|
||||||
"summary": str(card_data.get("summary", "")),
|
|
||||||
"attachments": item.attachments,
|
|
||||||
"keywords": [str(value) for value in card_data.get("keywords", [])],
|
|
||||||
"claims": [str(value) for value in card_data.get("core_claims", [])],
|
|
||||||
"citations": self._normalize_citations(
|
|
||||||
[value for value in card_data.get("citations", []) if isinstance(value, dict)]
|
|
||||||
),
|
|
||||||
"quotable_spans": [str(value) for value in card_data.get("quotable_passages", [])],
|
|
||||||
"writing_hints": [str(value) for value in card_data.get("writing_hints", [])],
|
|
||||||
}
|
|
||||||
cards_index[item.item_key] = existing_variants
|
|
||||||
|
|
||||||
for path_parts in item.collection_paths or [["Unsorted"]]:
|
|
||||||
collection_key = "/".join(path_parts)
|
|
||||||
collections_index[collection_key] = {
|
|
||||||
"collection_key": collection_key,
|
|
||||||
"name": path_parts[-1],
|
|
||||||
"path": path_parts,
|
|
||||||
"item_keys": sorted(
|
|
||||||
set(collections_index.get(collection_key, {}).get("item_keys", []) + [item.item_key])
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
self._write_json(self.index_dir / "items.json", items_index)
|
|
||||||
self._write_json(self.index_dir / "cards.json", cards_index)
|
|
||||||
self._write_json(self.index_dir / "collections.json", collections_index)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _read_json(path: Path) -> dict[str, object]:
|
|
||||||
if not path.exists():
|
|
||||||
return {}
|
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _write_json(path: Path, payload: dict[str, object]) -> None:
|
|
||||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,174 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import urllib.request
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from typing import Any, Callable, Protocol
|
|
||||||
|
|
||||||
|
|
||||||
class CardGenerationClient(Protocol):
|
|
||||||
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
|
|
||||||
"""Return structured card sections for a normalized source bundle."""
|
|
||||||
|
|
||||||
|
|
||||||
class DeterministicCardGenerationClient:
|
|
||||||
"""Fallback card generator used when no remote model client is configured."""
|
|
||||||
|
|
||||||
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
|
|
||||||
abstract = str(source_bundle.get("abstract", "")).strip()
|
|
||||||
language = str(source_bundle.get("card_language", "en"))
|
|
||||||
notes = [str(value).strip() for value in source_bundle.get("notes", []) if str(value).strip()]
|
|
||||||
attachment_texts = [
|
|
||||||
str(value).strip() for value in source_bundle.get("attachment_texts", []) if str(value).strip()
|
|
||||||
]
|
|
||||||
summary = abstract or (notes[0] if notes else "") or (attachment_texts[0][:240] if attachment_texts else "")
|
|
||||||
claims = (notes[:3] or [summary]) if summary else []
|
|
||||||
return {
|
|
||||||
"summary": summary if language == "en" else f"中文:{summary}",
|
|
||||||
"core_claims": claims if language == "en" else [f"中文:{claim}" for claim in claims],
|
|
||||||
"methods": [],
|
|
||||||
"evidence": attachment_texts[:3],
|
|
||||||
"citations": [
|
|
||||||
{
|
|
||||||
"claim": claims[0] if claims else summary,
|
|
||||||
"quote": attachment_texts[0][:240],
|
|
||||||
"paraphrase": summary if language == "en" else f"中文:{summary}",
|
|
||||||
"quote_source": "attachment_texts",
|
|
||||||
"use_case": "direct_quote",
|
|
||||||
}
|
|
||||||
] if attachment_texts else [],
|
|
||||||
"quotable_passages": attachment_texts[:2],
|
|
||||||
"writing_hints": [
|
|
||||||
"Use this card when the writing intent overlaps with its summary or notes."
|
|
||||||
if language == "en"
|
|
||||||
else "当写作意图与摘要或笔记相关时使用这张卡片。",
|
|
||||||
],
|
|
||||||
"keywords": [str(value) for value in source_bundle.get("tags", [])],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
RequestFunction = Callable[[str, dict[str, object], dict[str, str]], dict[str, object]]
|
|
||||||
|
|
||||||
|
|
||||||
class DeepSeekCardGenerationClient:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
api_key: str,
|
|
||||||
*,
|
|
||||||
model: str = "deepseek-chat",
|
|
||||||
base_url: str = "https://api.deepseek.com/v1",
|
|
||||||
request_fn: RequestFunction | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.api_key = api_key
|
|
||||||
self.model = model
|
|
||||||
self.base_url = base_url.rstrip("/")
|
|
||||||
self.request_fn = request_fn or self._default_request
|
|
||||||
|
|
||||||
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
|
|
||||||
language = str(source_bundle.get("card_language", "en"))
|
|
||||||
language_instruction = (
|
|
||||||
"Write summary, core_claims, methods, evidence, paraphrase fields, and writing_hints in Chinese."
|
|
||||||
if language == "zh"
|
|
||||||
else "Write summary, core_claims, methods, evidence, paraphrase fields, and writing_hints in English."
|
|
||||||
)
|
|
||||||
payload = {
|
|
||||||
"model": self.model,
|
|
||||||
"response_format": {"type": "json_object"},
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": (
|
|
||||||
"You convert research source bundles into structured evidence cards for academic writing. "
|
|
||||||
"Never hallucinate. Use only information explicitly supported by the source bundle. "
|
|
||||||
"Preserve uncertainty and hedging. Return valid JSON only with keys: "
|
|
||||||
"summary, core_claims, methods, evidence, citations, quotable_passages, writing_hints, keywords."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": (
|
|
||||||
"Given this source bundle, return exactly one JSON object with keys: "
|
|
||||||
"summary, core_claims, methods, evidence, citations, quotable_passages, writing_hints, keywords.\n\n"
|
|
||||||
"Rules:\n"
|
|
||||||
"1. summary: 2-4 factual sentences, no hype.\n"
|
|
||||||
"2. core_claims: concrete findings only; preserve numbers and hedging.\n"
|
|
||||||
"3. methods: brief method or setup details actually stated in the source.\n"
|
|
||||||
"4. evidence: empirical or textual support from the source, numbers preferred.\n"
|
|
||||||
"5. citations: an array of objects with keys claim, quote, paraphrase, quote_source, use_case.\n"
|
|
||||||
"6. citations.quote must be verbatim text from notes or attachment_texts only.\n"
|
|
||||||
"7. Return 1-3 citations whenever the source bundle contains usable supporting text in notes or attachment_texts.\n"
|
|
||||||
"8. quote_source must identify where the quote came from, such as attachment_texts or notes.\n"
|
|
||||||
"9. use_case must be one of direct_quote, paraphrase, or background.\n"
|
|
||||||
"10. quotable_passages: short verbatim excerpts from notes or attachment_texts only.\n"
|
|
||||||
"11. Use empty arrays only when the source bundle truly contains no usable supporting quote; do not fabricate.\n"
|
|
||||||
f"12. {language_instruction}\n\n"
|
|
||||||
f"Source bundle:\n{json.dumps(source_bundle, ensure_ascii=False)}"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
response = self.request_fn(f"{self.base_url}/chat/completions", payload, headers)
|
|
||||||
content = str(response["choices"][0]["message"]["content"])
|
|
||||||
parsed = json.loads(_strip_json_fence(content))
|
|
||||||
return {
|
|
||||||
"summary": str(parsed.get("summary", "")),
|
|
||||||
"core_claims": [str(value) for value in parsed.get("core_claims", [])],
|
|
||||||
"methods": [str(value) for value in parsed.get("methods", [])],
|
|
||||||
"evidence": [str(value) for value in parsed.get("evidence", [])],
|
|
||||||
"citations": [
|
|
||||||
{
|
|
||||||
"claim": str(value.get("claim", "")),
|
|
||||||
"quote": str(value.get("quote", "")),
|
|
||||||
"paraphrase": str(value.get("paraphrase", "")),
|
|
||||||
"quote_source": str(value.get("quote_source", "")),
|
|
||||||
"use_case": str(value.get("use_case", "")),
|
|
||||||
}
|
|
||||||
for value in parsed.get("citations", [])
|
|
||||||
if isinstance(value, dict)
|
|
||||||
],
|
|
||||||
"quotable_passages": [str(value) for value in parsed.get("quotable_passages", [])],
|
|
||||||
"writing_hints": [str(value) for value in parsed.get("writing_hints", [])],
|
|
||||||
"keywords": [str(value) for value in parsed.get("keywords", [])],
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _default_request(url: str, payload: dict[str, object], headers: dict[str, str]) -> dict[str, object]:
|
|
||||||
request = urllib.request.Request(
|
|
||||||
url=url,
|
|
||||||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
|
||||||
headers=headers,
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(request, timeout=120) as response:
|
|
||||||
return json.loads(response.read().decode("utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
def create_card_generation_client(
|
|
||||||
provider: str,
|
|
||||||
model: str,
|
|
||||||
*,
|
|
||||||
env: Mapping[str, str] | None = None,
|
|
||||||
request_fn: RequestFunction | None = None,
|
|
||||||
) -> CardGenerationClient:
|
|
||||||
current_env = os.environ if env is None else env
|
|
||||||
normalized = provider.lower()
|
|
||||||
if normalized == "deepseek":
|
|
||||||
api_key = current_env.get("DEEPSEEK_API_KEY")
|
|
||||||
if not api_key:
|
|
||||||
raise ValueError("DEEPSEEK_API_KEY is required when llm provider is deepseek")
|
|
||||||
return DeepSeekCardGenerationClient(api_key=api_key, model=model, request_fn=request_fn)
|
|
||||||
return DeterministicCardGenerationClient()
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_json_fence(value: str) -> str:
|
|
||||||
stripped = value.strip()
|
|
||||||
if stripped.startswith("```"):
|
|
||||||
lines = stripped.splitlines()
|
|
||||||
if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].strip() == "```":
|
|
||||||
return "\n".join(lines[1:-1]).strip()
|
|
||||||
return stripped
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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()
|
|
||||||
@ -1,105 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectService:
|
|
||||||
def __init__(self, workspace_dir: Path) -> None:
|
|
||||||
self.workspace_dir = workspace_dir
|
|
||||||
self.projects_dir = workspace_dir / "projects"
|
|
||||||
self.index_dir = workspace_dir / "library" / "index"
|
|
||||||
|
|
||||||
def add_items(self, project_id: str, item_keys: list[str]) -> dict[str, object]:
|
|
||||||
selected_items = self._read_selected_items(project_id)
|
|
||||||
selected_items = sorted(set(selected_items + item_keys))
|
|
||||||
self._write_json(self._project_dir(project_id) / "selected-items.json", selected_items)
|
|
||||||
return self._rebuild_project_index(project_id, selected_items)
|
|
||||||
|
|
||||||
def remove_item(self, project_id: str, item_key: str) -> dict[str, object]:
|
|
||||||
selected_items = [key for key in self._read_selected_items(project_id) if key != item_key]
|
|
||||||
self._write_json(self._project_dir(project_id) / "selected-items.json", selected_items)
|
|
||||||
return self._rebuild_project_index(project_id, selected_items)
|
|
||||||
|
|
||||||
def get_project_view(self, project_id: str) -> dict[str, object]:
|
|
||||||
selected_items = self._read_selected_items(project_id)
|
|
||||||
return self._rebuild_project_index(project_id, selected_items)
|
|
||||||
|
|
||||||
def _rebuild_project_index(self, project_id: str, selected_items: list[str]) -> dict[str, object]:
|
|
||||||
cards_index = self._read_json(self.index_dir / "cards.json")
|
|
||||||
items_index = self._read_json(self.index_dir / "items.json")
|
|
||||||
collections_index = self._read_json(self.index_dir / "collections.json")
|
|
||||||
project_payload = self._read_json(self._project_dir(project_id) / "project.json")
|
|
||||||
card_language = str(project_payload.get("card_language", "en"))
|
|
||||||
project_items: list[dict[str, object]] = []
|
|
||||||
project_cards: list[dict[str, object]] = []
|
|
||||||
for item_key in selected_items:
|
|
||||||
item_data = items_index.get(item_key, {})
|
|
||||||
card_variants = self._normalize_card_variants(cards_index.get(item_key))
|
|
||||||
card_data = card_variants.get(card_language)
|
|
||||||
item_payload = {
|
|
||||||
"item_key": item_key,
|
|
||||||
"title": item_data.get("title", "Untitled"),
|
|
||||||
"creators": item_data.get("creators", []),
|
|
||||||
"year": item_data.get("year"),
|
|
||||||
"item_type": item_data.get("item_type", "unknown"),
|
|
||||||
"card_status": "done" if card_data else "pending",
|
|
||||||
"summary": card_data.get("summary") if card_data else None,
|
|
||||||
"attachments": card_data.get("attachments", []) if card_data else [],
|
|
||||||
"claims": card_data.get("claims", []) if card_data else [],
|
|
||||||
"citations": card_data.get("citations", []) if card_data else [],
|
|
||||||
"quotable_spans": card_data.get("quotable_spans", []) if card_data else [],
|
|
||||||
}
|
|
||||||
project_items.append(item_payload)
|
|
||||||
if card_data:
|
|
||||||
project_cards.append(card_data)
|
|
||||||
project_collections = [
|
|
||||||
payload
|
|
||||||
for payload in collections_index.values()
|
|
||||||
if set(payload.get("item_keys", [])) & set(selected_items)
|
|
||||||
]
|
|
||||||
payload = {
|
|
||||||
"project_id": project_id,
|
|
||||||
"card_language": card_language,
|
|
||||||
"selected_items": selected_items,
|
|
||||||
"items": project_items,
|
|
||||||
"cards": project_cards,
|
|
||||||
"collections": project_collections,
|
|
||||||
}
|
|
||||||
self._write_json(self._project_dir(project_id) / "project-index.json", payload)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
def _read_selected_items(self, project_id: str) -> list[str]:
|
|
||||||
path = self._project_dir(project_id) / "selected-items.json"
|
|
||||||
if not path.exists():
|
|
||||||
return []
|
|
||||||
return [str(value) for value in json.loads(path.read_text(encoding="utf-8"))]
|
|
||||||
|
|
||||||
def _project_dir(self, project_id: str) -> Path:
|
|
||||||
return self.projects_dir / project_id
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_card_variants(card_entry: object) -> dict[str, dict[str, object]]:
|
|
||||||
if not isinstance(card_entry, dict):
|
|
||||||
return {}
|
|
||||||
if any(key in card_entry for key in ("summary", "claims", "citations", "attachments")):
|
|
||||||
legacy_card = dict(card_entry)
|
|
||||||
legacy_card.setdefault("language", "en")
|
|
||||||
return {"en": legacy_card}
|
|
||||||
variants: dict[str, dict[str, object]] = {}
|
|
||||||
for language, payload in card_entry.items():
|
|
||||||
if isinstance(payload, dict):
|
|
||||||
variant = dict(payload)
|
|
||||||
variant.setdefault("language", str(language))
|
|
||||||
variants[str(language)] = variant
|
|
||||||
return variants
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _read_json(path: Path) -> dict[str, object]:
|
|
||||||
if not path.exists():
|
|
||||||
return {}
|
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _write_json(path: Path, payload: object) -> None:
|
|
||||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,92 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import shutil
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .config import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ProjectRecord:
|
|
||||||
project_id: str
|
|
||||||
name: str
|
|
||||||
project_dir: Path
|
|
||||||
card_language: str
|
|
||||||
|
|
||||||
|
|
||||||
class Workspace:
|
|
||||||
def __init__(self, config: AppConfig) -> None:
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
def ensure_layout(self) -> None:
|
|
||||||
for path in (
|
|
||||||
self.config.workspace_dir / "library" / "collections",
|
|
||||||
self.config.workspace_dir / "library" / "index",
|
|
||||||
self.config.workspace_dir / "library" / "cache" / "source-bundles",
|
|
||||||
self.config.workspace_dir / "bridge",
|
|
||||||
self.config.workspace_dir / "projects",
|
|
||||||
):
|
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
def create_project(
|
|
||||||
self,
|
|
||||||
project_id: str,
|
|
||||||
name: str,
|
|
||||||
llm_provider: str,
|
|
||||||
llm_model: str,
|
|
||||||
card_language: str,
|
|
||||||
) -> ProjectRecord:
|
|
||||||
self.ensure_layout()
|
|
||||||
project_dir = self.config.workspace_dir / "projects" / project_id
|
|
||||||
project_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
project_payload = {
|
|
||||||
"id": project_id,
|
|
||||||
"name": name,
|
|
||||||
"zotero_data_dir": str(self.config.zotero_data_dir),
|
|
||||||
"selection_mode": "zotero-bridge",
|
|
||||||
"llm": {
|
|
||||||
"provider": llm_provider,
|
|
||||||
"model": llm_model,
|
|
||||||
"base_url": None,
|
|
||||||
},
|
|
||||||
"card_language": card_language,
|
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
self._write_json(project_dir / "project.json", project_payload)
|
|
||||||
self._write_json(project_dir / "selected-items.json", [])
|
|
||||||
self._write_json(project_dir / "project-index.json", {"items": []})
|
|
||||||
|
|
||||||
return ProjectRecord(project_id=project_id, name=name, project_dir=project_dir, card_language=card_language)
|
|
||||||
|
|
||||||
def rename_project(self, project_id: str, name: str, card_language: str | None = None) -> ProjectRecord:
|
|
||||||
project_dir = self.config.workspace_dir / "projects" / project_id
|
|
||||||
project_file = project_dir / "project.json"
|
|
||||||
if not project_file.exists():
|
|
||||||
raise FileNotFoundError(project_id)
|
|
||||||
|
|
||||||
project_payload = json.loads(project_file.read_text(encoding="utf-8"))
|
|
||||||
project_payload["name"] = name
|
|
||||||
if card_language is not None:
|
|
||||||
project_payload["card_language"] = card_language
|
|
||||||
self._write_json(project_file, project_payload)
|
|
||||||
return ProjectRecord(
|
|
||||||
project_id=project_id,
|
|
||||||
name=name,
|
|
||||||
project_dir=project_dir,
|
|
||||||
card_language=str(project_payload.get("card_language", "en")),
|
|
||||||
)
|
|
||||||
|
|
||||||
def delete_project(self, project_id: str) -> None:
|
|
||||||
project_dir = self.config.workspace_dir / "projects" / project_id
|
|
||||||
if not project_dir.exists():
|
|
||||||
raise FileNotFoundError(project_id)
|
|
||||||
shutil.rmtree(project_dir)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _write_json(path: Path, payload: object) -> None:
|
|
||||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,488 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import html
|
|
||||||
import re
|
|
||||||
import sqlite3
|
|
||||||
import subprocess
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ZoteroItemRecord:
|
|
||||||
item_key: str
|
|
||||||
title: str
|
|
||||||
creators: list[str]
|
|
||||||
year: str | None
|
|
||||||
item_type: str
|
|
||||||
abstract: str
|
|
||||||
tags: list[str]
|
|
||||||
collection_paths: list[list[str]]
|
|
||||||
notes: list[str]
|
|
||||||
attachments: list[dict[str, object]]
|
|
||||||
attachment_texts: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
class ZoteroReader:
|
|
||||||
def __init__(self, zotero_data_dir: Path) -> None:
|
|
||||||
self.zotero_data_dir = zotero_data_dir
|
|
||||||
self.db_path = zotero_data_dir / "zotero.sqlite"
|
|
||||||
|
|
||||||
def read_items(self, item_keys: list[str]) -> list[ZoteroItemRecord]:
|
|
||||||
if not item_keys:
|
|
||||||
return []
|
|
||||||
|
|
||||||
conn = sqlite3.connect(self.db_path)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
field_lookup = self._read_field_lookup(conn)
|
|
||||||
collection_lookup = self._read_collection_lookup(conn)
|
|
||||||
items = []
|
|
||||||
for item_key in item_keys:
|
|
||||||
item_row = conn.execute(
|
|
||||||
"""
|
|
||||||
select i.itemID, i.key, it.typeName
|
|
||||||
from items i
|
|
||||||
join itemTypes it on it.itemTypeID = i.itemTypeID
|
|
||||||
where i.key = ?
|
|
||||||
""",
|
|
||||||
(item_key,),
|
|
||||||
).fetchone()
|
|
||||||
if item_row is None or item_row["typeName"] in {"attachment", "note", "annotation"}:
|
|
||||||
continue
|
|
||||||
|
|
||||||
item_id = int(item_row["itemID"])
|
|
||||||
item_data = self._read_item_data(conn, item_id, field_lookup)
|
|
||||||
attachment_records = self._read_attachment_records(conn, item_id)
|
|
||||||
items.append(
|
|
||||||
ZoteroItemRecord(
|
|
||||||
item_key=item_row["key"],
|
|
||||||
title=item_data.get("title", "Untitled"),
|
|
||||||
creators=self._read_creators(conn, item_id),
|
|
||||||
year=self._extract_year(item_data.get("date")),
|
|
||||||
item_type=item_row["typeName"],
|
|
||||||
abstract=item_data.get("abstractNote", ""),
|
|
||||||
tags=self._read_tags(conn, item_id),
|
|
||||||
collection_paths=self._read_collection_paths(conn, item_id, collection_lookup),
|
|
||||||
notes=self._read_notes(conn, item_id),
|
|
||||||
attachments=[self._public_attachment_metadata(attachment) for attachment in attachment_records],
|
|
||||||
attachment_texts=[str(attachment["text"]) for attachment in attachment_records if attachment.get("text")],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return items
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def get_item_attachments(self, item_key: str) -> list[dict[str, object]]:
|
|
||||||
items = self.read_items([item_key])
|
|
||||||
if not items:
|
|
||||||
return []
|
|
||||||
return items[0].attachments
|
|
||||||
|
|
||||||
def search_items(self, query: str, limit: int = 20) -> list[dict[str, object]]:
|
|
||||||
conn = sqlite3.connect(self.db_path)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
field_lookup = self._read_field_lookup(conn)
|
|
||||||
sql = """
|
|
||||||
select i.itemID, i.key, it.typeName
|
|
||||||
from items i
|
|
||||||
join itemTypes it on it.itemTypeID = i.itemTypeID
|
|
||||||
where it.typeName not in ('attachment', 'note', 'annotation')
|
|
||||||
order by i.dateModified desc, i.itemID desc
|
|
||||||
limit ?
|
|
||||||
"""
|
|
||||||
rows = conn.execute(sql, (limit * 5,)).fetchall()
|
|
||||||
lowered = query.strip().lower()
|
|
||||||
results: list[dict[str, object]] = []
|
|
||||||
for row in rows:
|
|
||||||
item_id = int(row["itemID"])
|
|
||||||
item_data = self._read_item_data(conn, item_id, field_lookup)
|
|
||||||
title = item_data.get("title", "Untitled")
|
|
||||||
abstract = item_data.get("abstractNote", "")
|
|
||||||
haystack = " ".join([title, abstract]).lower()
|
|
||||||
if lowered and lowered not in haystack:
|
|
||||||
continue
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"item_key": str(row["key"]),
|
|
||||||
"title": title,
|
|
||||||
"item_type": str(row["typeName"]),
|
|
||||||
"year": self._extract_year(item_data.get("date")),
|
|
||||||
"abstract": abstract,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
return results
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def get_collection_tree(self) -> list[dict[str, object]]:
|
|
||||||
conn = sqlite3.connect(self.db_path)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
collections = self._read_collection_lookup(conn)
|
|
||||||
children_lookup = self._build_children_lookup(collections)
|
|
||||||
direct_counts = self._read_direct_item_counts(conn)
|
|
||||||
root_ids = [collection_id for collection_id, row in collections.items() if row["parentCollectionID"] is None]
|
|
||||||
return [
|
|
||||||
self._build_collection_node(collection_id, collections, children_lookup, direct_counts)
|
|
||||||
for collection_id in sorted(root_ids)
|
|
||||||
]
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def get_collection_items(
|
|
||||||
self,
|
|
||||||
collection_key: str,
|
|
||||||
include_descendants: bool = True,
|
|
||||||
) -> list[dict[str, object]]:
|
|
||||||
conn = sqlite3.connect(self.db_path)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
field_lookup = self._read_field_lookup(conn)
|
|
||||||
collections = self._read_collection_lookup(conn)
|
|
||||||
children_lookup = self._build_children_lookup(collections)
|
|
||||||
collection_ids = self._resolve_collection_ids(
|
|
||||||
collection_key,
|
|
||||||
collections,
|
|
||||||
children_lookup,
|
|
||||||
include_descendants=include_descendants,
|
|
||||||
)
|
|
||||||
item_ids = self._read_item_ids_for_collections(conn, collection_ids)
|
|
||||||
return self._read_item_summaries(conn, item_ids, field_lookup, collections)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _read_field_lookup(conn: sqlite3.Connection) -> dict[int, str]:
|
|
||||||
return {
|
|
||||||
int(row["fieldID"]): str(row["fieldName"])
|
|
||||||
for row in conn.execute("select fieldID, fieldName from fields")
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _read_collection_lookup(conn: sqlite3.Connection) -> dict[int, sqlite3.Row]:
|
|
||||||
return {
|
|
||||||
int(row["collectionID"]): row
|
|
||||||
for row in conn.execute(
|
|
||||||
"select collectionID, collectionName, parentCollectionID, key from collections"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_children_lookup(collections: dict[int, sqlite3.Row]) -> dict[int, list[int]]:
|
|
||||||
children_lookup: dict[int, list[int]] = {}
|
|
||||||
for collection_id, row in collections.items():
|
|
||||||
parent_id = row["parentCollectionID"]
|
|
||||||
if parent_id is None:
|
|
||||||
continue
|
|
||||||
children_lookup.setdefault(int(parent_id), []).append(collection_id)
|
|
||||||
for children in children_lookup.values():
|
|
||||||
children.sort()
|
|
||||||
return children_lookup
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _read_direct_item_counts(conn: sqlite3.Connection) -> dict[int, int]:
|
|
||||||
rows = conn.execute(
|
|
||||||
"""
|
|
||||||
select ci.collectionID, count(*) as item_count
|
|
||||||
from collectionItems ci
|
|
||||||
join items i on i.itemID = ci.itemID
|
|
||||||
join itemTypes it on it.itemTypeID = i.itemTypeID
|
|
||||||
where it.typeName not in ('attachment', 'note', 'annotation')
|
|
||||||
group by ci.collectionID
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
return {int(row["collectionID"]): int(row["item_count"]) for row in rows}
|
|
||||||
|
|
||||||
def _build_collection_node(
|
|
||||||
self,
|
|
||||||
collection_id: int,
|
|
||||||
collections: dict[int, sqlite3.Row],
|
|
||||||
children_lookup: dict[int, list[int]],
|
|
||||||
direct_counts: dict[int, int],
|
|
||||||
) -> dict[str, object]:
|
|
||||||
row = collections[collection_id]
|
|
||||||
children = [
|
|
||||||
self._build_collection_node(child_id, collections, children_lookup, direct_counts)
|
|
||||||
for child_id in children_lookup.get(collection_id, [])
|
|
||||||
]
|
|
||||||
descendant_count = direct_counts.get(collection_id, 0) + sum(
|
|
||||||
int(child["descendant_item_count"]) for child in children
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"collection_key": str(row["key"]),
|
|
||||||
"name": str(row["collectionName"]),
|
|
||||||
"parent_key": self._parent_key(row["parentCollectionID"], collections),
|
|
||||||
"children": children,
|
|
||||||
"direct_item_count": direct_counts.get(collection_id, 0),
|
|
||||||
"descendant_item_count": descendant_count,
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parent_key(parent_collection_id: object, collections: dict[int, sqlite3.Row]) -> str | None:
|
|
||||||
if parent_collection_id is None:
|
|
||||||
return None
|
|
||||||
parent_row = collections.get(int(parent_collection_id))
|
|
||||||
if parent_row is None:
|
|
||||||
return None
|
|
||||||
return str(parent_row["key"])
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _resolve_collection_ids(
|
|
||||||
collection_key: str,
|
|
||||||
collections: dict[int, sqlite3.Row],
|
|
||||||
children_lookup: dict[int, list[int]],
|
|
||||||
include_descendants: bool,
|
|
||||||
) -> list[int]:
|
|
||||||
collection_id = next(
|
|
||||||
(current_id for current_id, row in collections.items() if str(row["key"]) == collection_key),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if collection_id is None:
|
|
||||||
return []
|
|
||||||
if not include_descendants:
|
|
||||||
return [collection_id]
|
|
||||||
|
|
||||||
ordered: list[int] = []
|
|
||||||
stack = [collection_id]
|
|
||||||
while stack:
|
|
||||||
current_id = stack.pop(0)
|
|
||||||
ordered.append(current_id)
|
|
||||||
stack[0:0] = children_lookup.get(current_id, [])
|
|
||||||
return ordered
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _read_item_ids_for_collections(conn: sqlite3.Connection, collection_ids: list[int]) -> list[int]:
|
|
||||||
if not collection_ids:
|
|
||||||
return []
|
|
||||||
placeholders = ", ".join("?" for _ in collection_ids)
|
|
||||||
rows = conn.execute(
|
|
||||||
f"""
|
|
||||||
select distinct ci.itemID
|
|
||||||
from collectionItems ci
|
|
||||||
join items i on i.itemID = ci.itemID
|
|
||||||
join itemTypes it on it.itemTypeID = i.itemTypeID
|
|
||||||
where ci.collectionID in ({placeholders})
|
|
||||||
and it.typeName not in ('attachment', 'note', 'annotation')
|
|
||||||
order by ci.itemID asc
|
|
||||||
""",
|
|
||||||
tuple(collection_ids),
|
|
||||||
)
|
|
||||||
return [int(row["itemID"]) for row in rows]
|
|
||||||
|
|
||||||
def _read_item_summaries(
|
|
||||||
self,
|
|
||||||
conn: sqlite3.Connection,
|
|
||||||
item_ids: list[int],
|
|
||||||
field_lookup: dict[int, str],
|
|
||||||
collections: dict[int, sqlite3.Row],
|
|
||||||
) -> list[dict[str, object]]:
|
|
||||||
summaries: list[dict[str, object]] = []
|
|
||||||
for item_id in item_ids:
|
|
||||||
row = conn.execute(
|
|
||||||
"""
|
|
||||||
select i.key, it.typeName
|
|
||||||
from items i
|
|
||||||
join itemTypes it on it.itemTypeID = i.itemTypeID
|
|
||||||
where i.itemID = ?
|
|
||||||
""",
|
|
||||||
(item_id,),
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
continue
|
|
||||||
item_data = self._read_item_data(conn, item_id, field_lookup)
|
|
||||||
summaries.append(
|
|
||||||
{
|
|
||||||
"item_key": str(row["key"]),
|
|
||||||
"title": item_data.get("title", "Untitled"),
|
|
||||||
"year": self._extract_year(item_data.get("date")),
|
|
||||||
"item_type": str(row["typeName"]),
|
|
||||||
"abstract": item_data.get("abstractNote", ""),
|
|
||||||
"collection_paths": self._read_collection_paths(conn, item_id, collections),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return summaries
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _read_item_data(
|
|
||||||
conn: sqlite3.Connection,
|
|
||||||
item_id: int,
|
|
||||||
field_lookup: dict[int, str],
|
|
||||||
) -> dict[str, str]:
|
|
||||||
data: dict[str, str] = {}
|
|
||||||
rows = conn.execute(
|
|
||||||
"""
|
|
||||||
select fieldID, value
|
|
||||||
from itemData
|
|
||||||
join itemDataValues using (valueID)
|
|
||||||
where itemID = ?
|
|
||||||
""",
|
|
||||||
(item_id,),
|
|
||||||
)
|
|
||||||
for row in rows:
|
|
||||||
field_name = field_lookup.get(int(row["fieldID"]))
|
|
||||||
if field_name:
|
|
||||||
data[field_name] = str(row["value"])
|
|
||||||
return data
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _read_creators(conn: sqlite3.Connection, item_id: int) -> list[str]:
|
|
||||||
rows = conn.execute(
|
|
||||||
"""
|
|
||||||
select firstName, lastName, fieldMode
|
|
||||||
from itemCreators
|
|
||||||
join creators using (creatorID)
|
|
||||||
where itemID = ?
|
|
||||||
order by orderIndex asc
|
|
||||||
""",
|
|
||||||
(item_id,),
|
|
||||||
)
|
|
||||||
creators = []
|
|
||||||
for row in rows:
|
|
||||||
if int(row["fieldMode"] or 0) == 1:
|
|
||||||
creators.append(str(row["lastName"]))
|
|
||||||
else:
|
|
||||||
first = str(row["firstName"] or "").strip()
|
|
||||||
last = str(row["lastName"] or "").strip()
|
|
||||||
creators.append(" ".join(part for part in (first, last) if part))
|
|
||||||
return creators
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _read_tags(conn: sqlite3.Connection, item_id: int) -> list[str]:
|
|
||||||
rows = conn.execute(
|
|
||||||
"""
|
|
||||||
select tags.name
|
|
||||||
from itemTags
|
|
||||||
join tags using (tagID)
|
|
||||||
where itemTags.itemID = ?
|
|
||||||
order by lower(tags.name) asc
|
|
||||||
""",
|
|
||||||
(item_id,),
|
|
||||||
)
|
|
||||||
return [str(row["name"]) for row in rows]
|
|
||||||
|
|
||||||
def _read_collection_paths(
|
|
||||||
self,
|
|
||||||
conn: sqlite3.Connection,
|
|
||||||
item_id: int,
|
|
||||||
collection_lookup: dict[int, sqlite3.Row],
|
|
||||||
) -> list[list[str]]:
|
|
||||||
rows = conn.execute(
|
|
||||||
"select collectionID from collectionItems where itemID = ? order by orderIndex asc",
|
|
||||||
(item_id,),
|
|
||||||
)
|
|
||||||
return [self._build_collection_path(int(row["collectionID"]), collection_lookup) for row in rows]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_collection_path(
|
|
||||||
collection_id: int,
|
|
||||||
collection_lookup: dict[int, sqlite3.Row],
|
|
||||||
) -> list[str]:
|
|
||||||
path: list[str] = []
|
|
||||||
current = collection_id
|
|
||||||
while current in collection_lookup:
|
|
||||||
row = collection_lookup[current]
|
|
||||||
path.append(str(row["collectionName"]))
|
|
||||||
parent = row["parentCollectionID"]
|
|
||||||
current = int(parent) if parent is not None else -1
|
|
||||||
path.reverse()
|
|
||||||
return path
|
|
||||||
|
|
||||||
def _read_notes(self, conn: sqlite3.Connection, item_id: int) -> list[str]:
|
|
||||||
rows = conn.execute(
|
|
||||||
"select note from itemNotes where parentItemID = ? order by itemID asc",
|
|
||||||
(item_id,),
|
|
||||||
)
|
|
||||||
return [self._strip_markup(str(row["note"])) for row in rows]
|
|
||||||
|
|
||||||
def _read_attachment_records(self, conn: sqlite3.Connection, item_id: int) -> list[dict[str, object]]:
|
|
||||||
rows = conn.execute(
|
|
||||||
"""
|
|
||||||
select contentType, path
|
|
||||||
from itemAttachments
|
|
||||||
where parentItemID = ?
|
|
||||||
order by itemID asc
|
|
||||||
""",
|
|
||||||
(item_id,),
|
|
||||||
)
|
|
||||||
attachments: list[dict[str, object]] = []
|
|
||||||
for row in rows:
|
|
||||||
raw_path = str(row["path"] or "")
|
|
||||||
content_type = str(row["contentType"] or "")
|
|
||||||
attachment_path = self._resolve_attachment_path(raw_path)
|
|
||||||
if attachment_path is None:
|
|
||||||
continue
|
|
||||||
text = self._extract_attachment_text_from_resolved_path(attachment_path, content_type)
|
|
||||||
attachments.append(
|
|
||||||
{
|
|
||||||
"path": str(attachment_path),
|
|
||||||
"filename": attachment_path.name,
|
|
||||||
"content_type": content_type,
|
|
||||||
"is_pdf": content_type == "application/pdf",
|
|
||||||
"text": text,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return attachments
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _public_attachment_metadata(attachment: dict[str, object]) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"path": attachment["path"],
|
|
||||||
"filename": attachment["filename"],
|
|
||||||
"content_type": attachment["content_type"],
|
|
||||||
"is_pdf": attachment["is_pdf"],
|
|
||||||
}
|
|
||||||
|
|
||||||
def _extract_attachment_text(self, raw_path: str, content_type: str) -> str:
|
|
||||||
attachment_path = self._resolve_attachment_path(raw_path)
|
|
||||||
if attachment_path is None:
|
|
||||||
return ""
|
|
||||||
return self._extract_attachment_text_from_resolved_path(attachment_path, content_type)
|
|
||||||
|
|
||||||
def _extract_attachment_text_from_resolved_path(self, attachment_path: Path, content_type: str) -> str:
|
|
||||||
if not attachment_path.exists():
|
|
||||||
return ""
|
|
||||||
|
|
||||||
if content_type == "application/pdf":
|
|
||||||
return self._extract_pdf_text(attachment_path)
|
|
||||||
|
|
||||||
return attachment_path.read_text(encoding="utf-8", errors="ignore").strip()
|
|
||||||
|
|
||||||
def _resolve_attachment_path(self, raw_path: str) -> Path | None:
|
|
||||||
if raw_path.startswith("storage:"):
|
|
||||||
relative_path = raw_path.removeprefix("storage:")
|
|
||||||
return self.zotero_data_dir / "storage" / relative_path
|
|
||||||
path = Path(raw_path)
|
|
||||||
if path.is_absolute():
|
|
||||||
return path
|
|
||||||
return self.zotero_data_dir / raw_path
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_pdf_text(path: Path) -> str:
|
|
||||||
completed = subprocess.run(
|
|
||||||
["pdftotext", str(path), "-"],
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
if completed.returncode != 0:
|
|
||||||
return ""
|
|
||||||
return completed.stdout.strip()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _strip_markup(value: str) -> str:
|
|
||||||
without_tags = re.sub(r"<[^>]+>", " ", value)
|
|
||||||
normalized = html.unescape(without_tags)
|
|
||||||
return " ".join(normalized.split())
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_year(value: str | None) -> str | None:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
match = re.search(r"(19|20)\d{2}", value)
|
|
||||||
if match:
|
|
||||||
return match.group(0)
|
|
||||||
return value[:4] if len(value) >= 4 else value
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
"""Test package."""
|
|
||||||
|
|
||||||
1
tests/fixtures/__init__.py
vendored
1
tests/fixtures/__init__.py
vendored
@ -1 +0,0 @@
|
|||||||
"""Test fixtures package."""
|
|
||||||
112
tests/fixtures/build_zotero_fixture.py
vendored
112
tests/fixtures/build_zotero_fixture.py
vendored
@ -1,112 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def build_fixture_zotero_dir(zotero_dir: Path) -> None:
|
|
||||||
storage_dir = zotero_dir / "storage" / "ATTACH01"
|
|
||||||
storage_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
(storage_dir / "paper.txt").write_text(
|
|
||||||
"This paper studies card pipelines for research writing.",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
conn = sqlite3.connect(zotero_dir / "zotero.sqlite")
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
cur.executescript(
|
|
||||||
"""
|
|
||||||
create table itemTypes (itemTypeID integer primary key, typeName text, templateItemTypeID int, display int);
|
|
||||||
create table items (itemID integer primary key, itemTypeID int, dateAdded text, dateModified text, clientDateModified text, libraryID int, key text, version int, synced int);
|
|
||||||
create table fields (fieldID integer primary key, fieldName text, fieldFormatID int);
|
|
||||||
create table itemDataValues (valueID integer primary key, value text);
|
|
||||||
create table itemData (itemID int, fieldID int, valueID int);
|
|
||||||
create table creators (creatorID integer primary key, firstName text, lastName text, fieldMode int);
|
|
||||||
create table creatorTypes (creatorTypeID integer primary key, creatorType text);
|
|
||||||
create table itemCreators (itemID int, creatorID int, creatorTypeID int, orderIndex int);
|
|
||||||
create table tags (tagID integer primary key, name text);
|
|
||||||
create table itemTags (itemID int, tagID int, type int);
|
|
||||||
create table collections (collectionID integer primary key, collectionName text, parentCollectionID int, clientDateModified text, libraryID int, key text, version int, synced int);
|
|
||||||
create table collectionItems (collectionID int, itemID int, orderIndex int);
|
|
||||||
create table itemNotes (itemID integer primary key, parentItemID int, note text, title text);
|
|
||||||
create table itemAttachments (itemID integer primary key, parentItemID int, linkMode int, contentType text, charsetID int, path text, syncState int, storageModTime int, storageHash text, lastProcessedModificationTime int);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
cur.executemany(
|
|
||||||
"insert into itemTypes(itemTypeID, typeName, templateItemTypeID, display) values (?, ?, ?, ?)",
|
|
||||||
[
|
|
||||||
(1, "journalArticle", None, 1),
|
|
||||||
(2, "note", None, 1),
|
|
||||||
(3, "attachment", None, 1),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
cur.executemany(
|
|
||||||
"insert into fields(fieldID, fieldName, fieldFormatID) values (?, ?, ?)",
|
|
||||||
[
|
|
||||||
(1, "title", 1),
|
|
||||||
(2, "abstractNote", 1),
|
|
||||||
(3, "date", 1),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
cur.executemany(
|
|
||||||
"insert into itemDataValues(valueID, value) values (?, ?)",
|
|
||||||
[
|
|
||||||
(1, "Card Pipelines for Research Writing"),
|
|
||||||
(2, "Merged metadata and notes improve drafting support."),
|
|
||||||
(3, "2024"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"insert into items(itemID, itemTypeID, dateAdded, dateModified, clientDateModified, libraryID, key, version, synced) values (?, ?, '', '', '', 1, ?, 1, 1)",
|
|
||||||
(1, 1, "PAPER0001"),
|
|
||||||
)
|
|
||||||
cur.executemany(
|
|
||||||
"insert into itemData(itemID, fieldID, valueID) values (?, ?, ?)",
|
|
||||||
[
|
|
||||||
(1, 1, 1),
|
|
||||||
(1, 2, 2),
|
|
||||||
(1, 3, 3),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"insert into creators(creatorID, firstName, lastName, fieldMode) values (1, 'Alice', 'Smith', 0)"
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"insert into creatorTypes(creatorTypeID, creatorType) values (1, 'author')"
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"insert into itemCreators(itemID, creatorID, creatorTypeID, orderIndex) values (1, 1, 1, 0)"
|
|
||||||
)
|
|
||||||
cur.executemany(
|
|
||||||
"insert into tags(tagID, name) values (?, ?)",
|
|
||||||
[
|
|
||||||
(1, "llm"),
|
|
||||||
(2, "writing"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
cur.executemany(
|
|
||||||
"insert into itemTags(itemID, tagID, type) values (?, ?, 0)",
|
|
||||||
[
|
|
||||||
(1, 1),
|
|
||||||
(1, 2),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
cur.executemany(
|
|
||||||
"insert into collections(collectionID, collectionName, parentCollectionID, clientDateModified, libraryID, key, version, synced) values (?, ?, ?, '', 1, ?, 1, 1)",
|
|
||||||
[
|
|
||||||
(1, "Theory", None, "COLL0001"),
|
|
||||||
(2, "Drafting", 1, "COLL0002"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
cur.execute("insert into collectionItems(collectionID, itemID, orderIndex) values (2, 1, 0)")
|
|
||||||
cur.execute(
|
|
||||||
"insert into itemNotes(itemID, parentItemID, note, title) values (2, 1, '<div><p>Merged notes matter.</p></div>', 'note')"
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"insert into itemAttachments(itemID, parentItemID, linkMode, contentType, charsetID, path, syncState, storageModTime, storageHash, lastProcessedModificationTime) values (3, 1, 1, 'text/plain', null, 'storage:ATTACH01/paper.txt', 0, 0, '', 0)"
|
|
||||||
)
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
@ -1,21 +1,17 @@
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from tests.fixtures.build_zotero_fixture import build_fixture_zotero_dir
|
from tests.fixtures.build_zotero_fixture import build_fixture_zotero_dir
|
||||||
from zotero_kb.api import CreateProjectRequest, RenameProjectRequest, WritingPromptRequest, create_app
|
from zotero_kb.api import CreateProjectRequest, WritingPromptRequest, create_app
|
||||||
from zotero_kb.api import GenerateCardsRequest
|
|
||||||
from zotero_kb.config import AppConfig
|
from zotero_kb.config import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class FakeLlmClient:
|
class FakeLlmClient:
|
||||||
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
|
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
|
||||||
title = str(source_bundle["title"])
|
title = str(source_bundle["title"])
|
||||||
language = str(source_bundle.get("card_language", "en"))
|
|
||||||
return {
|
return {
|
||||||
"summary": f"中文摘要 {title}" if language == "zh" else f"Summary for {title}",
|
"summary": f"Summary for {title}",
|
||||||
"core_claims": [f"中文论点 {title}"] if language == "zh" else [f"{title} supports scoped retrieval."],
|
"core_claims": [f"{title} supports scoped retrieval."],
|
||||||
"methods": ["Method details."],
|
"methods": ["Method details."],
|
||||||
"evidence": ["Evidence details."],
|
"evidence": ["Evidence details."],
|
||||||
"quotable_passages": [f"{title} supports scoped retrieval."],
|
"quotable_passages": [f"{title} supports scoped retrieval."],
|
||||||
@ -54,81 +50,12 @@ def test_create_project_endpoint(tmp_path: Path) -> None:
|
|||||||
name="Thesis Chapter 2",
|
name="Thesis Chapter 2",
|
||||||
llm_provider="openai",
|
llm_provider="openai",
|
||||||
llm_model="gpt-5-mini",
|
llm_model="gpt-5-mini",
|
||||||
card_language="zh",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response["id"] == "thesis-ch2"
|
assert response["id"] == "thesis-ch2"
|
||||||
|
|
||||||
|
|
||||||
def test_rename_project_endpoint_updates_project_name(tmp_path: Path) -> None:
|
|
||||||
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
|
|
||||||
create_project = _route(app, "/api/projects", "POST")
|
|
||||||
rename_project = _route(app, "/api/projects/{project_id}", "PATCH")
|
|
||||||
list_projects = _route(app, "/api/projects", "GET")
|
|
||||||
|
|
||||||
create_project(
|
|
||||||
CreateProjectRequest(
|
|
||||||
project_id="thesis-ch2",
|
|
||||||
name="Old Name",
|
|
||||||
llm_provider="openai",
|
|
||||||
llm_model="gpt-5-mini",
|
|
||||||
card_language="en",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
response = rename_project("thesis-ch2", RenameProjectRequest(name="New Name", card_language="zh"))
|
|
||||||
|
|
||||||
assert response["id"] == "thesis-ch2"
|
|
||||||
assert response["name"] == "New Name"
|
|
||||||
assert list_projects()[0]["name"] == "New Name"
|
|
||||||
assert list_projects()[0]["card_language"] == "zh"
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_project_endpoint_removes_project_from_list(tmp_path: Path) -> None:
|
|
||||||
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
|
|
||||||
create_project = _route(app, "/api/projects", "POST")
|
|
||||||
delete_project = _route(app, "/api/projects/{project_id}", "DELETE")
|
|
||||||
list_projects = _route(app, "/api/projects", "GET")
|
|
||||||
|
|
||||||
create_project(
|
|
||||||
CreateProjectRequest(
|
|
||||||
project_id="thesis-ch2",
|
|
||||||
name="Thesis Chapter 2",
|
|
||||||
llm_provider="openai",
|
|
||||||
llm_model="gpt-5-mini",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
response = delete_project("thesis-ch2")
|
|
||||||
|
|
||||||
assert response["deleted"] == "thesis-ch2"
|
|
||||||
assert list_projects() == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_project_post_fallback_endpoint_removes_project_from_list(tmp_path: Path) -> None:
|
|
||||||
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
create_response = client.post(
|
|
||||||
"/api/projects",
|
|
||||||
json={
|
|
||||||
"project_id": "thesis-ch2",
|
|
||||||
"name": "Thesis Chapter 2",
|
|
||||||
"llm_provider": "openai",
|
|
||||||
"llm_model": "gpt-5-mini",
|
|
||||||
"card_language": "zh",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert create_response.status_code == 201
|
|
||||||
|
|
||||||
delete_response = client.post("/api/projects/thesis-ch2/delete")
|
|
||||||
|
|
||||||
assert delete_response.status_code == 200
|
|
||||||
assert delete_response.json() == {"deleted": "thesis-ch2"}
|
|
||||||
assert client.get("/api/projects").json() == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_import_selected_items_endpoint(tmp_path: Path) -> None:
|
def test_import_selected_items_endpoint(tmp_path: Path) -> None:
|
||||||
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
|
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
|
||||||
create_project = _route(app, "/api/projects", "POST")
|
create_project = _route(app, "/api/projects", "POST")
|
||||||
@ -140,7 +67,6 @@ def test_import_selected_items_endpoint(tmp_path: Path) -> None:
|
|||||||
name="Thesis Chapter 2",
|
name="Thesis Chapter 2",
|
||||||
llm_provider="openai",
|
llm_provider="openai",
|
||||||
llm_model="gpt-5-mini",
|
llm_model="gpt-5-mini",
|
||||||
card_language="zh",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -161,7 +87,6 @@ def test_recommend_citations_endpoint(tmp_path: Path) -> None:
|
|||||||
name="Thesis Chapter 2",
|
name="Thesis Chapter 2",
|
||||||
llm_provider="openai",
|
llm_provider="openai",
|
||||||
llm_model="gpt-5-mini",
|
llm_model="gpt-5-mini",
|
||||||
card_language="zh",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
import_selected("thesis-ch2")
|
import_selected("thesis-ch2")
|
||||||
@ -184,7 +109,6 @@ def test_create_project_persists_deepseek_provider(tmp_path: Path) -> None:
|
|||||||
name="Thesis Chapter 2",
|
name="Thesis Chapter 2",
|
||||||
llm_provider="deepseek",
|
llm_provider="deepseek",
|
||||||
llm_model="deepseek-chat",
|
llm_model="deepseek-chat",
|
||||||
card_language="en",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -193,7 +117,6 @@ def test_create_project_persists_deepseek_provider(tmp_path: Path) -> None:
|
|||||||
)
|
)
|
||||||
assert project_payload["llm"]["provider"] == "deepseek"
|
assert project_payload["llm"]["provider"] == "deepseek"
|
||||||
assert project_payload["llm"]["model"] == "deepseek-chat"
|
assert project_payload["llm"]["model"] == "deepseek-chat"
|
||||||
assert project_payload["card_language"] == "en"
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_library_items_endpoint(tmp_path: Path) -> None:
|
def test_search_library_items_endpoint(tmp_path: Path) -> None:
|
||||||
@ -225,18 +148,6 @@ def test_collection_items_endpoint_includes_descendants(tmp_path: Path) -> None:
|
|||||||
assert payload["collection_key"] == "COLL0001"
|
assert payload["collection_key"] == "COLL0001"
|
||||||
|
|
||||||
|
|
||||||
def test_item_attachments_endpoint_returns_attachment_metadata(tmp_path: Path) -> None:
|
|
||||||
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
|
|
||||||
endpoint = _route(app, "/api/zotero/items/{item_key}/attachments", "GET")
|
|
||||||
|
|
||||||
payload = endpoint("PAPER0001")
|
|
||||||
|
|
||||||
assert payload["item_key"] == "PAPER0001"
|
|
||||||
assert payload["attachments"][0]["filename"] == "paper.txt"
|
|
||||||
assert payload["attachments"][0]["content_type"] == "text/plain"
|
|
||||||
assert payload["attachments"][0]["is_pdf"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_import_item_keys_endpoint_without_bridge(tmp_path: Path) -> None:
|
def test_import_item_keys_endpoint_without_bridge(tmp_path: Path) -> None:
|
||||||
config = make_test_config(tmp_path)
|
config = make_test_config(tmp_path)
|
||||||
config.bridge_file.unlink()
|
config.bridge_file.unlink()
|
||||||
@ -285,8 +196,7 @@ def test_import_state_endpoint_shows_pending_when_no_card(tmp_path: Path) -> Non
|
|||||||
assert payload["done_count"] == 0
|
assert payload["done_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_import_state_endpoint_shows_pending_after_import(tmp_path: Path) -> None:
|
def test_import_state_endpoint_shows_done_when_card_exists(tmp_path: Path) -> None:
|
||||||
"""After import_item_keys, card_status should be pending (cards generated separately)."""
|
|
||||||
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
|
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
|
||||||
create_project = _route(app, "/api/projects", "POST")
|
create_project = _route(app, "/api/projects", "POST")
|
||||||
import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST")
|
import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST")
|
||||||
@ -307,134 +217,6 @@ def test_import_state_endpoint_shows_pending_after_import(tmp_path: Path) -> Non
|
|||||||
assert payload["project_id"] == "thesis-ch2"
|
assert payload["project_id"] == "thesis-ch2"
|
||||||
assert len(payload["items"]) == 1
|
assert len(payload["items"]) == 1
|
||||||
assert payload["items"][0]["item_key"] == "PAPER0001"
|
assert payload["items"][0]["item_key"] == "PAPER0001"
|
||||||
assert payload["items"][0]["card_status"] == "pending"
|
assert payload["items"][0]["card_status"] == "done"
|
||||||
assert payload["pending_count"] == 1
|
assert payload["pending_count"] == 0
|
||||||
assert payload["done_count"] == 0
|
assert payload["done_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_import_item_keys_does_not_generate_cards(tmp_path: Path) -> None:
|
|
||||||
"""import_item_keys should only register items, not generate cards."""
|
|
||||||
config = make_test_config(tmp_path)
|
|
||||||
config.bridge_file.unlink()
|
|
||||||
app = create_app(config, llm_client=FakeLlmClient())
|
|
||||||
create_project = _route(app, "/api/projects", "POST")
|
|
||||||
import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST")
|
|
||||||
|
|
||||||
create_project(
|
|
||||||
CreateProjectRequest(
|
|
||||||
project_id="thesis-ch2",
|
|
||||||
name="Thesis Chapter 2",
|
|
||||||
llm_provider="openai",
|
|
||||||
llm_model="gpt-5-mini",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
|
|
||||||
|
|
||||||
# Verify item was added to selected-items.json
|
|
||||||
selected_items_path = tmp_path / "workspace" / "projects" / "thesis-ch2" / "selected-items.json"
|
|
||||||
selected_items = json.loads(selected_items_path.read_text(encoding="utf-8"))
|
|
||||||
assert "PAPER0001" in selected_items
|
|
||||||
|
|
||||||
# Verify item metadata was written to items.json
|
|
||||||
items_index_path = tmp_path / "workspace" / "library" / "index" / "items.json"
|
|
||||||
items_index = json.loads(items_index_path.read_text(encoding="utf-8"))
|
|
||||||
assert "PAPER0001" in items_index
|
|
||||||
assert items_index["PAPER0001"]["title"] == "Card Pipelines for Research Writing"
|
|
||||||
|
|
||||||
# Verify NO card was generated (cards.json should not have PAPER0001)
|
|
||||||
cards_index_path = tmp_path / "workspace" / "library" / "index" / "cards.json"
|
|
||||||
if cards_index_path.exists():
|
|
||||||
cards_index = json.loads(cards_index_path.read_text(encoding="utf-8"))
|
|
||||||
assert "PAPER0001" not in cards_index
|
|
||||||
else:
|
|
||||||
# File not existing means no cards were generated, which is expected
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def test_cards_generate_creates_cards_for_pending_items(tmp_path: Path) -> None:
|
|
||||||
"""cards/generate endpoint should generate cards for items that have pending status."""
|
|
||||||
config = make_test_config(tmp_path)
|
|
||||||
config.bridge_file.unlink()
|
|
||||||
app = create_app(config, llm_client=FakeLlmClient())
|
|
||||||
create_project = _route(app, "/api/projects", "POST")
|
|
||||||
import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST")
|
|
||||||
import_state = _route(app, "/api/projects/{project_id}/import-state", "GET")
|
|
||||||
generate_cards = _route(app, "/api/projects/{project_id}/cards/generate", "POST")
|
|
||||||
|
|
||||||
create_project(
|
|
||||||
CreateProjectRequest(
|
|
||||||
project_id="thesis-ch2",
|
|
||||||
name="Thesis Chapter 2",
|
|
||||||
llm_provider="openai",
|
|
||||||
llm_model="gpt-5-mini",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Import item keys first (writes to items.json but doesn't generate cards)
|
|
||||||
import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
|
|
||||||
|
|
||||||
# Verify card_status is pending before generation
|
|
||||||
state_before = import_state("thesis-ch2")
|
|
||||||
assert state_before["items"][0]["card_status"] == "pending"
|
|
||||||
|
|
||||||
# Call generate endpoint
|
|
||||||
response = generate_cards("thesis-ch2", GenerateCardsRequest(item_keys=["PAPER0001"]))
|
|
||||||
|
|
||||||
# Verify response structure
|
|
||||||
assert response["project_id"] == "thesis-ch2"
|
|
||||||
assert response["generated"] == ["PAPER0001"]
|
|
||||||
assert response["failed"] == []
|
|
||||||
assert len(response["items"]) == 1
|
|
||||||
assert response["items"][0]["item_key"] == "PAPER0001"
|
|
||||||
assert response["items"][0]["card_status"] == "done"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cards_generate_allows_regeneration_for_done_items(tmp_path: Path) -> None:
|
|
||||||
config = make_test_config(tmp_path)
|
|
||||||
config.bridge_file.unlink()
|
|
||||||
app = create_app(config, llm_client=FakeLlmClient())
|
|
||||||
create_project = _route(app, "/api/projects", "POST")
|
|
||||||
import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST")
|
|
||||||
generate_cards = _route(app, "/api/projects/{project_id}/cards/generate", "POST")
|
|
||||||
|
|
||||||
create_project(
|
|
||||||
CreateProjectRequest(
|
|
||||||
project_id="thesis-ch2",
|
|
||||||
name="Thesis Chapter 2",
|
|
||||||
llm_provider="openai",
|
|
||||||
llm_model="gpt-5-mini",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
|
|
||||||
first_response = generate_cards("thesis-ch2", GenerateCardsRequest(item_keys=["PAPER0001"]))
|
|
||||||
second_response = generate_cards("thesis-ch2", GenerateCardsRequest(item_keys=["PAPER0001"]))
|
|
||||||
|
|
||||||
assert first_response["generated"] == ["PAPER0001"]
|
|
||||||
assert second_response["generated"] == ["PAPER0001"]
|
|
||||||
assert second_response["failed"] == []
|
|
||||||
assert second_response["items"][0]["card_status"] == "done"
|
|
||||||
|
|
||||||
|
|
||||||
def test_import_item_keys_endpoint_returns_pending_item_in_project_view(tmp_path: Path) -> None:
|
|
||||||
config = make_test_config(tmp_path)
|
|
||||||
config.bridge_file.unlink()
|
|
||||||
app = create_app(config, llm_client=FakeLlmClient())
|
|
||||||
create_project = _route(app, "/api/projects", "POST")
|
|
||||||
import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST")
|
|
||||||
|
|
||||||
create_project(
|
|
||||||
CreateProjectRequest(
|
|
||||||
project_id="thesis-ch2",
|
|
||||||
name="Thesis Chapter 2",
|
|
||||||
llm_provider="openai",
|
|
||||||
llm_model="gpt-5-mini",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
|
|
||||||
|
|
||||||
assert payload["project_view"]["selected_items"] == ["PAPER0001"]
|
|
||||||
assert payload["project_view"]["items"][0]["item_key"] == "PAPER0001"
|
|
||||||
assert payload["project_view"]["items"][0]["card_status"] == "pending"
|
|
||||||
|
|||||||
@ -1,98 +0,0 @@
|
|||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from zotero_kb.cards import CardBuilder
|
|
||||||
from zotero_kb.zotero_reader import ZoteroItemRecord
|
|
||||||
|
|
||||||
|
|
||||||
class FakeLlmClient:
|
|
||||||
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
|
|
||||||
language = str(source_bundle.get("card_language", "en"))
|
|
||||||
return {
|
|
||||||
"summary": "中文卡片摘要。" if language == "zh" else "Merged metadata and attachment text improve drafting support.",
|
|
||||||
"core_claims": [
|
|
||||||
"中文卡片 claim。" if language == "zh" else "Project-scoped cards reduce irrelevant retrieval.",
|
|
||||||
],
|
|
||||||
"methods": [
|
|
||||||
"Combines notes, metadata, and full text.",
|
|
||||||
],
|
|
||||||
"evidence": [
|
|
||||||
"The merged pipeline improved citation precision.",
|
|
||||||
],
|
|
||||||
"citations": [
|
|
||||||
{
|
|
||||||
"claim": "Project-scoped cards reduce irrelevant retrieval.",
|
|
||||||
"quote": "Project-scoped card retrieval improves citation precision during drafting.",
|
|
||||||
"paraphrase": "Scoped retrieval improved citation precision during drafting.",
|
|
||||||
"quote_source": "attachment_texts",
|
|
||||||
"use_case": "direct_quote",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"quotable_passages": [
|
|
||||||
"Project-scoped card retrieval improves citation precision during drafting.",
|
|
||||||
],
|
|
||||||
"writing_hints": [
|
|
||||||
"Use when arguing for scoped retrieval during drafting.",
|
|
||||||
],
|
|
||||||
"keywords": ["retrieval", "writing"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_card_writes_markdown_and_indexes(tmp_path: Path) -> None:
|
|
||||||
item = ZoteroItemRecord(
|
|
||||||
item_key="PAPER0001",
|
|
||||||
title="Card Pipelines for Research Writing",
|
|
||||||
creators=["Alice Smith", "Bob Li"],
|
|
||||||
year="2024",
|
|
||||||
item_type="journalArticle",
|
|
||||||
abstract="Merged metadata and notes improve drafting support.",
|
|
||||||
tags=["llm", "writing"],
|
|
||||||
collection_paths=[["Theory", "Drafting"]],
|
|
||||||
notes=["Merged notes matter."],
|
|
||||||
attachments=[
|
|
||||||
{
|
|
||||||
"path": str(tmp_path / "zotero" / "storage" / "ATTACH01" / "paper.txt"),
|
|
||||||
"filename": "paper.txt",
|
|
||||||
"content_type": "text/plain",
|
|
||||||
"is_pdf": False,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
attachment_texts=["This paper studies card pipelines for research writing."],
|
|
||||||
)
|
|
||||||
builder = CardBuilder(workspace_dir=tmp_path, llm_client=FakeLlmClient())
|
|
||||||
|
|
||||||
result = builder.build_or_update(item, "en")
|
|
||||||
|
|
||||||
expected_path = (
|
|
||||||
tmp_path
|
|
||||||
/ "library"
|
|
||||||
/ "collections"
|
|
||||||
/ "Theory"
|
|
||||||
/ "Drafting"
|
|
||||||
/ "Card Pipelines for Research Writing [PAPER0001][en].md"
|
|
||||||
)
|
|
||||||
assert result.card_path == expected_path
|
|
||||||
card_text = result.card_path.read_text(encoding="utf-8")
|
|
||||||
assert card_text.startswith("---")
|
|
||||||
assert "# Citations" in card_text
|
|
||||||
assert "quote_source: attachment_texts" in card_text
|
|
||||||
assert "locator:" not in card_text
|
|
||||||
|
|
||||||
cards_index = json.loads((tmp_path / "library" / "index" / "cards.json").read_text(encoding="utf-8"))
|
|
||||||
assert cards_index["PAPER0001"]["en"]["title"] == "Card Pipelines for Research Writing"
|
|
||||||
assert cards_index["PAPER0001"]["en"]["language"] == "en"
|
|
||||||
assert cards_index["PAPER0001"]["en"]["citations"][0]["use_case"] == "direct_quote"
|
|
||||||
assert "locator" not in cards_index["PAPER0001"]["en"]["citations"][0]
|
|
||||||
assert cards_index["PAPER0001"]["en"]["attachments"][0]["filename"] == "paper.txt"
|
|
||||||
assert cards_index["PAPER0001"]["en"]["attachments"][0]["is_pdf"] is False
|
|
||||||
|
|
||||||
items_index = json.loads((tmp_path / "library" / "index" / "items.json").read_text(encoding="utf-8"))
|
|
||||||
assert items_index["PAPER0001"]["card_path"] == str(expected_path)
|
|
||||||
assert items_index["PAPER0001"]["card_paths"]["en"] == str(expected_path)
|
|
||||||
|
|
||||||
source_bundle = json.loads(
|
|
||||||
(tmp_path / "library" / "cache" / "source-bundles" / "PAPER0001.en.json").read_text(encoding="utf-8")
|
|
||||||
)
|
|
||||||
assert source_bundle["item_key"] == "PAPER0001"
|
|
||||||
assert source_bundle["card_language"] == "en"
|
|
||||||
assert source_bundle["attachments"][0]["filename"] == "paper.txt"
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
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"
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from zotero_kb.llm import DeepSeekCardGenerationClient, create_card_generation_client
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_card_generation_client_requires_deepseek_api_key() -> None:
|
|
||||||
with pytest.raises(ValueError, match="DEEPSEEK_API_KEY"):
|
|
||||||
create_card_generation_client("deepseek", "deepseek-chat", env={})
|
|
||||||
|
|
||||||
|
|
||||||
def test_deepseek_client_uses_env_api_key_and_parses_json() -> None:
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
def fake_request(url: str, payload: dict[str, object], headers: dict[str, str]) -> dict[str, object]:
|
|
||||||
captured["url"] = url
|
|
||||||
captured["payload"] = payload
|
|
||||||
captured["headers"] = headers
|
|
||||||
return {
|
|
||||||
"choices": [
|
|
||||||
{
|
|
||||||
"message": {
|
|
||||||
"content": json.dumps(
|
|
||||||
{
|
|
||||||
"summary": "DeepSeek summary",
|
|
||||||
"core_claims": ["Claim A"],
|
|
||||||
"methods": ["Method A"],
|
|
||||||
"evidence": ["Evidence A"],
|
|
||||||
"citations": [
|
|
||||||
{
|
|
||||||
"claim": "Claim A",
|
|
||||||
"quote": "Exact supporting quote.",
|
|
||||||
"paraphrase": "Paraphrased support.",
|
|
||||||
"quote_source": "attachment_texts",
|
|
||||||
"use_case": "direct_quote",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"quotable_passages": ["Quote A"],
|
|
||||||
"writing_hints": ["Hint A"],
|
|
||||||
"keywords": ["kw-a"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
client = create_card_generation_client(
|
|
||||||
"deepseek",
|
|
||||||
"deepseek-chat",
|
|
||||||
env={"DEEPSEEK_API_KEY": "sk-test"},
|
|
||||||
request_fn=fake_request,
|
|
||||||
)
|
|
||||||
result = client.generate_card(
|
|
||||||
{
|
|
||||||
"title": "Scoped Retrieval for Drafting",
|
|
||||||
"abstract": "Merged metadata improves drafting.",
|
|
||||||
"card_language": "zh",
|
|
||||||
"notes": ["Project scoping helps."],
|
|
||||||
"attachment_texts": ["Attachment evidence."],
|
|
||||||
"tags": ["retrieval"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["summary"] == "DeepSeek summary"
|
|
||||||
assert "locator" not in result["citations"][0]
|
|
||||||
assert captured["url"] == "https://api.deepseek.com/v1/chat/completions"
|
|
||||||
assert captured["payload"]["model"] == "deepseek-chat"
|
|
||||||
assert "Return 1-3 citations whenever the source bundle contains usable supporting text" in captured["payload"]["messages"][1]["content"]
|
|
||||||
assert "quote_source must identify where the quote came from" in captured["payload"]["messages"][1]["content"]
|
|
||||||
assert "Write summary, core_claims, methods, evidence, paraphrase fields, and writing_hints in Chinese." in captured["payload"]["messages"][1]["content"]
|
|
||||||
assert captured["headers"]["Authorization"] == "Bearer sk-test"
|
|
||||||
|
|
||||||
|
|
||||||
def test_deepseek_client_strips_markdown_fences() -> None:
|
|
||||||
client = DeepSeekCardGenerationClient(
|
|
||||||
api_key="sk-test",
|
|
||||||
request_fn=lambda *_args, **_kwargs: {
|
|
||||||
"choices": [
|
|
||||||
{
|
|
||||||
"message": {
|
|
||||||
"content": "```json\n{\"summary\":\"Fence summary\",\"core_claims\":[],\"methods\":[],\"evidence\":[],\"citations\":[],\"quotable_passages\":[],\"writing_hints\":[],\"keywords\":[]}\n```"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
result = client.generate_card({"title": "Paper"})
|
|
||||||
|
|
||||||
assert result["summary"] == "Fence summary"
|
|
||||||
@ -1,229 +0,0 @@
|
|||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from zotero_kb.cards import CardBuilder
|
|
||||||
from zotero_kb.config import AppConfig
|
|
||||||
from zotero_kb.projects import ProjectService
|
|
||||||
from zotero_kb.workspace import Workspace
|
|
||||||
from zotero_kb.zotero_reader import ZoteroItemRecord
|
|
||||||
|
|
||||||
|
|
||||||
class FakeLlmClient:
|
|
||||||
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
|
|
||||||
title = str(source_bundle["title"])
|
|
||||||
language = str(source_bundle.get("card_language", "en"))
|
|
||||||
if "Scoped" in title:
|
|
||||||
summary = "项目范围检索能改善写作。" if language == "zh" else "Project-scoped retrieval improves drafting."
|
|
||||||
claims = ["项目范围限定能提升引文精度。"] if language == "zh" else ["Project scoping improves citation precision."]
|
|
||||||
else:
|
|
||||||
summary = "无关检索基线。" if language == "zh" else "Unrelated retrieval baseline."
|
|
||||||
claims = ["基线检索范围较宽。"] if language == "zh" else ["Baseline retrieval is broad."]
|
|
||||||
return {
|
|
||||||
"summary": summary,
|
|
||||||
"core_claims": claims,
|
|
||||||
"methods": ["Method details."],
|
|
||||||
"evidence": ["Evidence details."],
|
|
||||||
"quotable_passages": claims,
|
|
||||||
"writing_hints": [summary],
|
|
||||||
"keywords": ["retrieval"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_item(item_key: str, title: str) -> ZoteroItemRecord:
|
|
||||||
return ZoteroItemRecord(
|
|
||||||
item_key=item_key,
|
|
||||||
title=title,
|
|
||||||
creators=["Alice Smith"],
|
|
||||||
year="2024",
|
|
||||||
item_type="journalArticle",
|
|
||||||
abstract=title,
|
|
||||||
tags=["retrieval"],
|
|
||||||
collection_paths=[["Theory", "Drafting"]],
|
|
||||||
notes=[title],
|
|
||||||
attachments=[],
|
|
||||||
attachment_texts=[title],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_item_to_project_updates_selected_items_and_project_index(tmp_path: Path) -> None:
|
|
||||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
|
||||||
workspace = Workspace(config)
|
|
||||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "en")
|
|
||||||
builder = CardBuilder(config.workspace_dir, FakeLlmClient())
|
|
||||||
builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting"))
|
|
||||||
|
|
||||||
service = ProjectService(config.workspace_dir)
|
|
||||||
payload = service.add_items("thesis-ch2", ["PAPER0001"])
|
|
||||||
|
|
||||||
assert payload["selected_items"] == ["PAPER0001"]
|
|
||||||
assert payload["cards"][0]["item_key"] == "PAPER0001"
|
|
||||||
|
|
||||||
selected_items = json.loads(
|
|
||||||
(config.workspace_dir / "projects" / "thesis-ch2" / "selected-items.json").read_text(encoding="utf-8")
|
|
||||||
)
|
|
||||||
assert selected_items == ["PAPER0001"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_remove_item_from_project_updates_selected_items(tmp_path: Path) -> None:
|
|
||||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
|
||||||
workspace = Workspace(config)
|
|
||||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "en")
|
|
||||||
builder = CardBuilder(config.workspace_dir, FakeLlmClient())
|
|
||||||
builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting"))
|
|
||||||
|
|
||||||
service = ProjectService(config.workspace_dir)
|
|
||||||
service.add_items("thesis-ch2", ["PAPER0001"])
|
|
||||||
payload = service.remove_item("thesis-ch2", "PAPER0001")
|
|
||||||
|
|
||||||
assert payload["selected_items"] == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_item_to_project_returns_pending_item_when_card_not_generated(tmp_path: Path) -> None:
|
|
||||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
|
||||||
workspace = Workspace(config)
|
|
||||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "zh")
|
|
||||||
|
|
||||||
items_index_path = config.workspace_dir / "library" / "index" / "items.json"
|
|
||||||
items_index_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
items_index_path.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"PAPER0001": {
|
|
||||||
"item_key": "PAPER0001",
|
|
||||||
"title": "Pending Item",
|
|
||||||
"creators": ["Alice Smith"],
|
|
||||||
"year": "2024",
|
|
||||||
"item_type": "journalArticle",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
service = ProjectService(config.workspace_dir)
|
|
||||||
payload = service.add_items("thesis-ch2", ["PAPER0001"])
|
|
||||||
|
|
||||||
assert payload["selected_items"] == ["PAPER0001"]
|
|
||||||
assert len(payload["items"]) == 1
|
|
||||||
assert payload["items"][0]["item_key"] == "PAPER0001"
|
|
||||||
assert payload["items"][0]["card_status"] == "pending"
|
|
||||||
|
|
||||||
|
|
||||||
def test_project_view_treats_legacy_cards_as_english_only(tmp_path: Path) -> None:
|
|
||||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
|
||||||
workspace = Workspace(config)
|
|
||||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "zh")
|
|
||||||
|
|
||||||
cards_index_path = config.workspace_dir / "library" / "index" / "cards.json"
|
|
||||||
cards_index_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
cards_index_path.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"PAPER0001": {
|
|
||||||
"item_key": "PAPER0001",
|
|
||||||
"title": "Legacy Card",
|
|
||||||
"summary": "English legacy summary",
|
|
||||||
"claims": ["Legacy claim"],
|
|
||||||
"citations": [],
|
|
||||||
"quotable_spans": [],
|
|
||||||
"writing_hints": [],
|
|
||||||
"attachments": [],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
items_index_path = config.workspace_dir / "library" / "index" / "items.json"
|
|
||||||
items_index_path.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"PAPER0001": {
|
|
||||||
"item_key": "PAPER0001",
|
|
||||||
"title": "Legacy Card",
|
|
||||||
"creators": ["Alice Smith"],
|
|
||||||
"year": "2024",
|
|
||||||
"item_type": "journalArticle",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
service = ProjectService(config.workspace_dir)
|
|
||||||
payload = service.add_items("thesis-ch2", ["PAPER0001"])
|
|
||||||
|
|
||||||
assert payload["items"][0]["card_status"] == "pending"
|
|
||||||
workspace.rename_project("thesis-ch2", "Thesis Chapter 2", "en")
|
|
||||||
english_view = service.get_project_view("thesis-ch2")
|
|
||||||
assert english_view["items"][0]["card_status"] == "done"
|
|
||||||
assert english_view["items"][0]["summary"] == "English legacy summary"
|
|
||||||
|
|
||||||
|
|
||||||
def test_project_view_reads_only_current_language_card_variant(tmp_path: Path) -> None:
|
|
||||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
|
||||||
workspace = Workspace(config)
|
|
||||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "zh")
|
|
||||||
|
|
||||||
cards_index_path = config.workspace_dir / "library" / "index" / "cards.json"
|
|
||||||
cards_index_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
cards_index_path.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"PAPER0001": {
|
|
||||||
"zh": {
|
|
||||||
"item_key": "PAPER0001",
|
|
||||||
"language": "zh",
|
|
||||||
"title": "Bilingual Card",
|
|
||||||
"summary": "中文摘要",
|
|
||||||
"claims": ["中文 claim"],
|
|
||||||
"citations": [],
|
|
||||||
"quotable_spans": [],
|
|
||||||
"writing_hints": [],
|
|
||||||
"attachments": [],
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"item_key": "PAPER0001",
|
|
||||||
"language": "en",
|
|
||||||
"title": "Bilingual Card",
|
|
||||||
"summary": "English summary",
|
|
||||||
"claims": ["English claim"],
|
|
||||||
"citations": [],
|
|
||||||
"quotable_spans": [],
|
|
||||||
"writing_hints": [],
|
|
||||||
"attachments": [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
items_index_path = config.workspace_dir / "library" / "index" / "items.json"
|
|
||||||
items_index_path.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"PAPER0001": {
|
|
||||||
"item_key": "PAPER0001",
|
|
||||||
"title": "Bilingual Card",
|
|
||||||
"creators": ["Alice Smith"],
|
|
||||||
"year": "2024",
|
|
||||||
"item_type": "journalArticle",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
service = ProjectService(config.workspace_dir)
|
|
||||||
zh_view = service.add_items("thesis-ch2", ["PAPER0001"])
|
|
||||||
assert zh_view["items"][0]["summary"] == "中文摘要"
|
|
||||||
assert zh_view["cards"][0]["language"] == "zh"
|
|
||||||
|
|
||||||
workspace.rename_project("thesis-ch2", "Thesis Chapter 2", "en")
|
|
||||||
en_view = service.get_project_view("thesis-ch2")
|
|
||||||
assert en_view["items"][0]["summary"] == "English summary"
|
|
||||||
assert en_view["cards"][0]["language"] == "en"
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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()
|
|
||||||
174
tests/test_ui.py
174
tests/test_ui.py
@ -1,5 +1,3 @@
|
|||||||
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
|
||||||
|
|
||||||
@ -26,7 +24,6 @@ def test_index_contains_base_page_forms(tmp_path) -> None:
|
|||||||
assert 'id="create-project-form"' in html
|
assert 'id="create-project-form"' in html
|
||||||
assert 'id="recommend-form"' in html
|
assert 'id="recommend-form"' in html
|
||||||
assert 'id="plan-form"' in html
|
assert 'id="plan-form"' in html
|
||||||
assert 'id="card-language"' in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_index_contains_import_window_controls(tmp_path) -> None:
|
def test_index_contains_import_window_controls(tmp_path) -> None:
|
||||||
@ -40,14 +37,12 @@ def test_index_contains_import_window_controls(tmp_path) -> None:
|
|||||||
assert 'id="window-restore-button"' in html
|
assert 'id="window-restore-button"' in html
|
||||||
assert 'id="import-window-minimized-bar"' in html
|
assert 'id="import-window-minimized-bar"' in html
|
||||||
assert 'id="import-window-project-label"' in html
|
assert 'id="import-window-project-label"' in html
|
||||||
assert 'id="window-clear-selection-button"' in html
|
|
||||||
assert 'id="window-import-selected-items-button"' in html
|
|
||||||
assert 'id="window-collection-status"' in html
|
|
||||||
assert 'id="window-collection-tree"' in html
|
assert 'id="window-collection-tree"' in html
|
||||||
assert 'id="window-collection-items"' in html
|
assert 'id="window-collection-items"' in html
|
||||||
assert 'class="window-toolbar-actions"' in html
|
assert 'id="window-selected-count"' in html
|
||||||
assert 'class="window-action-cluster"' in html
|
assert 'id="window-clear-selection-button"' in html
|
||||||
assert 'class="window-control-cluster"' in html
|
assert 'id="window-import-selected-items-button"' in html
|
||||||
|
assert 'id="item-preview-popover"' 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
|
||||||
@ -70,164 +65,3 @@ def test_index_window_frame_css(tmp_path) -> None:
|
|||||||
assert "restoreWindow" in html
|
assert "restoreWindow" in html
|
||||||
assert "initWindowFromSession" in html
|
assert "initWindowFromSession" in html
|
||||||
assert "persistWindowSessionState" in html
|
assert "persistWindowSessionState" in html
|
||||||
|
|
||||||
|
|
||||||
def test_index_import_window_has_collection_import_layout(tmp_path) -> None:
|
|
||||||
html = _get_index_html(tmp_path)
|
|
||||||
|
|
||||||
assert 'class="window-pane collections"' in html
|
|
||||||
assert 'class="window-pane items"' in html
|
|
||||||
|
|
||||||
assert 'id="window-collection-status"' in html
|
|
||||||
assert 'id="window-collection-tree"' in html
|
|
||||||
assert 'id="window-collection-items"' in html
|
|
||||||
assert ".window-pane.collections" in html
|
|
||||||
assert ".window-pane.items" in html
|
|
||||||
assert ".tree-list" in html
|
|
||||||
assert ".collection-items-list" in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_index_has_collection_import_api_calls(tmp_path) -> None:
|
|
||||||
html = _get_index_html(tmp_path)
|
|
||||||
|
|
||||||
assert "function loadCollectionTree()" in html
|
|
||||||
assert "function loadCollectionItems(collectionKey)" in html
|
|
||||||
assert "/api/zotero/collections/tree" in html
|
|
||||||
assert "/api/zotero/collections/${collectionKey}/items" in html
|
|
||||||
|
|
||||||
assert "/api/projects/" in html
|
|
||||||
assert "imports/item-keys" in html
|
|
||||||
assert "function importSelectedItems()" in html
|
|
||||||
|
|
||||||
# openImportWindow calls loadCollectionTree
|
|
||||||
assert "openImportWindow()" in html
|
|
||||||
assert "await loadCollectionTree()" in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_index_has_responsive_import_window_geometry_helpers(tmp_path) -> None:
|
|
||||||
html = _get_index_html(tmp_path)
|
|
||||||
|
|
||||||
assert "function computeResponsiveWindowRect()" in html
|
|
||||||
assert "function applyResponsiveWindowRect()" in html
|
|
||||||
assert "function syncWindowToViewport()" in html
|
|
||||||
assert 'window.addEventListener("resize"' in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_index_has_responsive_import_window_layout_rules(tmp_path) -> None:
|
|
||||||
html = _get_index_html(tmp_path)
|
|
||||||
|
|
||||||
assert "@media (max-width: 980px)" in html
|
|
||||||
assert ".window-pane.items" in html
|
|
||||||
assert ".window-body {" in html
|
|
||||||
assert "grid-template-columns: 1fr;" in html
|
|
||||||
assert "border-bottom: 1px solid var(--line);" in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_index_has_project_item_list_and_inline_detail_hooks(tmp_path) -> None:
|
|
||||||
html = _get_index_html(tmp_path)
|
|
||||||
|
|
||||||
assert 'id="project-item-list"' in html
|
|
||||||
assert "expandedProjectItemKey" in html
|
|
||||||
assert "function renderProjectItems(items)" in html
|
|
||||||
assert "function toggleProjectItemDetail(itemKey)" in html
|
|
||||||
assert "function renderProjectItemCitations(citations)" in html
|
|
||||||
assert "function renderProjectItemAttachments(attachments)" in html
|
|
||||||
assert ".project-item-detail" in html
|
|
||||||
assert "<h4>Citations</h4>" in html
|
|
||||||
assert "<h4>Source Files</h4>" in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_index_collection_rows_use_readable_unselected_text(tmp_path) -> None:
|
|
||||||
html = _get_index_html(tmp_path)
|
|
||||||
|
|
||||||
assert ".project-item strong" in html
|
|
||||||
assert ".project-item .meta" in html
|
|
||||||
assert ".collection-row-toggle" in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_index_project_item_active_state_uses_high_contrast_text(tmp_path) -> None:
|
|
||||||
html = _get_index_html(tmp_path)
|
|
||||||
|
|
||||||
assert ".project-item.active {" in html
|
|
||||||
assert "background: var(--accent-strong);" in html
|
|
||||||
assert "color: #f7f2e8;" in html
|
|
||||||
assert ".project-item.active strong," in html
|
|
||||||
assert ".project-item.active .meta {" in html
|
|
||||||
assert "color: inherit;" in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_index_has_project_rename_delete_controls(tmp_path) -> None:
|
|
||||||
html = _get_index_html(tmp_path)
|
|
||||||
|
|
||||||
assert "editingProjectId" in html
|
|
||||||
assert "confirmingDeleteProjectId" in html
|
|
||||||
assert "function startProjectRename(projectId)" in html
|
|
||||||
assert "function cancelProjectRename()" in html
|
|
||||||
assert "function saveProjectRename(projectId)" in html
|
|
||||||
assert "function confirmProjectDelete(projectId)" in html
|
|
||||||
assert "function deleteProject(projectId)" in html
|
|
||||||
assert 'method: "DELETE"' in html
|
|
||||||
assert 'method: "POST"' in html
|
|
||||||
assert 'api(`/api/projects/${projectId}/delete`' in html
|
|
||||||
assert 'error.message === "Method Not Allowed"' in html
|
|
||||||
assert ".project-list-entry" in html
|
|
||||||
assert ".project-list-main" in html
|
|
||||||
assert ".project-list-actions" in html
|
|
||||||
assert ".project-inline-form" in html
|
|
||||||
assert ".project-delete-confirmation" in html
|
|
||||||
assert "card_language" in html
|
|
||||||
assert "中文" in html
|
|
||||||
assert "English" in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_index_has_project_item_generate_actions_and_batch_window(tmp_path) -> None:
|
|
||||||
html = _get_index_html(tmp_path)
|
|
||||||
|
|
||||||
assert 'id="open-batch-generate-button"' in html
|
|
||||||
assert 'id="batch-generate-shell"' in html
|
|
||||||
assert 'id="batch-generate-window"' in html
|
|
||||||
assert 'id="batch-generate-list"' in html
|
|
||||||
assert 'id="select-pending-batch-button"' in html
|
|
||||||
assert 'id="clear-batch-selection-button"' in html
|
|
||||||
assert 'id="start-batch-generate-button"' in html
|
|
||||||
assert 'id="batch-generate-progress"' in html
|
|
||||||
assert "function openBatchGenerateWindow()" in html
|
|
||||||
assert "function closeBatchGenerateWindow()" in html
|
|
||||||
assert "function generateProjectItem(itemKey)" in html
|
|
||||||
assert "function canGenerateProjectItemInBatch(status)" in html
|
|
||||||
assert "function getProjectItemGenerateButtonLabel(status)" in html
|
|
||||||
assert "function selectAllPendingBatchItems()" in html
|
|
||||||
assert "function runBatchGenerate()" in html
|
|
||||||
assert ".project-item-status-pill" in html
|
|
||||||
assert ".project-item-generate-button" in html
|
|
||||||
assert "重新生成" in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_index_marks_already_imported_collection_items_as_checked_and_disabled(tmp_path) -> None:
|
|
||||||
html = _get_index_html(tmp_path)
|
|
||||||
|
|
||||||
assert "function isItemInCurrentProject(itemKey)" in html
|
|
||||||
assert "function getCurrentProjectItemKeySet()" in html
|
|
||||||
assert "已在项目中" in html
|
|
||||||
assert '? "checked disabled" :' in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_index_inline_script_is_valid_javascript(tmp_path) -> None:
|
|
||||||
html = _get_index_html(tmp_path)
|
|
||||||
script_start = html.rfind("<script>")
|
|
||||||
script_end = html.rfind("</script>")
|
|
||||||
|
|
||||||
assert script_start != -1
|
|
||||||
assert script_end != -1
|
|
||||||
|
|
||||||
script_path = tmp_path / "index-inline.js"
|
|
||||||
script_path.write_text(html[script_start + len("<script>") : script_end], encoding="utf-8")
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
["node", "--check", str(script_path)],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
|
|||||||
@ -1,82 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from zotero_kb.config import AppConfig
|
|
||||||
from zotero_kb.workspace import Workspace
|
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_initialization_creates_required_directories(tmp_path: Path) -> None:
|
|
||||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
|
||||||
workspace = Workspace(config)
|
|
||||||
|
|
||||||
workspace.ensure_layout()
|
|
||||||
|
|
||||||
assert (config.workspace_dir / "library" / "collections").is_dir()
|
|
||||||
assert (config.workspace_dir / "library" / "index").is_dir()
|
|
||||||
assert (config.workspace_dir / "library" / "cache" / "source-bundles").is_dir()
|
|
||||||
assert (config.workspace_dir / "projects").is_dir()
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_project_writes_project_files(tmp_path: Path) -> None:
|
|
||||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
|
||||||
workspace = Workspace(config)
|
|
||||||
workspace.ensure_layout()
|
|
||||||
|
|
||||||
project = workspace.create_project(
|
|
||||||
project_id="thesis-ch2",
|
|
||||||
name="Thesis Chapter 2",
|
|
||||||
llm_provider="openai",
|
|
||||||
llm_model="gpt-5-mini",
|
|
||||||
card_language="zh",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert project.project_id == "thesis-ch2"
|
|
||||||
assert (config.workspace_dir / "projects" / "thesis-ch2" / "project.json").is_file()
|
|
||||||
assert (config.workspace_dir / "projects" / "thesis-ch2" / "selected-items.json").is_file()
|
|
||||||
assert (config.workspace_dir / "projects" / "thesis-ch2" / "project-index.json").is_file()
|
|
||||||
assert '"card_language": "zh"' in (
|
|
||||||
config.workspace_dir / "projects" / "thesis-ch2" / "project.json"
|
|
||||||
).read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def test_rename_project_updates_project_name(tmp_path: Path) -> None:
|
|
||||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
|
||||||
workspace = Workspace(config)
|
|
||||||
workspace.ensure_layout()
|
|
||||||
workspace.create_project(
|
|
||||||
project_id="thesis-ch2",
|
|
||||||
name="Old Name",
|
|
||||||
llm_provider="openai",
|
|
||||||
llm_model="gpt-5-mini",
|
|
||||||
card_language="en",
|
|
||||||
)
|
|
||||||
|
|
||||||
project = workspace.rename_project("thesis-ch2", "New Name", "zh")
|
|
||||||
|
|
||||||
assert project.project_id == "thesis-ch2"
|
|
||||||
assert project.name == "New Name"
|
|
||||||
assert '"name": "New Name"' in (
|
|
||||||
config.workspace_dir / "projects" / "thesis-ch2" / "project.json"
|
|
||||||
).read_text(encoding="utf-8")
|
|
||||||
assert '"card_language": "zh"' in (
|
|
||||||
config.workspace_dir / "projects" / "thesis-ch2" / "project.json"
|
|
||||||
).read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_project_removes_project_directory_only(tmp_path: Path) -> None:
|
|
||||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
|
||||||
workspace = Workspace(config)
|
|
||||||
workspace.ensure_layout()
|
|
||||||
workspace.create_project(
|
|
||||||
project_id="thesis-ch2",
|
|
||||||
name="Thesis Chapter 2",
|
|
||||||
llm_provider="openai",
|
|
||||||
llm_model="gpt-5-mini",
|
|
||||||
card_language="en",
|
|
||||||
)
|
|
||||||
preserved_index = config.workspace_dir / "library" / "index" / "items.json"
|
|
||||||
preserved_index.write_text('{"PAPER0001": {"title": "Keep me"}}', encoding="utf-8")
|
|
||||||
|
|
||||||
workspace.delete_project("thesis-ch2")
|
|
||||||
|
|
||||||
assert not (config.workspace_dir / "projects" / "thesis-ch2").exists()
|
|
||||||
assert preserved_index.is_file()
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from zotero_kb.cards import CardBuilder
|
|
||||||
from zotero_kb.config import AppConfig
|
|
||||||
from zotero_kb.projects import ProjectService
|
|
||||||
from zotero_kb.workspace import Workspace
|
|
||||||
from zotero_kb.writing import WritingService
|
|
||||||
from zotero_kb.zotero_reader import ZoteroItemRecord
|
|
||||||
|
|
||||||
|
|
||||||
class FakeLlmClient:
|
|
||||||
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
|
|
||||||
title = str(source_bundle["title"])
|
|
||||||
if "Scoped" in title:
|
|
||||||
summary = "Project-scoped retrieval improves drafting support."
|
|
||||||
claims = ["Project scoping improves citation precision."]
|
|
||||||
hints = ["Use for scoped retrieval arguments."]
|
|
||||||
else:
|
|
||||||
summary = "Broad retrieval baseline for comparison."
|
|
||||||
claims = ["Baseline retrieval may drift."]
|
|
||||||
hints = ["Use as a contrast only."]
|
|
||||||
return {
|
|
||||||
"summary": summary,
|
|
||||||
"core_claims": claims,
|
|
||||||
"methods": ["Method details."],
|
|
||||||
"evidence": ["Evidence details."],
|
|
||||||
"quotable_passages": claims,
|
|
||||||
"writing_hints": hints,
|
|
||||||
"keywords": ["retrieval", "writing"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_item(item_key: str, title: str) -> ZoteroItemRecord:
|
|
||||||
return ZoteroItemRecord(
|
|
||||||
item_key=item_key,
|
|
||||||
title=title,
|
|
||||||
creators=["Alice Smith"],
|
|
||||||
year="2024",
|
|
||||||
item_type="journalArticle",
|
|
||||||
abstract=title,
|
|
||||||
tags=["retrieval"],
|
|
||||||
collection_paths=[["Theory", "Drafting"]],
|
|
||||||
notes=[title],
|
|
||||||
attachments=[],
|
|
||||||
attachment_texts=[title],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_recommend_citations_only_reads_project_items(tmp_path: Path) -> None:
|
|
||||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
|
||||||
workspace = Workspace(config)
|
|
||||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "en")
|
|
||||||
builder = CardBuilder(config.workspace_dir, FakeLlmClient())
|
|
||||||
builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting"))
|
|
||||||
builder.build_or_update(_build_item("PAPER0002", "Broad Retrieval Baseline"))
|
|
||||||
|
|
||||||
project_service = ProjectService(config.workspace_dir)
|
|
||||||
project_service.add_items("thesis-ch2", ["PAPER0001"])
|
|
||||||
|
|
||||||
service = WritingService(config.workspace_dir)
|
|
||||||
result = service.recommend_citations("thesis-ch2", "support scoped retrieval during drafting")
|
|
||||||
|
|
||||||
assert [item["item_key"] for item in result["results"]] == ["PAPER0001"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_generate_plan_returns_structured_sections(tmp_path: Path) -> None:
|
|
||||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
|
||||||
workspace = Workspace(config)
|
|
||||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "en")
|
|
||||||
builder = CardBuilder(config.workspace_dir, FakeLlmClient())
|
|
||||||
builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting"))
|
|
||||||
|
|
||||||
project_service = ProjectService(config.workspace_dir)
|
|
||||||
project_service.add_items("thesis-ch2", ["PAPER0001"])
|
|
||||||
|
|
||||||
service = WritingService(config.workspace_dir)
|
|
||||||
plan = service.generate_plan("thesis-ch2", "argue that project scoping improves drafting")
|
|
||||||
|
|
||||||
assert "sections" in plan
|
|
||||||
assert plan["sections"][0]["citations"][0]["item_key"] == "PAPER0001"
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from tests.fixtures.build_zotero_fixture import build_fixture_zotero_dir
|
|
||||||
from zotero_kb.bridge import read_selected_keys
|
|
||||||
from zotero_kb.zotero_reader import ZoteroReader
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_selected_items_from_fixture(tmp_path: Path) -> None:
|
|
||||||
fixture_dir = tmp_path / "zotero"
|
|
||||||
fixture_dir.mkdir()
|
|
||||||
build_fixture_zotero_dir(fixture_dir)
|
|
||||||
reader = ZoteroReader(fixture_dir)
|
|
||||||
|
|
||||||
items = reader.read_items(["PAPER0001"])
|
|
||||||
|
|
||||||
assert len(items) == 1
|
|
||||||
item = items[0]
|
|
||||||
assert item.item_key == "PAPER0001"
|
|
||||||
assert item.title == "Card Pipelines for Research Writing"
|
|
||||||
assert item.tags == ["llm", "writing"]
|
|
||||||
assert item.collection_paths == [["Theory", "Drafting"]]
|
|
||||||
assert item.notes == ["Merged notes matter."]
|
|
||||||
assert item.attachment_texts[0].startswith("This paper studies")
|
|
||||||
assert item.attachments == [
|
|
||||||
{
|
|
||||||
"path": str(fixture_dir / "storage" / "ATTACH01" / "paper.txt"),
|
|
||||||
"filename": "paper.txt",
|
|
||||||
"content_type": "text/plain",
|
|
||||||
"is_pdf": False,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_selected_keys_from_bridge_snapshot(tmp_path: Path) -> None:
|
|
||||||
bridge_file = tmp_path / "selected-items.json"
|
|
||||||
bridge_file.write_text('{"selected_keys": ["PAPER0001", "PAPER0002"]}', encoding="utf-8")
|
|
||||||
|
|
||||||
assert read_selected_keys(bridge_file) == ["PAPER0001", "PAPER0002"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_items_returns_matching_library_records(tmp_path: Path) -> None:
|
|
||||||
fixture_dir = tmp_path / "zotero"
|
|
||||||
fixture_dir.mkdir()
|
|
||||||
build_fixture_zotero_dir(fixture_dir)
|
|
||||||
reader = ZoteroReader(fixture_dir)
|
|
||||||
|
|
||||||
results = reader.search_items("research", limit=5)
|
|
||||||
|
|
||||||
assert len(results) == 1
|
|
||||||
assert results[0]["item_key"] == "PAPER0001"
|
|
||||||
assert results[0]["title"] == "Card Pipelines for Research Writing"
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_collection_tree_returns_descendant_counts(tmp_path: Path) -> None:
|
|
||||||
fixture_dir = tmp_path / "zotero"
|
|
||||||
fixture_dir.mkdir()
|
|
||||||
build_fixture_zotero_dir(fixture_dir)
|
|
||||||
reader = ZoteroReader(fixture_dir)
|
|
||||||
|
|
||||||
tree = reader.get_collection_tree()
|
|
||||||
|
|
||||||
assert len(tree) == 1
|
|
||||||
root = tree[0]
|
|
||||||
assert root["collection_key"] == "COLL0001"
|
|
||||||
assert root["name"] == "Theory"
|
|
||||||
assert root["direct_item_count"] == 0
|
|
||||||
assert root["descendant_item_count"] == 1
|
|
||||||
assert root["children"][0]["collection_key"] == "COLL0002"
|
|
||||||
assert root["children"][0]["direct_item_count"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_collection_items_includes_descendants(tmp_path: Path) -> None:
|
|
||||||
fixture_dir = tmp_path / "zotero"
|
|
||||||
fixture_dir.mkdir()
|
|
||||||
build_fixture_zotero_dir(fixture_dir)
|
|
||||||
reader = ZoteroReader(fixture_dir)
|
|
||||||
|
|
||||||
items = reader.get_collection_items("COLL0001", include_descendants=True)
|
|
||||||
|
|
||||||
assert [item["item_key"] for item in items] == ["PAPER0001"]
|
|
||||||
assert items[0]["collection_paths"] == [["Theory", "Drafting"]]
|
|
||||||
assert items[0]["title"] == "Card Pipelines for Research Writing"
|
|
||||||
561
uv.lock
generated
561
uv.lock
generated
@ -1,561 +0,0 @@
|
|||||||
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
33
zotero-bridge/src/bootstrap.js
vendored
@ -1,33 +0,0 @@
|
|||||||
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.`);
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"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