Initial commit
Photo-based book cataloger with AI identification. Room → Cabinet → Shelf → Book hierarchy; FastAPI + SQLite backend; vanilla JS SPA; OpenAI-compatible plugin system for boundary detection, text recognition, and archive search.
This commit is contained in:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Runtime data
|
||||||
|
data/
|
||||||
|
|
||||||
|
# User-specific config overrides (contain API keys)
|
||||||
|
config/*.user.yaml
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
settings.yaml
|
||||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"singleQuote": true
|
||||||
|
}
|
||||||
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"cSpell.words": [
|
||||||
|
"gphoto",
|
||||||
|
"LANCZOS"
|
||||||
|
],
|
||||||
|
"claudeCode.allowDangerouslySkipPermissions": true
|
||||||
|
}
|
||||||
10
AGENTS.md
Normal file
10
AGENTS.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
**Read `docs/overview.md` once at the start of each session before doing anything else.**
|
||||||
|
|
||||||
|
## Communication
|
||||||
|
- Brief, technical only — no preambles, no summaries.
|
||||||
|
|
||||||
|
## Implementation rules
|
||||||
|
- No backward-compatibility shims or legacy endpoint aliases.
|
||||||
|
- Run `poetry run presubmit` before finishing any task. Fix all failures before marking work done.
|
||||||
73
README.md
Normal file
73
README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Bookshelf
|
||||||
|
|
||||||
|
Photo-based book cataloger. Organizes books in a Room -> Cabinet -> Shelf -> Book hierarchy. Photographs shelf spines; AI plugins identify books and look up metadata in library archives.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.11+, [Poetry](https://python-poetry.org/)
|
||||||
|
- An OpenAI-compatible API endpoint (OpenRouter recommended)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```
|
||||||
|
poetry install
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `config/credentials.user.yaml` with your API key:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
credentials:
|
||||||
|
openrouter:
|
||||||
|
api_key: "sk-or-your-key-here"
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the server:
|
||||||
|
|
||||||
|
```
|
||||||
|
poetry run serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8000` in a browser.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Config is loaded from `config/*.default.yaml` merged with `config/*.user.yaml` overrides. User files take precedence; dicts merge recursively, lists replace entirely. User files are gitignored.
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `credentials.default.yaml` | API endpoints and keys |
|
||||||
|
| `models.default.yaml` | Model selection and prompts per AI function |
|
||||||
|
| `functions.default.yaml` | Plugin definitions (boundary detection, text recognition, identification, archive search) |
|
||||||
|
| `ui.default.yaml` | UI display settings |
|
||||||
|
|
||||||
|
To use a different model for a function, create `config/models.user.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
models:
|
||||||
|
vl_recognize:
|
||||||
|
credentials: openrouter
|
||||||
|
model: "google/gemini-2.0-flash"
|
||||||
|
```
|
||||||
|
|
||||||
|
To add an alternative provider, add it to `config/credentials.user.yaml` and reference it in `models.user.yaml`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Add a room, then cabinets and shelves using the tree in the sidebar.
|
||||||
|
2. Upload a photo of each cabinet or shelf.
|
||||||
|
3. Drag boundary lines on the photo to segment shelves (or books within a shelf). The AI boundary detector can suggest splits automatically.
|
||||||
|
4. Run the text recognizer on a book to extract spine text, then the book identifier to match it against library archives.
|
||||||
|
5. Review and approve AI suggestions in the detail panel. Use the batch button to process all unidentified books at once.
|
||||||
|
6. On mobile, use the photo queue button on a cabinet or shelf to photograph books one by one with automatic AI processing.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```
|
||||||
|
poetry run presubmit # black check + flake8 + pyright + pytest + JS tests
|
||||||
|
poetry run fmt # auto-format Python with black
|
||||||
|
npm install # install JS dev tools (ESLint, Prettier) — requires network
|
||||||
|
npm run lint # ESLint
|
||||||
|
npm run fmt # Prettier
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests are in `tests/` (Python) and `tests/js/` (JavaScript).
|
||||||
9
config/credentials.default.yaml
Normal file
9
config/credentials.default.yaml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# API credentials — connection endpoints only (no model, no prompt).
|
||||||
|
# Override api_key in credentials.user.yaml.
|
||||||
|
credentials:
|
||||||
|
openrouter:
|
||||||
|
base_url: "https://openrouter.ai/api/v1"
|
||||||
|
api_key: "sk-or-..."
|
||||||
|
# openai:
|
||||||
|
# base_url: "https://api.openai.com/v1"
|
||||||
|
# api_key: "sk-..."
|
||||||
103
config/functions.default.yaml
Normal file
103
config/functions.default.yaml
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Function configurations — dict per category (not lists).
|
||||||
|
# AI functions reference a model from models.*.yaml.
|
||||||
|
# Archive functions specify a type and optional config dict.
|
||||||
|
# Keys within each category serve as plugin_id; must be unique across all categories.
|
||||||
|
# Override individual functions in functions.user.yaml.
|
||||||
|
functions:
|
||||||
|
# ── Boundary detection: image → {boundaries: [...], confidence: 0.x}
|
||||||
|
# ai_shelf_boundaries / ai_book_boundaries stored as {functionId: [fractions]} per entity.
|
||||||
|
boundary_detectors:
|
||||||
|
shelves: # key = plugin_id = target; runs on cabinet images
|
||||||
|
model: vl_detect_shelves
|
||||||
|
max_image_px: 1600
|
||||||
|
auto_queue: false
|
||||||
|
rate_limit_seconds: 0
|
||||||
|
timeout: 30
|
||||||
|
|
||||||
|
books: # key = plugin_id = target; runs on shelf images
|
||||||
|
model: vl_detect_books
|
||||||
|
max_image_px: 1600
|
||||||
|
auto_queue: false
|
||||||
|
rate_limit_seconds: 0
|
||||||
|
timeout: 30
|
||||||
|
|
||||||
|
# ── Text recognition: spine image → {raw_text, title, author, year, publisher, other}
|
||||||
|
text_recognizers:
|
||||||
|
recognize:
|
||||||
|
model: vl_recognize
|
||||||
|
max_image_px: 1600
|
||||||
|
auto_queue: true
|
||||||
|
rate_limit_seconds: 0
|
||||||
|
timeout: 30
|
||||||
|
|
||||||
|
# ── Book identification: raw_text → {title, author, year, isbn, publisher, confidence}
|
||||||
|
book_identifiers:
|
||||||
|
identify:
|
||||||
|
model: ai_identify
|
||||||
|
confidence_threshold: 0.8
|
||||||
|
auto_queue: false
|
||||||
|
rate_limit_seconds: 0
|
||||||
|
timeout: 30
|
||||||
|
|
||||||
|
# ── Archive searchers: query → [{source, title, author, year, isbn, publisher}, ...]
|
||||||
|
archive_searchers:
|
||||||
|
openlibrary:
|
||||||
|
name: "OpenLibrary"
|
||||||
|
type: openlibrary
|
||||||
|
auto_queue: true
|
||||||
|
rate_limit_seconds: 5
|
||||||
|
timeout: 8
|
||||||
|
|
||||||
|
rsl:
|
||||||
|
name: "РГБ"
|
||||||
|
type: rsl
|
||||||
|
auto_queue: true
|
||||||
|
rate_limit_seconds: 5
|
||||||
|
timeout: 8
|
||||||
|
|
||||||
|
rusneb:
|
||||||
|
name: "НЭБ"
|
||||||
|
type: html_scraper
|
||||||
|
auto_queue: true
|
||||||
|
rate_limit_seconds: 5
|
||||||
|
timeout: 8
|
||||||
|
config:
|
||||||
|
url: "https://rusneb.ru/search/"
|
||||||
|
search_param: q
|
||||||
|
title_class: "title"
|
||||||
|
author_class: "author"
|
||||||
|
|
||||||
|
alib_web:
|
||||||
|
name: "Alib (web)"
|
||||||
|
type: html_scraper
|
||||||
|
auto_queue: false
|
||||||
|
rate_limit_seconds: 5
|
||||||
|
timeout: 8
|
||||||
|
config:
|
||||||
|
url: "https://www.alib.ru/find3.php4"
|
||||||
|
search_param: tfind
|
||||||
|
extra_params: {f: "5", s: "0"}
|
||||||
|
link_href_pattern: "t[a-z]+\\.phtml"
|
||||||
|
author_class: "aut"
|
||||||
|
|
||||||
|
nlr:
|
||||||
|
name: "НЛР"
|
||||||
|
type: sru_catalog
|
||||||
|
auto_queue: false
|
||||||
|
rate_limit_seconds: 5
|
||||||
|
timeout: 8
|
||||||
|
config:
|
||||||
|
url: "http://www.nlr.ru/search/query"
|
||||||
|
query_prefix: "title="
|
||||||
|
|
||||||
|
shpl:
|
||||||
|
name: "ШПИЛ"
|
||||||
|
type: html_scraper
|
||||||
|
auto_queue: false
|
||||||
|
rate_limit_seconds: 5
|
||||||
|
timeout: 8
|
||||||
|
config:
|
||||||
|
url: "https://www.shpl.ru/cgi-bin/irbis64/cgiirbis_64.exe"
|
||||||
|
search_param: S21ALL
|
||||||
|
extra_params: {C21COM: S, I21DBN: BIBL, P21DBN: BIBL, S21FMT: briefWebRus, Z21ID: ""}
|
||||||
|
brief_class: "brief"
|
||||||
50
config/models.default.yaml
Normal file
50
config/models.default.yaml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# AI model configurations — each model references a credential and provides
|
||||||
|
# the model string, optional openrouter routing (extra_body), and the prompt.
|
||||||
|
# ${OUTPUT_FORMAT} is injected by the plugin from its hardcoded schema constant.
|
||||||
|
# Override individual models in models.user.yaml.
|
||||||
|
models:
|
||||||
|
vl_detect_shelves:
|
||||||
|
credentials: openrouter
|
||||||
|
model: "google/gemini-flash-1.5"
|
||||||
|
prompt: |
|
||||||
|
# ${OUTPUT_FORMAT} — JSON schema injected by BoundaryDetectorShelvesPlugin
|
||||||
|
Look at this photo of a bookcase/shelf unit.
|
||||||
|
Count the number of horizontal shelves visible.
|
||||||
|
For each interior boundary between adjacent shelves, give its vertical position
|
||||||
|
as a fraction 0-1 (0=top of image, 1=bottom). Do NOT include 0 or 1 themselves.
|
||||||
|
Return ONLY valid JSON, no explanation:
|
||||||
|
${OUTPUT_FORMAT}
|
||||||
|
|
||||||
|
vl_detect_books:
|
||||||
|
credentials: openrouter
|
||||||
|
model: "google/gemini-flash-1.5"
|
||||||
|
prompt: |
|
||||||
|
# ${OUTPUT_FORMAT} — JSON schema injected by BoundaryDetectorBooksPlugin
|
||||||
|
Look at this shelf photo. Identify every book spine visible left-to-right.
|
||||||
|
For each interior boundary between adjacent books, give its horizontal position
|
||||||
|
as a fraction 0-1 (0=left edge of image, 1=right edge). Do NOT include 0 or 1.
|
||||||
|
Return ONLY valid JSON, no explanation:
|
||||||
|
${OUTPUT_FORMAT}
|
||||||
|
|
||||||
|
vl_recognize:
|
||||||
|
credentials: openrouter
|
||||||
|
model: "google/gemini-flash-1.5"
|
||||||
|
prompt: |
|
||||||
|
# ${OUTPUT_FORMAT} — JSON schema injected by TextRecognizerPlugin
|
||||||
|
Look at this book spine image. Read all visible text exactly as it appears,
|
||||||
|
preserving line breaks between distinct text blocks.
|
||||||
|
Then use visual cues (font size, position, layout) to identify which part is the title,
|
||||||
|
author, publisher, year, and any other notable text.
|
||||||
|
Return ONLY valid JSON, no explanation:
|
||||||
|
${OUTPUT_FORMAT}
|
||||||
|
|
||||||
|
ai_identify:
|
||||||
|
credentials: openrouter
|
||||||
|
model: "google/gemini-flash-1.5"
|
||||||
|
prompt: |
|
||||||
|
# ${RAW_TEXT} — text read from the book spine (multi-line)
|
||||||
|
# ${OUTPUT_FORMAT} — JSON schema injected by BookIdentifierPlugin
|
||||||
|
The following text was read from a book spine:
|
||||||
|
${RAW_TEXT}
|
||||||
|
Identify this book. Search for it if needed. Return ONLY valid JSON, no explanation:
|
||||||
|
${OUTPUT_FORMAT}
|
||||||
3
config/ui.default.yaml
Normal file
3
config/ui.default.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# UI settings. Override in ui.user.yaml.
|
||||||
|
ui:
|
||||||
|
boundary_grab_px: 14 # pixel grab threshold for dragging boundary lines
|
||||||
131
docs/overview.md
Normal file
131
docs/overview.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# Bookshelf — Technical Overview
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Photo-based book cataloger. Hierarchy: Room → Cabinet → Shelf → Book.
|
||||||
|
AI plugins identify spine text; archive plugins supply bibliographic metadata.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- **Server**: FastAPI + SQLite (no ORM), Python 3.11+, Poetry (`poetry run serve`)
|
||||||
|
- **Frontend**: Single-file vanilla JS SPA (`static/index.html`)
|
||||||
|
- **AI**: OpenAI-compatible API (OpenRouter, OpenAI, etc.) via `openai` library
|
||||||
|
- **Images**: Stored uncompressed in `data/images/`; Pillow used server-side for crops and AI prep
|
||||||
|
|
||||||
|
## Directory Layout
|
||||||
|
```
|
||||||
|
app.py # FastAPI routes only
|
||||||
|
storage.py # DB schema/helpers, settings loading, photo file I/O
|
||||||
|
logic.py # Image processing, boundary helpers, plugin runners, batch pipeline
|
||||||
|
scripts.py # Poetry console entry points: fmt, presubmit
|
||||||
|
plugins/
|
||||||
|
__init__.py # Registry: load_plugins(), get_manifest(), get_plugin()
|
||||||
|
rate_limiter.py # Thread-safe per-domain rate limiter (one global instance)
|
||||||
|
ai_compat/
|
||||||
|
__init__.py # Exports the four AI plugin classes
|
||||||
|
_client.py # Internal: AIClient (openai wrapper, JSON extractor)
|
||||||
|
boundary_detector_shelves.py # BoundaryDetectorShelvesPlugin
|
||||||
|
boundary_detector_books.py # BoundaryDetectorBooksPlugin
|
||||||
|
text_recognizer.py # TextRecognizerPlugin
|
||||||
|
book_identifier.py # BookIdentifierPlugin
|
||||||
|
archives/
|
||||||
|
openlibrary.py # OpenLibrary JSON API
|
||||||
|
rsl.py # RSL AJAX JSON API
|
||||||
|
html_scraper.py # Config-driven HTML scraper (rusneb, alib, shpl)
|
||||||
|
sru_catalog.py # SRU XML catalog (nlr)
|
||||||
|
telegram_bot.py # STUB (pending Telegram credentials)
|
||||||
|
static/index.html # Full SPA (no build step)
|
||||||
|
config/
|
||||||
|
providers.default.yaml # Provider credentials (placeholder api_key)
|
||||||
|
prompts.default.yaml # Default prompt templates
|
||||||
|
plugins.default.yaml # Default plugin configurations
|
||||||
|
ui.default.yaml # Default UI settings
|
||||||
|
providers.user.yaml # ← create this with your real api_key (gitignored)
|
||||||
|
*.user.yaml # Optional overrides for other categories (gitignored)
|
||||||
|
data/ # Runtime: books.db + images/
|
||||||
|
docs/overview.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration System
|
||||||
|
Config is loaded from `config/*.default.yaml` merged with `config/*.user.yaml` overrides.
|
||||||
|
Deep merge: dicts are merged recursively; lists in user files replace default lists entirely.
|
||||||
|
|
||||||
|
Categories: `providers`, `prompts`, `plugins`, `ui` — each loaded from its own pair of files.
|
||||||
|
|
||||||
|
Minimal setup — create `config/providers.user.yaml`:
|
||||||
|
```yaml
|
||||||
|
providers:
|
||||||
|
openrouter:
|
||||||
|
api_key: "sk-or-your-actual-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plugin System
|
||||||
|
|
||||||
|
### Categories
|
||||||
|
| Category | Input | Output | DB field |
|
||||||
|
|----------|-------|--------|----------|
|
||||||
|
| `boundary_detector` (`target=shelves`) | cabinet image | `{boundaries:[…], confidence:N}` | `cabinets.ai_shelf_boundaries` |
|
||||||
|
| `boundary_detector` (`target=books`) | shelf image | `{boundaries:[…]}` | `shelves.ai_book_boundaries` |
|
||||||
|
| `text_recognizer` | spine image | `{raw_text, title, author, …}` | `books.raw_text` + `candidates` |
|
||||||
|
| `book_identifier` | raw_text | `{title, author, …, confidence}` | `books.ai_*` + `candidates` |
|
||||||
|
| `archive_searcher` | query string | `[{source, title, author, …}, …]` | `books.candidates` |
|
||||||
|
|
||||||
|
### Universal plugin endpoint
|
||||||
|
```
|
||||||
|
POST /api/{entity_type}/{entity_id}/plugin/{plugin_id}
|
||||||
|
```
|
||||||
|
Routes to the correct runner function in `logic.py` based on plugin category.
|
||||||
|
|
||||||
|
### AI Plugin Configuration
|
||||||
|
- **Providers** (`config/providers.*.yaml`): connection credentials only — `base_url`, `api_key`.
|
||||||
|
- **Per-plugin** (`config/plugins.*.yaml`): `provider`, `model`, optionally `max_image_px` (default 1600), `confidence_threshold` (default 0.8).
|
||||||
|
- `OUTPUT_FORMAT` is a **hardcoded class constant** in each plugin class — not user-configurable.
|
||||||
|
It is substituted into the prompt template as `${OUTPUT_FORMAT}` by `AIClient.call()`.
|
||||||
|
|
||||||
|
### Archive Plugin Interface
|
||||||
|
All archive plugins implement `search(query: str) -> list[CandidateRecord]`.
|
||||||
|
`CandidateRecord`: TypedDict with `{source, title, author, year, isbn, publisher}`.
|
||||||
|
Uses shared `RATE_LIMITER` singleton for per-domain throttling.
|
||||||
|
|
||||||
|
### Auto-queue
|
||||||
|
- After `text_recognizer` completes → fires all `archive_searchers` with `auto_queue: true` in background thread pool.
|
||||||
|
- `POST /api/batch` → runs `text_recognizers` then `archive_searchers` for all unidentified books.
|
||||||
|
|
||||||
|
## Database Schema (key fields)
|
||||||
|
| Table | Notable columns |
|
||||||
|
|-------|-----------------|
|
||||||
|
| `cabinets` | `shelf_boundaries` (JSON `[…]`), `ai_shelf_boundaries` (JSON `{pluginId:[…]}`) |
|
||||||
|
| `shelves` | `book_boundaries`, `ai_book_boundaries` (same format) |
|
||||||
|
| `books` | `raw_text`, `ai_title/author/year/isbn/publisher`, `candidates` (JSON `[{source,…}]`), `identification_status` |
|
||||||
|
|
||||||
|
`identification_status`: `unidentified` → `ai_identified` → `user_approved`.
|
||||||
|
|
||||||
|
## Boundary System
|
||||||
|
N interior boundaries → N+1 segments. `full = [0] + boundaries + [1]`. Segment K spans `full[K]..full[K+1]`.
|
||||||
|
- User boundaries: `shelf_boundaries` / `book_boundaries` (editable via canvas drag)
|
||||||
|
- AI suggestions: `ai_shelf_boundaries` / `ai_book_boundaries` (JSON object `{pluginId: [fractions]}`)
|
||||||
|
- Shelf K image = cabinet photo cropped to `(0, y_start, 1, y_end)` unless override photo exists
|
||||||
|
- Book K spine = shelf image cropped to `(x_start, *, x_end, *)` with composed crop if cabinet-based
|
||||||
|
|
||||||
|
## Tooling
|
||||||
|
```
|
||||||
|
poetry run serve # start uvicorn on :8000
|
||||||
|
poetry run fmt # black (in-place)
|
||||||
|
poetry run presubmit # black --check + flake8 + pyright + pytest ← run before finishing any task
|
||||||
|
```
|
||||||
|
Line length: 120. Type checking: pyright strict mode. Pytest fixtures with `yield` use `Iterator[T]` return type.
|
||||||
|
Tests in `tests/`; use `monkeypatch` on `storage.DB_PATH` / `storage.DATA_DIR` for temp-DB fixtures.
|
||||||
|
|
||||||
|
## Key API Endpoints
|
||||||
|
```
|
||||||
|
GET /api/config # UI config + plugin manifest
|
||||||
|
GET /api/tree # full nested tree
|
||||||
|
POST /api/{entity_type}/{entity_id}/plugin/{plugin_id} # universal plugin runner
|
||||||
|
PATCH /api/cabinets/{id}/boundaries # update shelf boundary list
|
||||||
|
PATCH /api/shelves/{id}/boundaries # update book boundary list
|
||||||
|
GET /api/shelves/{id}/image # shelf image (override or cabinet crop)
|
||||||
|
GET /api/books/{id}/spine # book spine crop
|
||||||
|
POST /api/books/{id}/process # run full auto-queue pipeline (single book)
|
||||||
|
POST /api/batch # start batch processing
|
||||||
|
GET /api/batch/status
|
||||||
|
POST /api/books/{id}/dismiss-field # dismiss a candidate suggestion
|
||||||
|
PATCH /api/{kind}/reorder # SortableJS drag reorder
|
||||||
|
```
|
||||||
127
eslint.config.js
Normal file
127
eslint.config.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/*
|
||||||
|
* eslint.config.js (ESLint 9 flat config)
|
||||||
|
*
|
||||||
|
* Lints static/js/**\/*.js as plain browser scripts (sourceType:'script').
|
||||||
|
* All cross-file globals are declared here so no-undef works across the
|
||||||
|
* multi-file global-scope architecture (no ES modules, no bundler).
|
||||||
|
*
|
||||||
|
* Load order and ownership of each global is documented in index.html.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
|
||||||
|
// ── Globals that cross file boundaries ──────────────────────────────────────
|
||||||
|
// Declared 'writable' if the variable itself is reassigned across files;
|
||||||
|
// 'readonly' if only the function/value is consumed by other files.
|
||||||
|
const appGlobals = {
|
||||||
|
// state.js — mutable state shared across all modules
|
||||||
|
S: 'writable',
|
||||||
|
_plugins: 'writable',
|
||||||
|
_batchState: 'writable',
|
||||||
|
_batchPollTimer: 'writable',
|
||||||
|
_bnd: 'writable',
|
||||||
|
_photoQueue: 'writable',
|
||||||
|
|
||||||
|
// helpers.js
|
||||||
|
esc: 'readonly',
|
||||||
|
toast: 'readonly',
|
||||||
|
isDesktop: 'readonly',
|
||||||
|
|
||||||
|
// api.js
|
||||||
|
req: 'readonly',
|
||||||
|
|
||||||
|
// canvas-boundary.js
|
||||||
|
parseBounds: 'readonly',
|
||||||
|
parseBndPluginResults: 'readonly',
|
||||||
|
setupDetailCanvas: 'readonly',
|
||||||
|
drawBnd: 'readonly',
|
||||||
|
|
||||||
|
// tree-render.js
|
||||||
|
walkTree: 'readonly',
|
||||||
|
removeNode: 'readonly',
|
||||||
|
findNode: 'readonly',
|
||||||
|
pluginsByCategory: 'readonly',
|
||||||
|
pluginsByTarget: 'readonly',
|
||||||
|
isLoading: 'readonly',
|
||||||
|
vPluginBtn: 'readonly',
|
||||||
|
vBatchBtn: 'readonly',
|
||||||
|
candidateSugRows: 'readonly',
|
||||||
|
_STATUS_BADGE: 'readonly',
|
||||||
|
getBookStats: 'readonly',
|
||||||
|
vAiProgressBar: 'readonly',
|
||||||
|
vApp: 'readonly',
|
||||||
|
mainTitle: 'readonly',
|
||||||
|
mainHeaderBtns: 'readonly',
|
||||||
|
|
||||||
|
// detail-render.js
|
||||||
|
vDetailBody: 'readonly',
|
||||||
|
|
||||||
|
// canvas-crop.js
|
||||||
|
startCropMode: 'readonly',
|
||||||
|
|
||||||
|
// editing.js
|
||||||
|
attachEditables: 'readonly',
|
||||||
|
initSortables: 'readonly',
|
||||||
|
|
||||||
|
// photo.js
|
||||||
|
collectQueueBooks: 'readonly',
|
||||||
|
renderPhotoQueue: 'readonly',
|
||||||
|
triggerPhoto: 'readonly',
|
||||||
|
|
||||||
|
// init.js
|
||||||
|
render: 'readonly',
|
||||||
|
renderDetail: 'readonly',
|
||||||
|
startBatchPolling: 'readonly',
|
||||||
|
loadTree: 'readonly',
|
||||||
|
|
||||||
|
// CDN (SortableJS loaded via <script> in index.html)
|
||||||
|
Sortable: 'readonly',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
files: ['static/js/**/*.js'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'script', // browser scripts, not ES modules
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...appGlobals,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...js.configs.recommended.rules,
|
||||||
|
|
||||||
|
// Catch typos and missing globals
|
||||||
|
'no-undef': 'error',
|
||||||
|
|
||||||
|
// Unused variables: allow leading-underscore convention for intentional ignores
|
||||||
|
'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||||
|
|
||||||
|
// Require strict equality
|
||||||
|
eqeqeq: ['error', 'always', { null: 'ignore' }],
|
||||||
|
|
||||||
|
// Disallow var; prefer const/let
|
||||||
|
'no-var': 'error',
|
||||||
|
'prefer-const': ['error', { destructuring: 'all' }],
|
||||||
|
|
||||||
|
// Warn on console usage (intentional debug left-ins)
|
||||||
|
'no-console': 'warn',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Test files run in Node.js, not the browser
|
||||||
|
files: ['tests/js/**/*.js'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
globals: globals.node,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...js.configs.recommended.rules,
|
||||||
|
'no-undef': 'error',
|
||||||
|
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
16
package.json
Normal file
16
package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"type": "module",
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9",
|
||||||
|
"eslint": "^9",
|
||||||
|
"globals": "^16",
|
||||||
|
"prettier": "^3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"lint": "eslint static/js",
|
||||||
|
"lint:fix": "eslint --fix static/js",
|
||||||
|
"fmt": "prettier --write 'static/js/**/*.js'",
|
||||||
|
"fmt:check": "prettier --check 'static/js/**/*.js'",
|
||||||
|
"test": "node --test 'tests/js/**/*.test.js'"
|
||||||
|
}
|
||||||
|
}
|
||||||
1574
poetry.lock
generated
Normal file
1574
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
62
pyproject.toml
Normal file
62
pyproject.toml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
[tool.poetry]
|
||||||
|
name = "bookshelf"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Photo-based book cataloger with AI identification"
|
||||||
|
authors = []
|
||||||
|
packages = [
|
||||||
|
{include = "app.py", from = "src"},
|
||||||
|
{include = "api.py", from = "src"},
|
||||||
|
{include = "config.py", from = "src"},
|
||||||
|
{include = "db.py", from = "src"},
|
||||||
|
{include = "errors.py", from = "src"},
|
||||||
|
{include = "files.py", from = "src"},
|
||||||
|
{include = "models.py", from = "src"},
|
||||||
|
{include = "logic", from = "src"},
|
||||||
|
{include = "plugins", from = "src"},
|
||||||
|
|
||||||
|
{include = "presubmit.py", from = "scripts"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.11"
|
||||||
|
fastapi = ">=0.111.0"
|
||||||
|
uvicorn = { version = ">=0.29.0", extras = ["standard"] }
|
||||||
|
python-multipart = ">=0.0.9"
|
||||||
|
openai = ">=1.0"
|
||||||
|
pyyaml = ">=6.0"
|
||||||
|
Pillow = ">=10.0"
|
||||||
|
aiofiles = ">=23.2.1"
|
||||||
|
httpx = ">=0.27"
|
||||||
|
mashumaro = "^3.20"
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
black = ">=24.0.0"
|
||||||
|
flake8 = ">=7.0.0"
|
||||||
|
flake8-pyproject = ">=1.2.0"
|
||||||
|
pyright = ">=1.1"
|
||||||
|
pytest = ">=8.0"
|
||||||
|
numpy = "^2.4.2"
|
||||||
|
|
||||||
|
[tool.poetry.scripts]
|
||||||
|
serve = "app:main"
|
||||||
|
fmt = "presubmit:fmt"
|
||||||
|
presubmit = "presubmit:presubmit"
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 120
|
||||||
|
|
||||||
|
[tool.flake8]
|
||||||
|
max-line-length = 120
|
||||||
|
extend-ignore = ["E203"]
|
||||||
|
|
||||||
|
[tool.pyright]
|
||||||
|
pythonVersion = "3.14"
|
||||||
|
typeCheckingMode = "strict"
|
||||||
|
include = ["src", "tests", "scripts"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
55
scripts/presubmit.py
Normal file
55
scripts/presubmit.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Presubmit and utility scripts registered as poetry console entry points."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _run(*cmd: str) -> int:
|
||||||
|
return subprocess.run(list(cmd)).returncode
|
||||||
|
|
||||||
|
|
||||||
|
def fmt():
|
||||||
|
"""Run black formatter (modify files in place)."""
|
||||||
|
sys.exit(_run("black", "."))
|
||||||
|
|
||||||
|
|
||||||
|
def presubmit():
|
||||||
|
"""Run all checks: black format check, flake8, pyright, pytest, JS lint/fmt/test.
|
||||||
|
|
||||||
|
JS lint and format checks require `npm install` to be run once first;
|
||||||
|
they are skipped (with a warning) when node_modules is absent.
|
||||||
|
"""
|
||||||
|
steps = [
|
||||||
|
["black", "--check", "."],
|
||||||
|
["flake8", "."],
|
||||||
|
["pyright"],
|
||||||
|
["pytest", "tests/"],
|
||||||
|
# JS: tests run via Node built-in runner (no npm packages needed)
|
||||||
|
["node", "--test", "tests/js/pure-functions.test.js"],
|
||||||
|
]
|
||||||
|
# JS lint/fmt require npm packages — skip gracefully if not installed
|
||||||
|
npm_steps: list[list[str]] = [
|
||||||
|
["npm", "run", "fmt:check"],
|
||||||
|
["npm", "run", "lint"],
|
||||||
|
]
|
||||||
|
|
||||||
|
failed: list[str] = []
|
||||||
|
for step in steps:
|
||||||
|
if subprocess.run(step).returncode != 0:
|
||||||
|
failed.append(" ".join(step))
|
||||||
|
|
||||||
|
if Path("node_modules").exists():
|
||||||
|
for step in npm_steps:
|
||||||
|
if subprocess.run(step).returncode != 0:
|
||||||
|
failed.append(" ".join(step))
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"\nSkipping JS lint/fmt: run `npm install` to enable these checks.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
print(f"\nFailed: {', '.join(failed)}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print("\nAll presubmit checks passed.")
|
||||||
407
src/api.py
Normal file
407
src/api.py
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
"""
|
||||||
|
API routes for the bookshelf cataloger.
|
||||||
|
|
||||||
|
Each handler: parse payload → validate existence → call logic → return response.
|
||||||
|
No SQL here; no business logic here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
|
||||||
|
from mashumaro.codecs import BasicDecoder
|
||||||
|
|
||||||
|
import db
|
||||||
|
import logic
|
||||||
|
import plugins as plugin_registry
|
||||||
|
from config import get_config
|
||||||
|
from files import del_photo, save_photo
|
||||||
|
from logic.boundaries import book_spine_source, shelf_source
|
||||||
|
from logic.images import crop_save, serve_crop
|
||||||
|
from models import (
|
||||||
|
BoundariesPayload,
|
||||||
|
CropPayload,
|
||||||
|
DismissFieldPayload,
|
||||||
|
ReorderPayload,
|
||||||
|
UpdateBookPayload,
|
||||||
|
UpdateNamePayload,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# ── Payload decoders ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_name_dec: BasicDecoder[UpdateNamePayload] = BasicDecoder(UpdateNamePayload)
|
||||||
|
_book_dec: BasicDecoder[UpdateBookPayload] = BasicDecoder(UpdateBookPayload)
|
||||||
|
_boundaries_dec: BasicDecoder[BoundariesPayload] = BasicDecoder(BoundariesPayload)
|
||||||
|
_crop_dec: BasicDecoder[CropPayload] = BasicDecoder(CropPayload)
|
||||||
|
_dismiss_dec: BasicDecoder[DismissFieldPayload] = BasicDecoder(DismissFieldPayload)
|
||||||
|
_reorder_dec: BasicDecoder[ReorderPayload] = BasicDecoder(ReorderPayload)
|
||||||
|
|
||||||
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
|
||||||
|
async def _parse(decoder: BasicDecoder[_T], request: Request) -> _T:
|
||||||
|
try:
|
||||||
|
return decoder.decode(await request.json())
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(422, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Config ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/config")
|
||||||
|
def api_config() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"boundary_grab_px": get_config().ui.boundary_grab_px,
|
||||||
|
"plugins": plugin_registry.get_manifest(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tree ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tree")
|
||||||
|
def get_tree() -> list[dict[str, Any]]:
|
||||||
|
with db.connection() as c:
|
||||||
|
return db.get_tree(c)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Rooms ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/rooms")
|
||||||
|
async def create_room() -> dict[str, Any]:
|
||||||
|
with db.transaction() as c:
|
||||||
|
room = db.create_room(c)
|
||||||
|
return {**dataclasses.asdict(room), "cabinets": []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/rooms/{room_id}")
|
||||||
|
async def update_room(room_id: str, request: Request) -> dict[str, Any]:
|
||||||
|
payload = await _parse(_name_dec, request)
|
||||||
|
with db.connection() as c:
|
||||||
|
if not db.get_room(c, room_id):
|
||||||
|
raise HTTPException(404, "Room not found")
|
||||||
|
with db.transaction() as c:
|
||||||
|
db.rename_room(c, room_id, payload.name.strip())
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/rooms/{room_id}")
|
||||||
|
async def delete_room(room_id: str) -> dict[str, Any]:
|
||||||
|
with db.connection() as c:
|
||||||
|
if not db.get_room(c, room_id):
|
||||||
|
raise HTTPException(404, "Room not found")
|
||||||
|
photos = db.collect_room_photos(c, room_id)
|
||||||
|
with db.transaction() as c:
|
||||||
|
db.delete_room(c, room_id)
|
||||||
|
for fn in photos:
|
||||||
|
del_photo(fn)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cabinets ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/rooms/{room_id}/cabinets")
|
||||||
|
async def create_cabinet(room_id: str) -> dict[str, Any]:
|
||||||
|
with db.connection() as c:
|
||||||
|
if not db.get_room(c, room_id):
|
||||||
|
raise HTTPException(404, "Room not found")
|
||||||
|
with db.transaction() as c:
|
||||||
|
cabinet = db.create_cabinet(c, room_id)
|
||||||
|
return {**dataclasses.asdict(cabinet), "shelves": []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/cabinets/{cabinet_id}")
|
||||||
|
async def update_cabinet(cabinet_id: str, request: Request) -> dict[str, Any]:
|
||||||
|
payload = await _parse(_name_dec, request)
|
||||||
|
with db.connection() as c:
|
||||||
|
if not db.get_cabinet(c, cabinet_id):
|
||||||
|
raise HTTPException(404, "Cabinet not found")
|
||||||
|
with db.transaction() as c:
|
||||||
|
db.rename_cabinet(c, cabinet_id, payload.name.strip())
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/cabinets/{cabinet_id}")
|
||||||
|
async def delete_cabinet(cabinet_id: str) -> dict[str, Any]:
|
||||||
|
with db.connection() as c:
|
||||||
|
if not db.get_cabinet(c, cabinet_id):
|
||||||
|
raise HTTPException(404, "Cabinet not found")
|
||||||
|
photos = db.collect_cabinet_photos(c, cabinet_id)
|
||||||
|
with db.transaction() as c:
|
||||||
|
db.delete_cabinet(c, cabinet_id)
|
||||||
|
for fn in photos:
|
||||||
|
del_photo(fn)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/cabinets/{cabinet_id}/photo")
|
||||||
|
async def cabinet_photo(cabinet_id: str, image: UploadFile = File(...)) -> dict[str, Any]:
|
||||||
|
with db.connection() as c:
|
||||||
|
if not db.get_cabinet(c, cabinet_id):
|
||||||
|
raise HTTPException(404, "Cabinet not found")
|
||||||
|
old = db.get_cabinet_photo(c, cabinet_id)
|
||||||
|
del_photo(old)
|
||||||
|
fn = await save_photo(image)
|
||||||
|
with db.transaction() as c:
|
||||||
|
db.set_cabinet_photo(c, cabinet_id, fn)
|
||||||
|
return {"photo_filename": fn}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/api/cabinets/{cabinet_id}/boundaries")
|
||||||
|
async def update_cabinet_boundaries(cabinet_id: str, request: Request) -> dict[str, Any]:
|
||||||
|
payload = await _parse(_boundaries_dec, request)
|
||||||
|
with db.connection() as c:
|
||||||
|
if not db.get_cabinet(c, cabinet_id):
|
||||||
|
raise HTTPException(404, "Cabinet not found")
|
||||||
|
with db.transaction() as c:
|
||||||
|
db.set_cabinet_boundaries(c, cabinet_id, json.dumps(payload.boundaries))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/cabinets/{cabinet_id}/crop")
|
||||||
|
async def crop_cabinet_photo(cabinet_id: str, request: Request) -> dict[str, Any]:
|
||||||
|
payload = await _parse(_crop_dec, request)
|
||||||
|
with db.connection() as c:
|
||||||
|
fn = db.get_cabinet_photo(c, cabinet_id)
|
||||||
|
if not fn:
|
||||||
|
raise HTTPException(400, "No photo to crop")
|
||||||
|
from files import IMAGES_DIR
|
||||||
|
|
||||||
|
crop_save(IMAGES_DIR / fn, payload.x, payload.y, payload.w, payload.h)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Shelves ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/cabinets/{cabinet_id}/shelves")
|
||||||
|
async def create_shelf(cabinet_id: str) -> dict[str, Any]:
|
||||||
|
with db.connection() as c:
|
||||||
|
if not db.get_cabinet(c, cabinet_id):
|
||||||
|
raise HTTPException(404, "Cabinet not found")
|
||||||
|
with db.transaction() as c:
|
||||||
|
shelf = db.create_shelf(c, cabinet_id)
|
||||||
|
return {**dataclasses.asdict(shelf), "books": []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/shelves/{shelf_id}")
|
||||||
|
async def update_shelf(shelf_id: str, request: Request) -> dict[str, Any]:
|
||||||
|
payload = await _parse(_name_dec, request)
|
||||||
|
with db.connection() as c:
|
||||||
|
if not db.get_shelf(c, shelf_id):
|
||||||
|
raise HTTPException(404, "Shelf not found")
|
||||||
|
with db.transaction() as c:
|
||||||
|
db.rename_shelf(c, shelf_id, payload.name.strip())
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/shelves/{shelf_id}")
|
||||||
|
async def delete_shelf(shelf_id: str) -> dict[str, Any]:
|
||||||
|
with db.connection() as c:
|
||||||
|
if not db.get_shelf(c, shelf_id):
|
||||||
|
raise HTTPException(404, "Shelf not found")
|
||||||
|
photos = db.collect_shelf_photos(c, shelf_id)
|
||||||
|
with db.transaction() as c:
|
||||||
|
db.delete_shelf(c, shelf_id)
|
||||||
|
for fn in photos:
|
||||||
|
del_photo(fn)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/shelves/{shelf_id}/photo")
|
||||||
|
async def shelf_photo(shelf_id: str, image: UploadFile = File(...)) -> dict[str, Any]:
|
||||||
|
with db.connection() as c:
|
||||||
|
if not db.get_shelf(c, shelf_id):
|
||||||
|
raise HTTPException(404, "Shelf not found")
|
||||||
|
old = db.get_shelf_photo(c, shelf_id)
|
||||||
|
del_photo(old)
|
||||||
|
fn = await save_photo(image)
|
||||||
|
with db.transaction() as c:
|
||||||
|
db.set_shelf_photo(c, shelf_id, fn)
|
||||||
|
return {"photo_filename": fn}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/api/shelves/{shelf_id}/boundaries")
|
||||||
|
async def update_shelf_boundaries(shelf_id: str, request: Request) -> dict[str, Any]:
|
||||||
|
payload = await _parse(_boundaries_dec, request)
|
||||||
|
with db.connection() as c:
|
||||||
|
if not db.get_shelf(c, shelf_id):
|
||||||
|
raise HTTPException(404, "Shelf not found")
|
||||||
|
with db.transaction() as c:
|
||||||
|
db.set_shelf_boundaries(c, shelf_id, json.dumps(payload.boundaries))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/shelves/{shelf_id}/crop")
|
||||||
|
async def crop_shelf_photo(shelf_id: str, request: Request) -> dict[str, Any]:
|
||||||
|
payload = await _parse(_crop_dec, request)
|
||||||
|
with db.connection() as c:
|
||||||
|
fn = db.get_shelf_photo(c, shelf_id)
|
||||||
|
if not fn:
|
||||||
|
raise HTTPException(400, "No override photo to crop")
|
||||||
|
from files import IMAGES_DIR
|
||||||
|
|
||||||
|
crop_save(IMAGES_DIR / fn, payload.x, payload.y, payload.w, payload.h)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/shelves/{shelf_id}/image")
|
||||||
|
def shelf_image(shelf_id: str) -> Any:
|
||||||
|
with db.connection() as c:
|
||||||
|
path, crop = shelf_source(c, shelf_id)
|
||||||
|
return serve_crop(path, crop)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Books ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/shelves/{shelf_id}/books")
|
||||||
|
async def create_book(shelf_id: str) -> dict[str, Any]:
|
||||||
|
with db.connection() as c:
|
||||||
|
if not db.get_shelf(c, shelf_id):
|
||||||
|
raise HTTPException(404, "Shelf not found")
|
||||||
|
with db.transaction() as c:
|
||||||
|
book = db.create_book(c, shelf_id)
|
||||||
|
return dataclasses.asdict(book)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/books/{book_id}")
|
||||||
|
async def update_book(book_id: str, request: Request) -> dict[str, Any]:
|
||||||
|
payload = await _parse(_book_dec, request)
|
||||||
|
with db.connection() as c:
|
||||||
|
if not db.get_book(c, book_id):
|
||||||
|
raise HTTPException(404, "Book not found")
|
||||||
|
status = logic.save_user_fields(
|
||||||
|
book_id,
|
||||||
|
payload.title.strip(),
|
||||||
|
payload.author.strip(),
|
||||||
|
payload.year.strip(),
|
||||||
|
payload.isbn.strip(),
|
||||||
|
payload.publisher.strip(),
|
||||||
|
payload.notes.strip(),
|
||||||
|
)
|
||||||
|
return {"ok": True, "identification_status": status}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/books/{book_id}")
|
||||||
|
async def delete_book(book_id: str) -> dict[str, Any]:
|
||||||
|
with db.connection() as c:
|
||||||
|
if not db.get_book(c, book_id):
|
||||||
|
raise HTTPException(404, "Book not found")
|
||||||
|
fn = db.get_book_photo(c, book_id)
|
||||||
|
with db.transaction() as c:
|
||||||
|
db.delete_book(c, book_id)
|
||||||
|
del_photo(fn)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/books/{book_id}/photo")
|
||||||
|
async def book_photo(book_id: str, image: UploadFile = File(...)) -> dict[str, Any]:
|
||||||
|
with db.connection() as c:
|
||||||
|
if not db.get_book(c, book_id):
|
||||||
|
raise HTTPException(404, "Book not found")
|
||||||
|
old = db.get_book_photo(c, book_id)
|
||||||
|
del_photo(old)
|
||||||
|
fn = await save_photo(image)
|
||||||
|
with db.transaction() as c:
|
||||||
|
db.set_book_photo(c, book_id, fn)
|
||||||
|
return {"image_filename": fn}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/books/{book_id}/spine")
|
||||||
|
def book_spine(book_id: str) -> Any:
|
||||||
|
with db.connection() as c:
|
||||||
|
path, crop = book_spine_source(c, book_id)
|
||||||
|
return serve_crop(path, crop)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/books/{book_id}/dismiss-field")
|
||||||
|
async def dismiss_book_field(book_id: str, request: Request) -> dict[str, Any]:
|
||||||
|
payload = await _parse(_dismiss_dec, request)
|
||||||
|
if payload.field not in logic.AI_FIELDS:
|
||||||
|
raise HTTPException(400, f"field must be one of {logic.AI_FIELDS}")
|
||||||
|
with db.connection() as c:
|
||||||
|
if not db.get_book(c, book_id):
|
||||||
|
raise HTTPException(404, "Book not found")
|
||||||
|
status, candidates = logic.dismiss_field(book_id, payload.field, payload.value.strip())
|
||||||
|
return {"ok": True, "identification_status": status, "candidates": candidates}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/books/{book_id}/process")
|
||||||
|
async def process_book(book_id: str) -> dict[str, Any]:
|
||||||
|
"""Run full auto-queue pipeline for a single book."""
|
||||||
|
with db.connection() as c:
|
||||||
|
if not db.get_book(c, book_id):
|
||||||
|
raise HTTPException(404, "Book not found")
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(logic.batch_executor, logic.process_book_sync, book_id)
|
||||||
|
with db.connection() as c:
|
||||||
|
book = db.get_book(c, book_id)
|
||||||
|
if not book:
|
||||||
|
raise HTTPException(404, "Book not found")
|
||||||
|
return dataclasses.asdict(book)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Universal plugin endpoint ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/{entity_type}/{entity_id}/plugin/{plugin_id}")
|
||||||
|
async def run_plugin(entity_type: str, entity_id: str, plugin_id: str) -> dict[str, Any]:
|
||||||
|
"""Run any registered plugin on an entity. Returns updated entity."""
|
||||||
|
with db.connection() as c:
|
||||||
|
if entity_type == "cabinets":
|
||||||
|
if not db.get_cabinet(c, entity_id):
|
||||||
|
raise HTTPException(404, "Cabinet not found")
|
||||||
|
elif entity_type == "shelves":
|
||||||
|
if not db.get_shelf(c, entity_id):
|
||||||
|
raise HTTPException(404, "Shelf not found")
|
||||||
|
elif entity_type == "books":
|
||||||
|
if not db.get_book(c, entity_id):
|
||||||
|
raise HTTPException(404, "Book not found")
|
||||||
|
else:
|
||||||
|
raise HTTPException(400, f"Unknown entity type: {entity_type}")
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await logic.dispatch_plugin(plugin_id, plugin_registry.get_plugin(plugin_id), entity_type, entity_id, loop)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Batch ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/batch")
|
||||||
|
async def start_batch() -> dict[str, Any]:
|
||||||
|
if logic.batch_state["running"]:
|
||||||
|
return {"already_running": True}
|
||||||
|
with db.connection() as c:
|
||||||
|
ids = db.get_unidentified_book_ids(c)
|
||||||
|
if not ids:
|
||||||
|
return {"started": False, "reason": "no_unidentified_books"}
|
||||||
|
asyncio.create_task(logic.run_batch(ids))
|
||||||
|
return {"started": True, "total": len(ids)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/batch/status")
|
||||||
|
def batch_status() -> dict[str, Any]:
|
||||||
|
return dict(logic.batch_state)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Reorder ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_REORDER_TABLES = {"rooms", "cabinets", "shelves", "books"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/api/{kind}/reorder")
|
||||||
|
async def reorder(kind: str, request: Request) -> dict[str, Any]:
|
||||||
|
if kind not in _REORDER_TABLES:
|
||||||
|
raise HTTPException(400, "Invalid kind")
|
||||||
|
payload = await _parse(_reorder_dec, request)
|
||||||
|
with db.transaction() as c:
|
||||||
|
db.reorder_entities(c, kind, payload.ids)
|
||||||
|
return {"ok": True}
|
||||||
76
src/app.py
Normal file
76
src/app.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Bookshelf cataloger — FastAPI entry point.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cp config/credentials.default.yaml config/credentials.user.yaml # fill in your API key
|
||||||
|
poetry install
|
||||||
|
poetry run serve
|
||||||
|
"""
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
import plugins as plugin_registry
|
||||||
|
from api import router
|
||||||
|
from config import get_config, load_config
|
||||||
|
from db import init_db
|
||||||
|
from files import IMAGES_DIR, init_dirs
|
||||||
|
from errors import BadRequestError, ConfigError, ImageReadError, NotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
load_config()
|
||||||
|
init_dirs()
|
||||||
|
init_db()
|
||||||
|
plugin_registry.load_plugins(get_config())
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
app.include_router(router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(NotFoundError)
|
||||||
|
async def handle_not_found(_: Request, exc: NotFoundError) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(BadRequestError)
|
||||||
|
async def handle_bad_request(_: Request, exc: BadRequestError) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(ConfigError)
|
||||||
|
async def handle_config_error(_: Request, exc: ConfigError) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(ImageReadError)
|
||||||
|
async def handle_image_read_error(_: Request, exc: ImageReadError) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
|
app.mount("/images", StaticFiles(directory=str(IMAGES_DIR)), name="images")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index() -> FileResponse:
|
||||||
|
return FileResponse("static/index.html")
|
||||||
|
|
||||||
|
|
||||||
|
app.mount("/", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
176
src/config.py
Normal file
176
src/config.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""
|
||||||
|
Configuration loading and typed dataclasses for all config categories.
|
||||||
|
|
||||||
|
Reads config/*.default.yaml merged with config/*.user.yaml overrides.
|
||||||
|
Provides typed access via mashumaro dataclasses.
|
||||||
|
|
||||||
|
Three-layer config chain:
|
||||||
|
credentials → models → functions
|
||||||
|
credentials: API keys + base_url per provider endpoint
|
||||||
|
models: AI model string + openrouter routing + prompt; references a credential
|
||||||
|
functions: per-function-type settings (auto_queue, rate_limit, etc.); AI functions
|
||||||
|
reference a model; archive functions specify type + config dict
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigFileError: If a config file cannot be read or parsed as YAML.
|
||||||
|
ConfigValidationError: If merged config data does not match the AppConfig schema.
|
||||||
|
ConfigNotLoadedError: If get_config() is called before load_config().
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, TypeGuard
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from mashumaro.codecs import BasicDecoder
|
||||||
|
|
||||||
|
from errors import ConfigFileError, ConfigNotLoadedError, ConfigValidationError
|
||||||
|
|
||||||
|
_CONFIG_DIR = Path("config")
|
||||||
|
_CONFIG_CATEGORIES = ["credentials", "models", "functions", "ui"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CredentialConfig:
|
||||||
|
base_url: str = ""
|
||||||
|
api_key: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelConfig:
|
||||||
|
credentials: str = ""
|
||||||
|
model: str = ""
|
||||||
|
extra_body: dict[str, Any] = field(default_factory=lambda: {})
|
||||||
|
prompt: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AIFunctionConfig:
|
||||||
|
model: str = ""
|
||||||
|
auto_queue: bool = False
|
||||||
|
rate_limit_seconds: float = 0.0
|
||||||
|
timeout: int = 30
|
||||||
|
max_image_px: int = 1600
|
||||||
|
confidence_threshold: float = 0.8
|
||||||
|
name: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ArchiveSearcherFunctionConfig:
|
||||||
|
type: str = ""
|
||||||
|
auto_queue: bool = False
|
||||||
|
rate_limit_seconds: float = 0.0
|
||||||
|
timeout: int = 8
|
||||||
|
name: str = ""
|
||||||
|
config: dict[str, Any] = field(default_factory=lambda: {})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FunctionsConfig:
|
||||||
|
boundary_detectors: dict[str, AIFunctionConfig] = field(default_factory=lambda: {})
|
||||||
|
text_recognizers: dict[str, AIFunctionConfig] = field(default_factory=lambda: {})
|
||||||
|
book_identifiers: dict[str, AIFunctionConfig] = field(default_factory=lambda: {})
|
||||||
|
archive_searchers: dict[str, ArchiveSearcherFunctionConfig] = field(default_factory=lambda: {})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UIConfig:
|
||||||
|
boundary_grab_px: int = 14
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppConfig:
|
||||||
|
credentials: dict[str, CredentialConfig] = field(default_factory=lambda: {})
|
||||||
|
models: dict[str, ModelConfig] = field(default_factory=lambda: {})
|
||||||
|
functions: FunctionsConfig = field(default_factory=FunctionsConfig)
|
||||||
|
ui: UIConfig = field(default_factory=UIConfig)
|
||||||
|
|
||||||
|
|
||||||
|
_decoder: BasicDecoder[AppConfig] = BasicDecoder(AppConfig)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Merge helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _is_str_dict(v: object) -> TypeGuard[dict[str, Any]]:
|
||||||
|
"""TypeGuard that narrows Any/object to dict[str, Any] after isinstance check."""
|
||||||
|
return isinstance(v, dict)
|
||||||
|
|
||||||
|
|
||||||
|
def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Recursively merge override into base. Lists in override replace lists in base.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base: Base dictionary to merge into.
|
||||||
|
override: Override dictionary whose values take precedence.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New merged dictionary; base and override are not modified.
|
||||||
|
"""
|
||||||
|
result: dict[str, Any] = dict(base)
|
||||||
|
for key, val in override.items():
|
||||||
|
if key in result:
|
||||||
|
existing = result[key]
|
||||||
|
if _is_str_dict(existing) and _is_str_dict(val):
|
||||||
|
result[key] = deep_merge(existing, val)
|
||||||
|
continue
|
||||||
|
result[key] = val
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── Config loading ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
config_holder: list[AppConfig] = []
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> AppConfig:
|
||||||
|
"""Load and parse config from config/*.default.yaml merged with config/*.user.yaml.
|
||||||
|
|
||||||
|
Each config category (credentials, models, functions, ui) is read from its default
|
||||||
|
file, then deep-merged with the corresponding user override file if it exists.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed and validated AppConfig.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigFileError: If the config directory is missing, a file cannot be opened,
|
||||||
|
or a file contains invalid YAML.
|
||||||
|
ConfigValidationError: If the merged config does not match the AppConfig schema.
|
||||||
|
"""
|
||||||
|
if not _CONFIG_DIR.exists():
|
||||||
|
raise ConfigFileError(_CONFIG_DIR, "directory not found — see config/*.default.yaml")
|
||||||
|
merged: dict[str, Any] = {}
|
||||||
|
for cat in _CONFIG_CATEGORIES:
|
||||||
|
data: dict[str, Any] = {}
|
||||||
|
for f_path in [_CONFIG_DIR / f"{cat}.default.yaml", _CONFIG_DIR / f"{cat}.user.yaml"]:
|
||||||
|
if not f_path.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(f_path) as fh:
|
||||||
|
loaded = yaml.safe_load(fh)
|
||||||
|
except (OSError, yaml.YAMLError) as exc:
|
||||||
|
raise ConfigFileError(f_path, str(exc)) from exc
|
||||||
|
if _is_str_dict(loaded):
|
||||||
|
data = deep_merge(data, loaded)
|
||||||
|
merged = deep_merge(merged, data)
|
||||||
|
try:
|
||||||
|
cfg = _decoder.decode(merged)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ConfigValidationError(str(exc)) from exc
|
||||||
|
config_holder.clear()
|
||||||
|
config_holder.append(cfg)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def get_config() -> AppConfig:
|
||||||
|
"""Return the currently loaded config.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The AppConfig loaded by the most recent load_config() call.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigNotLoadedError: If load_config() has not yet been called.
|
||||||
|
"""
|
||||||
|
if not config_holder:
|
||||||
|
raise ConfigNotLoadedError()
|
||||||
|
return config_holder[0]
|
||||||
515
src/db.py
Normal file
515
src/db.py
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
"""
|
||||||
|
Database layer: schema, connection/transaction lifecycle, and all query functions.
|
||||||
|
No file I/O, no config, no business logic. All SQL lives here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from mashumaro.codecs import BasicDecoder
|
||||||
|
|
||||||
|
from models import BookRow, CabinetRow, RoomRow, ShelfRow
|
||||||
|
|
||||||
|
DB_PATH = Path("data") / "books.db"
|
||||||
|
|
||||||
|
# ── Schema ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS rooms (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS cabinets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
photo_filename TEXT,
|
||||||
|
shelf_boundaries TEXT DEFAULT NULL,
|
||||||
|
ai_shelf_boundaries TEXT DEFAULT NULL,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS shelves (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
cabinet_id TEXT NOT NULL REFERENCES cabinets(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
photo_filename TEXT,
|
||||||
|
book_boundaries TEXT DEFAULT NULL,
|
||||||
|
ai_book_boundaries TEXT DEFAULT NULL,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS books (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
shelf_id TEXT NOT NULL REFERENCES shelves(id) ON DELETE CASCADE,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
image_filename TEXT,
|
||||||
|
title TEXT DEFAULT '',
|
||||||
|
author TEXT DEFAULT '',
|
||||||
|
year TEXT DEFAULT '',
|
||||||
|
isbn TEXT DEFAULT '',
|
||||||
|
publisher TEXT DEFAULT '',
|
||||||
|
notes TEXT DEFAULT '',
|
||||||
|
raw_text TEXT DEFAULT '',
|
||||||
|
ai_title TEXT DEFAULT '',
|
||||||
|
ai_author TEXT DEFAULT '',
|
||||||
|
ai_year TEXT DEFAULT '',
|
||||||
|
ai_isbn TEXT DEFAULT '',
|
||||||
|
ai_publisher TEXT DEFAULT '',
|
||||||
|
identification_status TEXT DEFAULT 'unidentified',
|
||||||
|
title_confidence REAL DEFAULT 0,
|
||||||
|
analyzed_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
candidates TEXT DEFAULT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── Mashumaro decoders for entity rows ────────────────────────────────────────
|
||||||
|
|
||||||
|
_room_dec: BasicDecoder[RoomRow] = BasicDecoder(RoomRow)
|
||||||
|
_cabinet_dec: BasicDecoder[CabinetRow] = BasicDecoder(CabinetRow)
|
||||||
|
_shelf_dec: BasicDecoder[ShelfRow] = BasicDecoder(ShelfRow)
|
||||||
|
_book_dec: BasicDecoder[BookRow] = BasicDecoder(BookRow)
|
||||||
|
|
||||||
|
|
||||||
|
def _room(row: sqlite3.Row) -> RoomRow:
|
||||||
|
return _room_dec.decode(dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
def _cabinet(row: sqlite3.Row) -> CabinetRow:
|
||||||
|
return _cabinet_dec.decode(dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
def _shelf(row: sqlite3.Row) -> ShelfRow:
|
||||||
|
return _shelf_dec.decode(dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
def _book(row: sqlite3.Row) -> BookRow:
|
||||||
|
return _book_dec.decode(dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
# ── DB init + connection ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
DB_PATH.parent.mkdir(exist_ok=True)
|
||||||
|
c = conn()
|
||||||
|
c.executescript(SCHEMA)
|
||||||
|
c.commit()
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def conn() -> sqlite3.Connection:
|
||||||
|
c = sqlite3.connect(DB_PATH)
|
||||||
|
c.row_factory = sqlite3.Row
|
||||||
|
c.execute("PRAGMA foreign_keys = ON")
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
# ── Context managers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def connection() -> Iterator[sqlite3.Connection]:
|
||||||
|
"""Read-only context: opens a connection, closes on exit."""
|
||||||
|
c = conn()
|
||||||
|
try:
|
||||||
|
yield c
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def transaction() -> Iterator[sqlite3.Connection]:
|
||||||
|
"""Write context: opens, commits on success, rolls back on exception."""
|
||||||
|
c = conn()
|
||||||
|
try:
|
||||||
|
yield c
|
||||||
|
c.commit()
|
||||||
|
except Exception:
|
||||||
|
c.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
COUNTERS: dict[str, int] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def uid() -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> str:
|
||||||
|
return datetime.now().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def next_pos(db: sqlite3.Connection, table: str, parent_col: str, parent_id: str) -> int:
|
||||||
|
row = db.execute(f"SELECT COALESCE(MAX(position),0)+1 FROM {table} WHERE {parent_col}=?", [parent_id]).fetchone()
|
||||||
|
return int(row[0])
|
||||||
|
|
||||||
|
|
||||||
|
def next_root_pos(db: sqlite3.Connection, table: str) -> int:
|
||||||
|
row = db.execute(f"SELECT COALESCE(MAX(position),0)+1 FROM {table}").fetchone()
|
||||||
|
return int(row[0])
|
||||||
|
|
||||||
|
|
||||||
|
def next_name(prefix: str) -> str:
|
||||||
|
COUNTERS[prefix] = COUNTERS.get(prefix, 0) + 1
|
||||||
|
return f"{prefix} {COUNTERS[prefix]}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tree ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_tree(db: sqlite3.Connection) -> list[dict[str, object]]:
|
||||||
|
"""Build and return the full nested Room→Cabinet→Shelf→Book tree."""
|
||||||
|
rooms: list[dict[str, object]] = [dict(r) for r in db.execute("SELECT * FROM rooms ORDER BY position")]
|
||||||
|
for room in rooms:
|
||||||
|
cabs: list[dict[str, object]] = [
|
||||||
|
dict(c) for c in db.execute("SELECT * FROM cabinets WHERE room_id=? ORDER BY position", [room["id"]])
|
||||||
|
]
|
||||||
|
for cab in cabs:
|
||||||
|
shelves: list[dict[str, object]] = [
|
||||||
|
dict(s) for s in db.execute("SELECT * FROM shelves WHERE cabinet_id=? ORDER BY position", [cab["id"]])
|
||||||
|
]
|
||||||
|
for shelf in shelves:
|
||||||
|
shelf["books"] = [
|
||||||
|
dict(b) for b in db.execute("SELECT * FROM books WHERE shelf_id=? ORDER BY position", [shelf["id"]])
|
||||||
|
]
|
||||||
|
cab["shelves"] = shelves
|
||||||
|
room["cabinets"] = cabs
|
||||||
|
return rooms
|
||||||
|
|
||||||
|
|
||||||
|
# ── Rooms ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_room(db: sqlite3.Connection, room_id: str) -> RoomRow | None:
|
||||||
|
row = db.execute("SELECT * FROM rooms WHERE id=?", [room_id]).fetchone()
|
||||||
|
return _room(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_room(db: sqlite3.Connection) -> RoomRow:
|
||||||
|
data = {"id": uid(), "name": next_name("Room"), "position": next_root_pos(db, "rooms"), "created_at": now()}
|
||||||
|
db.execute("INSERT INTO rooms VALUES(:id,:name,:position,:created_at)", data)
|
||||||
|
return _room_dec.decode(data)
|
||||||
|
|
||||||
|
|
||||||
|
def rename_room(db: sqlite3.Connection, room_id: str, name: str) -> None:
|
||||||
|
db.execute("UPDATE rooms SET name=? WHERE id=?", [name, room_id])
|
||||||
|
|
||||||
|
|
||||||
|
def collect_room_photos(db: sqlite3.Connection, room_id: str) -> list[str]:
|
||||||
|
"""Return all photo filenames for cabinets/shelves/books under this room."""
|
||||||
|
photos: list[str] = []
|
||||||
|
for r in db.execute(
|
||||||
|
"SELECT image_filename FROM books WHERE shelf_id IN "
|
||||||
|
"(SELECT id FROM shelves WHERE cabinet_id IN (SELECT id FROM cabinets WHERE room_id=?))",
|
||||||
|
[room_id],
|
||||||
|
):
|
||||||
|
if r[0]:
|
||||||
|
photos.append(str(r[0]))
|
||||||
|
for r in db.execute(
|
||||||
|
"SELECT photo_filename FROM shelves WHERE cabinet_id IN (SELECT id FROM cabinets WHERE room_id=?)", [room_id]
|
||||||
|
):
|
||||||
|
if r[0]:
|
||||||
|
photos.append(str(r[0]))
|
||||||
|
for r in db.execute("SELECT photo_filename FROM cabinets WHERE room_id=?", [room_id]):
|
||||||
|
if r[0]:
|
||||||
|
photos.append(str(r[0]))
|
||||||
|
return photos
|
||||||
|
|
||||||
|
|
||||||
|
def delete_room(db: sqlite3.Connection, room_id: str) -> None:
|
||||||
|
"""Delete room; SQLite ON DELETE CASCADE removes all children."""
|
||||||
|
db.execute("DELETE FROM rooms WHERE id=?", [room_id])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cabinets ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_cabinet(db: sqlite3.Connection, cabinet_id: str) -> CabinetRow | None:
|
||||||
|
row = db.execute("SELECT * FROM cabinets WHERE id=?", [cabinet_id]).fetchone()
|
||||||
|
return _cabinet(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_cabinet(db: sqlite3.Connection, room_id: str) -> CabinetRow:
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"id": uid(),
|
||||||
|
"room_id": room_id,
|
||||||
|
"name": next_name("Cabinet"),
|
||||||
|
"photo_filename": None,
|
||||||
|
"shelf_boundaries": None,
|
||||||
|
"ai_shelf_boundaries": None,
|
||||||
|
"position": next_pos(db, "cabinets", "room_id", room_id),
|
||||||
|
"created_at": now(),
|
||||||
|
}
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO cabinets VALUES("
|
||||||
|
":id,:room_id,:name,:photo_filename,:shelf_boundaries,"
|
||||||
|
":ai_shelf_boundaries,:position,:created_at)",
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
return _cabinet_dec.decode(data)
|
||||||
|
|
||||||
|
|
||||||
|
def rename_cabinet(db: sqlite3.Connection, cabinet_id: str, name: str) -> None:
|
||||||
|
db.execute("UPDATE cabinets SET name=? WHERE id=?", [name, cabinet_id])
|
||||||
|
|
||||||
|
|
||||||
|
def collect_cabinet_photos(db: sqlite3.Connection, cabinet_id: str) -> list[str]:
|
||||||
|
photos: list[str] = []
|
||||||
|
for r in db.execute(
|
||||||
|
"SELECT image_filename FROM books WHERE shelf_id IN (SELECT id FROM shelves WHERE cabinet_id=?)", [cabinet_id]
|
||||||
|
):
|
||||||
|
if r[0]:
|
||||||
|
photos.append(str(r[0]))
|
||||||
|
for r in db.execute("SELECT photo_filename FROM shelves WHERE cabinet_id=?", [cabinet_id]):
|
||||||
|
if r[0]:
|
||||||
|
photos.append(str(r[0]))
|
||||||
|
row = db.execute("SELECT photo_filename FROM cabinets WHERE id=?", [cabinet_id]).fetchone()
|
||||||
|
if row and row[0]:
|
||||||
|
photos.append(str(row[0]))
|
||||||
|
return photos
|
||||||
|
|
||||||
|
|
||||||
|
def delete_cabinet(db: sqlite3.Connection, cabinet_id: str) -> None:
|
||||||
|
db.execute("DELETE FROM cabinets WHERE id=?", [cabinet_id])
|
||||||
|
|
||||||
|
|
||||||
|
def get_cabinet_photo(db: sqlite3.Connection, cabinet_id: str) -> str | None:
|
||||||
|
row = db.execute("SELECT photo_filename FROM cabinets WHERE id=?", [cabinet_id]).fetchone()
|
||||||
|
return str(row[0]) if row and row[0] else None
|
||||||
|
|
||||||
|
|
||||||
|
def set_cabinet_photo(db: sqlite3.Connection, cabinet_id: str, filename: str) -> None:
|
||||||
|
db.execute("UPDATE cabinets SET photo_filename=? WHERE id=?", [filename, cabinet_id])
|
||||||
|
|
||||||
|
|
||||||
|
def set_cabinet_boundaries(db: sqlite3.Connection, cabinet_id: str, boundaries_json: str) -> None:
|
||||||
|
db.execute("UPDATE cabinets SET shelf_boundaries=? WHERE id=?", [boundaries_json, cabinet_id])
|
||||||
|
|
||||||
|
|
||||||
|
def set_ai_shelf_boundaries(db: sqlite3.Connection, cabinet_id: str, plugin_id: str, boundaries: list[float]) -> None:
|
||||||
|
row = db.execute("SELECT ai_shelf_boundaries FROM cabinets WHERE id=?", [cabinet_id]).fetchone()
|
||||||
|
current: dict[str, object] = json.loads(row[0]) if row and row[0] else {}
|
||||||
|
current[plugin_id] = boundaries
|
||||||
|
db.execute("UPDATE cabinets SET ai_shelf_boundaries=? WHERE id=?", [json.dumps(current), cabinet_id])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Shelves ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_shelf(db: sqlite3.Connection, shelf_id: str) -> ShelfRow | None:
|
||||||
|
row = db.execute("SELECT * FROM shelves WHERE id=?", [shelf_id]).fetchone()
|
||||||
|
return _shelf(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_shelf(db: sqlite3.Connection, cabinet_id: str) -> ShelfRow:
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"id": uid(),
|
||||||
|
"cabinet_id": cabinet_id,
|
||||||
|
"name": next_name("Shelf"),
|
||||||
|
"photo_filename": None,
|
||||||
|
"book_boundaries": None,
|
||||||
|
"ai_book_boundaries": None,
|
||||||
|
"position": next_pos(db, "shelves", "cabinet_id", cabinet_id),
|
||||||
|
"created_at": now(),
|
||||||
|
}
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO shelves VALUES("
|
||||||
|
":id,:cabinet_id,:name,:photo_filename,:book_boundaries,:ai_book_boundaries,:position,:created_at)",
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
return _shelf_dec.decode(data)
|
||||||
|
|
||||||
|
|
||||||
|
def rename_shelf(db: sqlite3.Connection, shelf_id: str, name: str) -> None:
|
||||||
|
db.execute("UPDATE shelves SET name=? WHERE id=?", [name, shelf_id])
|
||||||
|
|
||||||
|
|
||||||
|
def collect_shelf_photos(db: sqlite3.Connection, shelf_id: str) -> list[str]:
|
||||||
|
photos: list[str] = []
|
||||||
|
row = db.execute("SELECT photo_filename FROM shelves WHERE id=?", [shelf_id]).fetchone()
|
||||||
|
if row and row[0]:
|
||||||
|
photos.append(str(row[0]))
|
||||||
|
for r in db.execute("SELECT image_filename FROM books WHERE shelf_id=?", [shelf_id]):
|
||||||
|
if r[0]:
|
||||||
|
photos.append(str(r[0]))
|
||||||
|
return photos
|
||||||
|
|
||||||
|
|
||||||
|
def delete_shelf(db: sqlite3.Connection, shelf_id: str) -> None:
|
||||||
|
db.execute("DELETE FROM shelves WHERE id=?", [shelf_id])
|
||||||
|
|
||||||
|
|
||||||
|
def get_shelf_photo(db: sqlite3.Connection, shelf_id: str) -> str | None:
|
||||||
|
row = db.execute("SELECT photo_filename FROM shelves WHERE id=?", [shelf_id]).fetchone()
|
||||||
|
return str(row[0]) if row and row[0] else None
|
||||||
|
|
||||||
|
|
||||||
|
def set_shelf_photo(db: sqlite3.Connection, shelf_id: str, filename: str) -> None:
|
||||||
|
db.execute("UPDATE shelves SET photo_filename=? WHERE id=?", [filename, shelf_id])
|
||||||
|
|
||||||
|
|
||||||
|
def set_shelf_boundaries(db: sqlite3.Connection, shelf_id: str, boundaries_json: str) -> None:
|
||||||
|
db.execute("UPDATE shelves SET book_boundaries=? WHERE id=?", [boundaries_json, shelf_id])
|
||||||
|
|
||||||
|
|
||||||
|
def set_ai_book_boundaries(db: sqlite3.Connection, shelf_id: str, plugin_id: str, boundaries: list[float]) -> None:
|
||||||
|
row = db.execute("SELECT ai_book_boundaries FROM shelves WHERE id=?", [shelf_id]).fetchone()
|
||||||
|
current: dict[str, object] = json.loads(row[0]) if row and row[0] else {}
|
||||||
|
current[plugin_id] = boundaries
|
||||||
|
db.execute("UPDATE shelves SET ai_book_boundaries=? WHERE id=?", [json.dumps(current), shelf_id])
|
||||||
|
|
||||||
|
|
||||||
|
def get_shelf_rank(db: sqlite3.Connection, shelf_id: str) -> int:
|
||||||
|
"""0-based rank of shelf among its siblings sorted by position."""
|
||||||
|
row = db.execute("SELECT cabinet_id FROM shelves WHERE id=?", [shelf_id]).fetchone()
|
||||||
|
if not row:
|
||||||
|
return 0
|
||||||
|
siblings = [r[0] for r in db.execute("SELECT id FROM shelves WHERE cabinet_id=? ORDER BY position", [row[0]])]
|
||||||
|
return siblings.index(shelf_id) if shelf_id in siblings else 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Books ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_book(db: sqlite3.Connection, book_id: str) -> BookRow | None:
|
||||||
|
row = db.execute("SELECT * FROM books WHERE id=?", [book_id]).fetchone()
|
||||||
|
return _book(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_book(db: sqlite3.Connection, shelf_id: str) -> BookRow:
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"id": uid(),
|
||||||
|
"shelf_id": shelf_id,
|
||||||
|
"position": next_pos(db, "books", "shelf_id", shelf_id),
|
||||||
|
"image_filename": None,
|
||||||
|
"title": "",
|
||||||
|
"author": "",
|
||||||
|
"year": "",
|
||||||
|
"isbn": "",
|
||||||
|
"publisher": "",
|
||||||
|
"notes": "",
|
||||||
|
"raw_text": "",
|
||||||
|
"ai_title": "",
|
||||||
|
"ai_author": "",
|
||||||
|
"ai_year": "",
|
||||||
|
"ai_isbn": "",
|
||||||
|
"ai_publisher": "",
|
||||||
|
"identification_status": "unidentified",
|
||||||
|
"title_confidence": 0,
|
||||||
|
"analyzed_at": None,
|
||||||
|
"created_at": now(),
|
||||||
|
"candidates": None,
|
||||||
|
}
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO books VALUES(:id,:shelf_id,:position,:image_filename,:title,:author,:year,:isbn,:publisher,"
|
||||||
|
":notes,:raw_text,:ai_title,:ai_author,:ai_year,:ai_isbn,:ai_publisher,:identification_status,"
|
||||||
|
":title_confidence,:analyzed_at,:created_at,:candidates)",
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
return _book_dec.decode(data)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_book(db: sqlite3.Connection, book_id: str) -> None:
|
||||||
|
db.execute("DELETE FROM books WHERE id=?", [book_id])
|
||||||
|
|
||||||
|
|
||||||
|
def get_book_photo(db: sqlite3.Connection, book_id: str) -> str | None:
|
||||||
|
row = db.execute("SELECT image_filename FROM books WHERE id=?", [book_id]).fetchone()
|
||||||
|
return str(row[0]) if row and row[0] else None
|
||||||
|
|
||||||
|
|
||||||
|
def set_book_photo(db: sqlite3.Connection, book_id: str, filename: str) -> None:
|
||||||
|
db.execute("UPDATE books SET image_filename=? WHERE id=?", [filename, book_id])
|
||||||
|
|
||||||
|
|
||||||
|
def set_user_book_fields(
|
||||||
|
db: sqlite3.Connection,
|
||||||
|
book_id: str,
|
||||||
|
title: str,
|
||||||
|
author: str,
|
||||||
|
year: str,
|
||||||
|
isbn: str,
|
||||||
|
publisher: str,
|
||||||
|
notes: str,
|
||||||
|
) -> None:
|
||||||
|
"""Set both user fields and ai_* fields (user edit is the authoritative identification)."""
|
||||||
|
db.execute(
|
||||||
|
"UPDATE books SET title=?,author=?,year=?,isbn=?,publisher=?,notes=?,"
|
||||||
|
"ai_title=?,ai_author=?,ai_year=?,ai_isbn=?,ai_publisher=? WHERE id=?",
|
||||||
|
[title, author, year, isbn, publisher, notes, title, author, year, isbn, publisher, book_id],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_book_status(db: sqlite3.Connection, book_id: str, status: str) -> None:
|
||||||
|
db.execute("UPDATE books SET identification_status=? WHERE id=?", [status, book_id])
|
||||||
|
|
||||||
|
|
||||||
|
def set_book_confidence(db: sqlite3.Connection, book_id: str, confidence: float, analyzed_at: str) -> None:
|
||||||
|
db.execute(
|
||||||
|
"UPDATE books SET title_confidence=?, analyzed_at=? WHERE id=?",
|
||||||
|
[confidence, analyzed_at, book_id],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_book_ai_fields(
|
||||||
|
db: sqlite3.Connection,
|
||||||
|
book_id: str,
|
||||||
|
ai_title: str,
|
||||||
|
ai_author: str,
|
||||||
|
ai_year: str,
|
||||||
|
ai_isbn: str,
|
||||||
|
ai_publisher: str,
|
||||||
|
) -> None:
|
||||||
|
db.execute(
|
||||||
|
"UPDATE books SET ai_title=?,ai_author=?,ai_year=?,ai_isbn=?,ai_publisher=? WHERE id=?",
|
||||||
|
[ai_title, ai_author, ai_year, ai_isbn, ai_publisher, book_id],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_book_ai_field(db: sqlite3.Connection, book_id: str, field: str, value: str) -> None:
|
||||||
|
"""Set a single ai_* field by name (used in dismiss_field logic)."""
|
||||||
|
# field is validated by caller to be in AI_FIELDS
|
||||||
|
db.execute(f"UPDATE books SET ai_{field}=? WHERE id=?", [value, book_id])
|
||||||
|
|
||||||
|
|
||||||
|
def set_book_raw_text(db: sqlite3.Connection, book_id: str, raw_text: str) -> None:
|
||||||
|
db.execute("UPDATE books SET raw_text=? WHERE id=?", [raw_text, book_id])
|
||||||
|
|
||||||
|
|
||||||
|
def set_book_candidates(db: sqlite3.Connection, book_id: str, candidates_json: str) -> None:
|
||||||
|
db.execute("UPDATE books SET candidates=? WHERE id=?", [candidates_json, book_id])
|
||||||
|
|
||||||
|
|
||||||
|
def get_book_rank(db: sqlite3.Connection, book_id: str) -> int:
|
||||||
|
"""0-based rank of book among its siblings sorted by position."""
|
||||||
|
row = db.execute("SELECT shelf_id FROM books WHERE id=?", [book_id]).fetchone()
|
||||||
|
if not row:
|
||||||
|
return 0
|
||||||
|
siblings = [r[0] for r in db.execute("SELECT id FROM books WHERE shelf_id=? ORDER BY position", [row[0]])]
|
||||||
|
return siblings.index(book_id) if book_id in siblings else 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_unidentified_book_ids(db: sqlite3.Connection) -> list[str]:
|
||||||
|
return [str(r[0]) for r in db.execute("SELECT id FROM books WHERE identification_status='unidentified'")]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Reorder ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def reorder_entities(db: sqlite3.Connection, table: str, ids: list[str]) -> None:
|
||||||
|
for i, entity_id in enumerate(ids, 1):
|
||||||
|
db.execute(f"UPDATE {table} SET position=? WHERE id=?", [i, entity_id])
|
||||||
280
src/errors.py
Normal file
280
src/errors.py
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"""Domain exceptions for the bookshelf application.
|
||||||
|
|
||||||
|
All layers may import from this module. Exceptions carry structured attributes
|
||||||
|
so callers can reason about failures programmatically without string parsing.
|
||||||
|
|
||||||
|
Hierarchy:
|
||||||
|
NotFoundError (→ HTTP 404)
|
||||||
|
BookNotFoundError
|
||||||
|
ShelfNotFoundError
|
||||||
|
CabinetNotFoundError
|
||||||
|
PluginNotFoundError
|
||||||
|
NoShelfImageError
|
||||||
|
ImageFileNotFoundError
|
||||||
|
BadRequestError (→ HTTP 400)
|
||||||
|
NoCabinetPhotoError
|
||||||
|
NoRawTextError
|
||||||
|
InvalidPluginEntityError
|
||||||
|
PluginTargetMismatchError
|
||||||
|
ConfigError (→ HTTP 500)
|
||||||
|
ConfigNotLoadedError
|
||||||
|
ConfigFileError
|
||||||
|
ConfigValidationError
|
||||||
|
ImageError (→ HTTP 500; ImageFileNotFoundError is also NotFoundError → 404)
|
||||||
|
ImageFileNotFoundError (also NotFoundError)
|
||||||
|
ImageReadError
|
||||||
|
|
||||||
|
Rules for exception classes:
|
||||||
|
- Constructor accepts only structured data, no message strings.
|
||||||
|
- All data is available as public attributes (no string parsing needed).
|
||||||
|
- __str__ performs all formatting; it is the only place with text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(Exception):
|
||||||
|
"""Base for all 'entity not found' errors. Caught globally and mapped to HTTP 404."""
|
||||||
|
|
||||||
|
|
||||||
|
class BadRequestError(Exception):
|
||||||
|
"""Base for all 'invalid state/input' errors. Caught globally and mapped to HTTP 400."""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Entity not-found errors ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class BookNotFoundError(NotFoundError):
|
||||||
|
"""Raised when a book ID cannot be found in the database.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
book_id: The book ID that was looked up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, book_id: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.book_id = book_id
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Book not found: {self.book_id!r}"
|
||||||
|
|
||||||
|
|
||||||
|
class ShelfNotFoundError(NotFoundError):
|
||||||
|
"""Raised when a shelf ID cannot be found in the database.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
shelf_id: The shelf ID that was looked up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, shelf_id: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.shelf_id = shelf_id
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Shelf not found: {self.shelf_id!r}"
|
||||||
|
|
||||||
|
|
||||||
|
class CabinetNotFoundError(NotFoundError):
|
||||||
|
"""Raised when a cabinet ID cannot be found in the database.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
cabinet_id: The cabinet ID that was looked up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cabinet_id: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.cabinet_id = cabinet_id
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Cabinet not found: {self.cabinet_id!r}"
|
||||||
|
|
||||||
|
|
||||||
|
class PluginNotFoundError(NotFoundError):
|
||||||
|
"""Raised when a plugin ID is not registered in any category.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
plugin_id: The plugin ID that was looked up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, plugin_id: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.plugin_id = plugin_id
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Plugin not found: {self.plugin_id!r}"
|
||||||
|
|
||||||
|
|
||||||
|
class NoShelfImageError(NotFoundError):
|
||||||
|
"""Raised when no image is available for a shelf (no override photo and parent cabinet has no photo).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
shelf_id: The shelf that has no usable image.
|
||||||
|
cabinet_id: The parent cabinet that also lacks a photo.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, shelf_id: str, cabinet_id: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.shelf_id = shelf_id
|
||||||
|
self.cabinet_id = cabinet_id
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"No image available for shelf {self.shelf_id!r} (cabinet {self.cabinet_id!r} has no photo)"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bad-request errors ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class NoCabinetPhotoError(BadRequestError):
|
||||||
|
"""Raised when boundary detection requires a cabinet photo that has not been uploaded.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
cabinet_id: The cabinet that is missing a photo.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cabinet_id: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.cabinet_id = cabinet_id
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Cabinet {self.cabinet_id!r} has no photo; upload one before running boundary detection"
|
||||||
|
|
||||||
|
|
||||||
|
class NoRawTextError(BadRequestError):
|
||||||
|
"""Raised when book identification is attempted before text recognition has been run.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
book_id: The book that is missing raw text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, book_id: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.book_id = book_id
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Book {self.book_id!r} has no raw text; run text recognizer first"
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidPluginEntityError(BadRequestError):
|
||||||
|
"""Raised when a plugin category does not support the requested entity type.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
plugin_category: The plugin category (e.g. 'text_recognizer').
|
||||||
|
entity_type: The entity type that was requested (e.g. 'cabinets').
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, plugin_category: str, entity_type: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.plugin_category = plugin_category
|
||||||
|
self.entity_type = entity_type
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Plugin category {self.plugin_category!r} does not support entity type {self.entity_type!r}"
|
||||||
|
|
||||||
|
|
||||||
|
class PluginTargetMismatchError(BadRequestError):
|
||||||
|
"""Raised when a boundary detector plugin's target conflicts with the entity being processed.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
plugin_id: The plugin whose target is wrong.
|
||||||
|
expected_target: The target required for the given entity type.
|
||||||
|
actual_target: The target the plugin actually declares.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, plugin_id: str, expected_target: str, actual_target: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.plugin_id = plugin_id
|
||||||
|
self.expected_target = expected_target
|
||||||
|
self.actual_target = actual_target
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"Plugin {self.plugin_id!r} targets {self.actual_target!r}; "
|
||||||
|
f"expected target {self.expected_target!r} for this entity type"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Config errors (→ HTTP 500) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(Exception):
|
||||||
|
"""Base for all configuration loading and validation errors. Maps to HTTP 500."""
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigNotLoadedError(ConfigError):
|
||||||
|
"""Raised when get_config() is called before load_config() has been run."""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return "Config not loaded; call load_config() first"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFileError(ConfigError):
|
||||||
|
"""Raised when a config file cannot be opened or parsed.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
path: The config file (or directory) that could not be read.
|
||||||
|
reason: Human-readable description of the underlying error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: Path, reason: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.path = path
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Config file error ({self.path}): {self.reason}"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigValidationError(ConfigError):
|
||||||
|
"""Raised when config data does not match the expected schema.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
reason: Human-readable description of the validation failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, reason: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Config validation error: {self.reason}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Image errors ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class ImageError(Exception):
|
||||||
|
"""Base for image file operation errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class ImageFileNotFoundError(NotFoundError, ImageError):
|
||||||
|
"""Raised when an image file referenced by an entity is missing from disk.
|
||||||
|
|
||||||
|
Inherits from NotFoundError (→ HTTP 404) and ImageError.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
path: The file path that does not exist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Image file not found: {self.path}"
|
||||||
|
|
||||||
|
|
||||||
|
class ImageReadError(ImageError):
|
||||||
|
"""Raised when an image file exists but cannot be opened or decoded. Maps to HTTP 500.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
path: The file path that could not be read.
|
||||||
|
reason: Human-readable description of the underlying error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: Path, reason: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.path = path
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Image read error ({self.path}): {self.reason}"
|
||||||
40
src/files.py
Normal file
40
src/files.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
File system layer: data directories and photo upload/delete helpers.
|
||||||
|
No DB access, no config parsing, no business logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
|
from fastapi import UploadFile
|
||||||
|
|
||||||
|
DATA_DIR = Path("data")
|
||||||
|
IMAGES_DIR = DATA_DIR / "images"
|
||||||
|
|
||||||
|
_ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".webp"}
|
||||||
|
|
||||||
|
# ── Directory init ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def init_dirs() -> None:
|
||||||
|
DATA_DIR.mkdir(exist_ok=True)
|
||||||
|
IMAGES_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Photo helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def save_photo(upload: UploadFile) -> str:
|
||||||
|
ext = Path(upload.filename or "").suffix.lower() or ".jpg"
|
||||||
|
if ext not in _ALLOWED_EXT:
|
||||||
|
ext = ".jpg"
|
||||||
|
fn = f"{uuid.uuid4()}{ext}"
|
||||||
|
async with aiofiles.open(IMAGES_DIR / fn, "wb") as f:
|
||||||
|
await f.write(await upload.read())
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
def del_photo(fn: str | None) -> None:
|
||||||
|
if fn:
|
||||||
|
(IMAGES_DIR / fn).unlink(missing_ok=True)
|
||||||
108
src/logic/__init__.py
Normal file
108
src/logic/__init__.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Logic package: plugin dispatch orchestration and public re-exports."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import dataclasses
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import plugins as plugin_registry
|
||||||
|
from errors import InvalidPluginEntityError, PluginNotFoundError, PluginTargetMismatchError
|
||||||
|
from models import PluginLookupResult
|
||||||
|
from logic.archive import run_archive_searcher, run_archive_searcher_bg
|
||||||
|
from logic.batch import archive_executor, batch_executor, batch_state, process_book_sync, run_batch
|
||||||
|
from logic.boundaries import book_spine_source, bounds_for_index, run_boundary_detector, shelf_source
|
||||||
|
from logic.identification import (
|
||||||
|
AI_FIELDS,
|
||||||
|
apply_ai_result,
|
||||||
|
build_query,
|
||||||
|
compute_status,
|
||||||
|
dismiss_field,
|
||||||
|
run_book_identifier,
|
||||||
|
run_text_recognizer,
|
||||||
|
save_user_fields,
|
||||||
|
)
|
||||||
|
from logic.images import prep_img_b64, crop_save, serve_crop
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AI_FIELDS",
|
||||||
|
"apply_ai_result",
|
||||||
|
"archive_executor",
|
||||||
|
"batch_executor",
|
||||||
|
"batch_state",
|
||||||
|
"book_spine_source",
|
||||||
|
"bounds_for_index",
|
||||||
|
"build_query",
|
||||||
|
"compute_status",
|
||||||
|
"crop_save",
|
||||||
|
"dismiss_field",
|
||||||
|
"dispatch_plugin",
|
||||||
|
"process_book_sync",
|
||||||
|
"run_archive_searcher",
|
||||||
|
"run_archive_searcher_bg",
|
||||||
|
"run_batch",
|
||||||
|
"run_book_identifier",
|
||||||
|
"run_boundary_detector",
|
||||||
|
"run_text_recognizer",
|
||||||
|
"save_user_fields",
|
||||||
|
"serve_crop",
|
||||||
|
"shelf_source",
|
||||||
|
"prep_img_b64",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def dispatch_plugin(
|
||||||
|
plugin_id: str,
|
||||||
|
lookup: PluginLookupResult,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: str,
|
||||||
|
loop: asyncio.AbstractEventLoop,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Validate plugin/entity compatibility, run the plugin, and trigger auto-queue follow-ups.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_id: The plugin ID string (used in error reporting).
|
||||||
|
lookup: Discriminated tuple from plugins.get_plugin(); (None, None) if not found.
|
||||||
|
entity_type: Entity type string (e.g. 'cabinets', 'shelves', 'books').
|
||||||
|
entity_id: ID of the entity to operate on.
|
||||||
|
loop: Running event loop for executor dispatch.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dataclasses.asdict() of the updated entity row.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PluginNotFoundError: If lookup is (None, None).
|
||||||
|
InvalidPluginEntityError: If the entity_type is not compatible with the plugin category.
|
||||||
|
PluginTargetMismatchError: If a boundary_detector plugin's target mismatches the entity.
|
||||||
|
"""
|
||||||
|
match lookup:
|
||||||
|
case (None, None):
|
||||||
|
raise PluginNotFoundError(plugin_id)
|
||||||
|
|
||||||
|
case ("boundary_detector", plugin):
|
||||||
|
if entity_type not in ("cabinets", "shelves"):
|
||||||
|
raise InvalidPluginEntityError("boundary_detector", entity_type)
|
||||||
|
if entity_type == "cabinets" and plugin.target != "shelves":
|
||||||
|
raise PluginTargetMismatchError(plugin.plugin_id, "shelves", plugin.target)
|
||||||
|
if entity_type == "shelves" and plugin.target != "books":
|
||||||
|
raise PluginTargetMismatchError(plugin.plugin_id, "books", plugin.target)
|
||||||
|
result = await loop.run_in_executor(None, run_boundary_detector, plugin, entity_type, entity_id)
|
||||||
|
return dataclasses.asdict(result)
|
||||||
|
|
||||||
|
case ("text_recognizer", plugin):
|
||||||
|
if entity_type != "books":
|
||||||
|
raise InvalidPluginEntityError("text_recognizer", entity_type)
|
||||||
|
result = await loop.run_in_executor(None, run_text_recognizer, plugin, entity_id)
|
||||||
|
for ap in plugin_registry.get_auto_queue("archive_searchers"):
|
||||||
|
loop.run_in_executor(archive_executor, run_archive_searcher_bg, ap, entity_id)
|
||||||
|
return dataclasses.asdict(result)
|
||||||
|
|
||||||
|
case ("book_identifier", plugin):
|
||||||
|
if entity_type != "books":
|
||||||
|
raise InvalidPluginEntityError("book_identifier", entity_type)
|
||||||
|
result = await loop.run_in_executor(None, run_book_identifier, plugin, entity_id)
|
||||||
|
return dataclasses.asdict(result)
|
||||||
|
|
||||||
|
case ("archive_searcher", plugin):
|
||||||
|
if entity_type != "books":
|
||||||
|
raise InvalidPluginEntityError("archive_searcher", entity_type)
|
||||||
|
result = await loop.run_in_executor(archive_executor, run_archive_searcher, plugin, entity_id)
|
||||||
|
return dataclasses.asdict(result)
|
||||||
52
src/logic/archive.py
Normal file
52
src/logic/archive.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Archive search plugin runner."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import db
|
||||||
|
from errors import BookNotFoundError
|
||||||
|
from models import ArchiveSearcherPlugin, BookRow, CandidateRecord
|
||||||
|
from logic.identification import build_query
|
||||||
|
|
||||||
|
|
||||||
|
def run_archive_searcher(plugin: ArchiveSearcherPlugin, book_id: str) -> BookRow:
|
||||||
|
"""Run an archive search for a book and merge results into the candidates list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin: The archive searcher plugin to execute.
|
||||||
|
book_id: ID of the book to search for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated BookRow after merging search results.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BookNotFoundError: If book_id does not exist.
|
||||||
|
"""
|
||||||
|
with db.transaction() as c:
|
||||||
|
book = db.get_book(c, book_id)
|
||||||
|
if not book:
|
||||||
|
raise BookNotFoundError(book_id)
|
||||||
|
query = build_query(book)
|
||||||
|
if not query:
|
||||||
|
return book
|
||||||
|
results: list[CandidateRecord] = plugin.search(query)
|
||||||
|
existing: list[CandidateRecord] = json.loads(book.candidates or "[]")
|
||||||
|
existing = [cd for cd in existing if cd.get("source") != plugin.plugin_id]
|
||||||
|
existing.extend(results)
|
||||||
|
db.set_book_candidates(c, book_id, json.dumps(existing))
|
||||||
|
updated = db.get_book(c, book_id)
|
||||||
|
if not updated:
|
||||||
|
raise BookNotFoundError(book_id)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def run_archive_searcher_bg(plugin: ArchiveSearcherPlugin, book_id: str) -> None:
|
||||||
|
"""Run an archive search in fire-and-forget mode; all exceptions are suppressed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin: The archive searcher plugin to execute.
|
||||||
|
book_id: ID of the book to search for.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
run_archive_searcher(plugin, book_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
66
src/logic/batch.py
Normal file
66
src/logic/batch.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""Batch processing pipeline: auto-queue text recognition and archive search."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
import db
|
||||||
|
import plugins as plugin_registry
|
||||||
|
from models import BatchState
|
||||||
|
from logic.identification import run_text_recognizer
|
||||||
|
from logic.archive import run_archive_searcher
|
||||||
|
|
||||||
|
batch_state: BatchState = {"running": False, "total": 0, "done": 0, "errors": 0, "current": ""}
|
||||||
|
batch_executor = ThreadPoolExecutor(max_workers=1)
|
||||||
|
archive_executor = ThreadPoolExecutor(max_workers=8)
|
||||||
|
|
||||||
|
|
||||||
|
def process_book_sync(book_id: str) -> None:
|
||||||
|
"""Run the full auto-queue pipeline for a single book synchronously.
|
||||||
|
|
||||||
|
Runs all auto_queue text_recognizers (if book has no raw_text yet), then all
|
||||||
|
auto_queue archive_searchers. Exceptions from individual plugins are suppressed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
book_id: ID of the book to process.
|
||||||
|
"""
|
||||||
|
with db.connection() as c:
|
||||||
|
book = db.get_book(c, book_id)
|
||||||
|
has_text = bool((book.raw_text if book else "").strip())
|
||||||
|
|
||||||
|
if not has_text:
|
||||||
|
for p in plugin_registry.get_auto_queue("text_recognizers"):
|
||||||
|
try:
|
||||||
|
run_text_recognizer(p, book_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for p in plugin_registry.get_auto_queue("archive_searchers"):
|
||||||
|
try:
|
||||||
|
run_archive_searcher(p, book_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def run_batch(book_ids: list[str]) -> None:
|
||||||
|
"""Process a list of books through the auto-queue pipeline sequentially.
|
||||||
|
|
||||||
|
Updates batch_state throughout execution. Exceptions from individual books
|
||||||
|
are counted in batch_state['errors'] and do not abort the run.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
book_ids: List of book IDs to process.
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
batch_state["running"] = True
|
||||||
|
batch_state["total"] = len(book_ids)
|
||||||
|
batch_state["done"] = 0
|
||||||
|
batch_state["errors"] = 0
|
||||||
|
for bid in book_ids:
|
||||||
|
batch_state["current"] = bid
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(batch_executor, process_book_sync, bid)
|
||||||
|
except Exception:
|
||||||
|
batch_state["errors"] += 1
|
||||||
|
batch_state["done"] += 1
|
||||||
|
batch_state["running"] = False
|
||||||
|
batch_state["current"] = ""
|
||||||
147
src/logic/boundaries.py
Normal file
147
src/logic/boundaries.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"""Boundary calculation and image source resolution for shelves and books."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import db
|
||||||
|
from errors import (
|
||||||
|
BookNotFoundError,
|
||||||
|
CabinetNotFoundError,
|
||||||
|
NoCabinetPhotoError,
|
||||||
|
NoShelfImageError,
|
||||||
|
ShelfNotFoundError,
|
||||||
|
)
|
||||||
|
from files import IMAGES_DIR
|
||||||
|
from logic.images import prep_img_b64
|
||||||
|
from models import BoundaryDetectorPlugin, CabinetRow, ShelfRow
|
||||||
|
|
||||||
|
|
||||||
|
def bounds_for_index(boundaries_json: str | None, idx: int) -> tuple[float, float]:
|
||||||
|
"""Return (start, end) 0-1 fractions for the segment at a 0-based index.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
boundaries_json: JSON-encoded list of interior boundary fractions, or None.
|
||||||
|
idx: 0-based segment index. Out-of-range values clamp to the last segment.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(start, end) fractions in [0, 1].
|
||||||
|
"""
|
||||||
|
bounds: list[float] = json.loads(boundaries_json) if boundaries_json else []
|
||||||
|
full = [0.0] + bounds + [1.0]
|
||||||
|
if idx + 1 >= len(full):
|
||||||
|
return (full[-2] if len(full) >= 2 else 0.0, 1.0)
|
||||||
|
return (full[idx], full[idx + 1])
|
||||||
|
|
||||||
|
|
||||||
|
def shelf_source(c: sqlite3.Connection, shelf_id: str) -> tuple[Path, tuple[float, float, float, float] | None]:
|
||||||
|
"""Return the image path and optional crop fractions for a shelf's display image.
|
||||||
|
|
||||||
|
Uses the shelf's own override photo if present; otherwise derives a crop from
|
||||||
|
the parent cabinet's photo using the shelf's positional rank and shelf boundaries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
c: Open database connection.
|
||||||
|
shelf_id: ID of the shelf to resolve.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(image_path, crop_frac_or_None) — crop is None when using the shelf's own photo.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ShelfNotFoundError: If shelf_id does not exist.
|
||||||
|
NoShelfImageError: If the shelf has no override photo and the cabinet has no photo.
|
||||||
|
"""
|
||||||
|
shelf = db.get_shelf(c, shelf_id)
|
||||||
|
if not shelf:
|
||||||
|
raise ShelfNotFoundError(shelf_id)
|
||||||
|
if shelf.photo_filename:
|
||||||
|
return IMAGES_DIR / shelf.photo_filename, None
|
||||||
|
cab = db.get_cabinet(c, shelf.cabinet_id)
|
||||||
|
if not cab or not cab.photo_filename:
|
||||||
|
raise NoShelfImageError(shelf_id, shelf.cabinet_id)
|
||||||
|
idx = db.get_shelf_rank(c, shelf_id)
|
||||||
|
y0, y1 = bounds_for_index(cab.shelf_boundaries, idx)
|
||||||
|
return IMAGES_DIR / cab.photo_filename, (0.0, y0, 1.0, y1)
|
||||||
|
|
||||||
|
|
||||||
|
def book_spine_source(c: sqlite3.Connection, book_id: str) -> tuple[Path, tuple[float, float, float, float]]:
|
||||||
|
"""Return the image path and crop fractions for a book's spine image.
|
||||||
|
|
||||||
|
Composes the shelf's image source with the book's horizontal position within
|
||||||
|
the shelf's book boundaries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
c: Open database connection.
|
||||||
|
book_id: ID of the book to resolve.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(image_path, crop_frac) — always returns a crop (never None).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BookNotFoundError: If book_id does not exist.
|
||||||
|
ShelfNotFoundError: If the book's parent shelf does not exist.
|
||||||
|
NoShelfImageError: If no image is available for the parent shelf.
|
||||||
|
"""
|
||||||
|
book = db.get_book(c, book_id)
|
||||||
|
if not book:
|
||||||
|
raise BookNotFoundError(book_id)
|
||||||
|
shelf = db.get_shelf(c, book.shelf_id)
|
||||||
|
if not shelf:
|
||||||
|
raise ShelfNotFoundError(book.shelf_id)
|
||||||
|
|
||||||
|
base_path, base_crop = shelf_source(c, book.shelf_id)
|
||||||
|
idx = db.get_book_rank(c, book_id)
|
||||||
|
x0, x1 = bounds_for_index(shelf.book_boundaries, idx)
|
||||||
|
|
||||||
|
if base_crop is None:
|
||||||
|
return base_path, (x0, 0.0, x1, 1.0)
|
||||||
|
else:
|
||||||
|
_, y0, _, y1 = base_crop
|
||||||
|
return base_path, (x0, y0, x1, y1)
|
||||||
|
|
||||||
|
|
||||||
|
def run_boundary_detector(plugin: BoundaryDetectorPlugin, entity_type: str, entity_id: str) -> CabinetRow | ShelfRow:
|
||||||
|
"""Run boundary detection on a cabinet or shelf and persist the result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin: The boundary detector plugin to execute.
|
||||||
|
entity_type: Either 'cabinets' or 'shelves'.
|
||||||
|
entity_id: ID of the entity to process.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated CabinetRow (if entity_type == 'cabinets') or ShelfRow (if 'shelves').
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CabinetNotFoundError: If entity_type is 'cabinets' and the cabinet does not exist.
|
||||||
|
NoCabinetPhotoError: If entity_type is 'cabinets' and the cabinet has no photo.
|
||||||
|
ShelfNotFoundError: If entity_type is 'shelves' and the shelf does not exist.
|
||||||
|
NoShelfImageError: If entity_type is 'shelves' and no image is available for the shelf.
|
||||||
|
"""
|
||||||
|
with db.transaction() as c:
|
||||||
|
if entity_type == "cabinets":
|
||||||
|
entity = db.get_cabinet(c, entity_id)
|
||||||
|
if not entity:
|
||||||
|
raise CabinetNotFoundError(entity_id)
|
||||||
|
if not entity.photo_filename:
|
||||||
|
raise NoCabinetPhotoError(entity_id)
|
||||||
|
b64, mt = prep_img_b64(IMAGES_DIR / entity.photo_filename, max_px=plugin.max_image_px)
|
||||||
|
result = plugin.detect(b64, mt)
|
||||||
|
boundaries: list[float] = list(result.get("boundaries") or [])
|
||||||
|
db.set_ai_shelf_boundaries(c, entity_id, plugin.plugin_id, boundaries)
|
||||||
|
updated_cab = db.get_cabinet(c, entity_id)
|
||||||
|
if not updated_cab:
|
||||||
|
raise CabinetNotFoundError(entity_id)
|
||||||
|
return updated_cab
|
||||||
|
else: # shelves
|
||||||
|
entity_s = db.get_shelf(c, entity_id)
|
||||||
|
if not entity_s:
|
||||||
|
raise ShelfNotFoundError(entity_id)
|
||||||
|
path, crop = shelf_source(c, entity_id)
|
||||||
|
b64, mt = prep_img_b64(path, crop, max_px=plugin.max_image_px)
|
||||||
|
result = plugin.detect(b64, mt)
|
||||||
|
boundaries = list(result.get("boundaries") or [])
|
||||||
|
db.set_ai_book_boundaries(c, entity_id, plugin.plugin_id, boundaries)
|
||||||
|
updated_shelf = db.get_shelf(c, entity_id)
|
||||||
|
if not updated_shelf:
|
||||||
|
raise ShelfNotFoundError(entity_id)
|
||||||
|
return updated_shelf
|
||||||
245
src/logic/identification.py
Normal file
245
src/logic/identification.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"""Book identification logic: status computation, AI result application, plugin runners."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import db
|
||||||
|
from db import now
|
||||||
|
from errors import BookNotFoundError, NoRawTextError
|
||||||
|
from logic.boundaries import book_spine_source
|
||||||
|
from logic.images import prep_img_b64
|
||||||
|
from models import (
|
||||||
|
AIIdentifyResult,
|
||||||
|
BookIdentifierPlugin,
|
||||||
|
BookRow,
|
||||||
|
CandidateRecord,
|
||||||
|
TextRecognizeResult,
|
||||||
|
TextRecognizerPlugin,
|
||||||
|
)
|
||||||
|
|
||||||
|
AI_FIELDS = ("title", "author", "year", "isbn", "publisher")
|
||||||
|
_APPROVED_REQUIRED = ("title", "author", "year")
|
||||||
|
|
||||||
|
|
||||||
|
def compute_status(book: BookRow) -> str:
|
||||||
|
"""Return the identification_status string derived from current book field values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
book: The book row to evaluate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
One of 'unidentified', 'ai_identified', or 'user_approved'.
|
||||||
|
"""
|
||||||
|
if not (book.ai_title or "").strip():
|
||||||
|
return "unidentified"
|
||||||
|
filled = all((getattr(book, f) or "").strip() for f in _APPROVED_REQUIRED)
|
||||||
|
no_diff = all(
|
||||||
|
not (getattr(book, f"ai_{f}") or "").strip()
|
||||||
|
or (getattr(book, f) or "").strip() == (getattr(book, f"ai_{f}") or "").strip()
|
||||||
|
for f in AI_FIELDS
|
||||||
|
)
|
||||||
|
return "user_approved" if (filled and no_diff) else "ai_identified"
|
||||||
|
|
||||||
|
|
||||||
|
def build_query(book: BookRow) -> str:
|
||||||
|
"""Build a search query string from the best available candidate fields.
|
||||||
|
|
||||||
|
Prefers the first candidate with a non-empty author+title pair; falls back to
|
||||||
|
AI fields, then raw OCR text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
book: The book row to build a query for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Query string, empty if no usable data is available.
|
||||||
|
"""
|
||||||
|
candidates: list[dict[str, object]] = json.loads(book.candidates or "[]")
|
||||||
|
for c in candidates:
|
||||||
|
q = " ".join(filter(None, [(str(c.get("author") or "")).strip(), (str(c.get("title") or "")).strip()]))
|
||||||
|
if q:
|
||||||
|
return q
|
||||||
|
q = " ".join(filter(None, [(book.ai_author or "").strip(), (book.ai_title or "").strip()]))
|
||||||
|
if q:
|
||||||
|
return q
|
||||||
|
return (book.raw_text or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def save_user_fields(book_id: str, title: str, author: str, year: str, isbn: str, publisher: str, notes: str) -> str:
|
||||||
|
"""Persist user-edited fields and recompute identification status.
|
||||||
|
|
||||||
|
Also sets ai_* fields to match user values so they are treated as approved.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
book_id: ID of the book to update.
|
||||||
|
title: User-provided title.
|
||||||
|
author: User-provided author.
|
||||||
|
year: User-provided year.
|
||||||
|
isbn: User-provided ISBN.
|
||||||
|
publisher: User-provided publisher.
|
||||||
|
notes: User-provided notes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated identification_status string.
|
||||||
|
"""
|
||||||
|
with db.transaction() as c:
|
||||||
|
db.set_user_book_fields(c, book_id, title, author, year, isbn, publisher, notes)
|
||||||
|
book = db.get_book(c, book_id)
|
||||||
|
status = compute_status(book) if book else "unidentified"
|
||||||
|
db.set_book_status(c, book_id, status)
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def dismiss_field(book_id: str, field: str, value: str) -> tuple[str, list[CandidateRecord]]:
|
||||||
|
"""Dismiss a candidate suggestion for a field.
|
||||||
|
|
||||||
|
If value is non-empty: removes matching candidates and reverts ai_field to the
|
||||||
|
user value if it matched. If value is empty: sets ai_field to the current user value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
book_id: ID of the book.
|
||||||
|
field: Field name (one of AI_FIELDS).
|
||||||
|
value: Candidate value to dismiss, or empty string to dismiss the AI suggestion.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(identification_status, updated_candidates).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BookNotFoundError: If book_id does not exist.
|
||||||
|
"""
|
||||||
|
with db.transaction() as c:
|
||||||
|
book = db.get_book(c, book_id)
|
||||||
|
if not book:
|
||||||
|
raise BookNotFoundError(book_id)
|
||||||
|
candidates: list[CandidateRecord] = json.loads(book.candidates or "[]")
|
||||||
|
if value:
|
||||||
|
candidates = [cand for cand in candidates if (str(cand.get(field) or "")).strip() != value]
|
||||||
|
db.set_book_candidates(c, book_id, json.dumps(candidates))
|
||||||
|
if (getattr(book, f"ai_{field}") or "").strip() == value:
|
||||||
|
db.set_book_ai_field(c, book_id, field, str(getattr(book, field) or ""))
|
||||||
|
else:
|
||||||
|
db.set_book_ai_field(c, book_id, field, str(getattr(book, field) or ""))
|
||||||
|
book = db.get_book(c, book_id)
|
||||||
|
status = compute_status(book) if book else "unidentified"
|
||||||
|
db.set_book_status(c, book_id, status)
|
||||||
|
candidates = json.loads(book.candidates or "[]") if book else []
|
||||||
|
return status, candidates
|
||||||
|
|
||||||
|
|
||||||
|
def apply_ai_result(book_id: str, result: AIIdentifyResult, confidence_threshold: float = 0.8) -> None:
|
||||||
|
"""Apply an AI identification result to a book.
|
||||||
|
|
||||||
|
Stores confidence unconditionally; sets ai_* fields only when confidence meets the threshold.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
book_id: ID of the book to update.
|
||||||
|
result: AI identification result dict.
|
||||||
|
confidence_threshold: Minimum confidence to write ai_* fields (default 0.8).
|
||||||
|
"""
|
||||||
|
confidence = float(result.get("confidence") or 0)
|
||||||
|
with db.transaction() as c:
|
||||||
|
db.set_book_confidence(c, book_id, confidence, now())
|
||||||
|
if confidence < confidence_threshold:
|
||||||
|
return
|
||||||
|
db.set_book_ai_fields(
|
||||||
|
c,
|
||||||
|
book_id,
|
||||||
|
result.get("title") or "",
|
||||||
|
result.get("author") or "",
|
||||||
|
result.get("year") or "",
|
||||||
|
result.get("isbn") or "",
|
||||||
|
result.get("publisher") or "",
|
||||||
|
)
|
||||||
|
book = db.get_book(c, book_id)
|
||||||
|
if book:
|
||||||
|
db.set_book_status(c, book_id, compute_status(book))
|
||||||
|
|
||||||
|
|
||||||
|
def run_text_recognizer(plugin: TextRecognizerPlugin, book_id: str) -> BookRow:
|
||||||
|
"""Recognize text from a book spine image and store the result.
|
||||||
|
|
||||||
|
Calls the plugin with the book's spine image, stores raw_text, and merges
|
||||||
|
the result into the candidates list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin: The text recognizer plugin to execute.
|
||||||
|
book_id: ID of the book to process.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated BookRow after storing the result.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BookNotFoundError: If book_id does not exist.
|
||||||
|
"""
|
||||||
|
with db.transaction() as c:
|
||||||
|
book = db.get_book(c, book_id)
|
||||||
|
if not book:
|
||||||
|
raise BookNotFoundError(book_id)
|
||||||
|
spine_path, spine_crop = book_spine_source(c, book_id)
|
||||||
|
b64, mt = prep_img_b64(spine_path, spine_crop, max_px=plugin.max_image_px)
|
||||||
|
result: TextRecognizeResult = plugin.recognize(b64, mt)
|
||||||
|
raw_text = result.get("raw_text") or ""
|
||||||
|
cand: CandidateRecord = {
|
||||||
|
"source": plugin.plugin_id,
|
||||||
|
"title": (result.get("title") or "").strip(),
|
||||||
|
"author": (result.get("author") or "").strip(),
|
||||||
|
"year": (result.get("year") or "").strip(),
|
||||||
|
"publisher": (result.get("publisher") or "").strip(),
|
||||||
|
"isbn": "",
|
||||||
|
}
|
||||||
|
existing: list[CandidateRecord] = json.loads(book.candidates or "[]")
|
||||||
|
existing = [cd for cd in existing if cd.get("source") != plugin.plugin_id]
|
||||||
|
if any([cand["title"], cand["author"], cand["year"], cand["publisher"]]):
|
||||||
|
existing.append(cand)
|
||||||
|
db.set_book_raw_text(c, book_id, raw_text)
|
||||||
|
db.set_book_candidates(c, book_id, json.dumps(existing))
|
||||||
|
updated = db.get_book(c, book_id)
|
||||||
|
if not updated:
|
||||||
|
raise BookNotFoundError(book_id)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def run_book_identifier(plugin: BookIdentifierPlugin, book_id: str) -> BookRow:
|
||||||
|
"""Identify a book using AI and update ai_* fields and candidates.
|
||||||
|
|
||||||
|
Requires raw_text to have been populated by a text recognizer first.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin: The book identifier plugin to execute.
|
||||||
|
book_id: ID of the book to process.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated BookRow after storing the identification result.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BookNotFoundError: If book_id does not exist.
|
||||||
|
NoRawTextError: If the book has no raw_text (text recognizer has not run).
|
||||||
|
"""
|
||||||
|
with db.transaction() as c:
|
||||||
|
book = db.get_book(c, book_id)
|
||||||
|
if not book:
|
||||||
|
raise BookNotFoundError(book_id)
|
||||||
|
raw_text = (book.raw_text or "").strip()
|
||||||
|
if not raw_text:
|
||||||
|
raise NoRawTextError(book_id)
|
||||||
|
result: AIIdentifyResult = plugin.identify(raw_text)
|
||||||
|
# apply_ai_result manages its own transaction
|
||||||
|
apply_ai_result(book_id, result, plugin.confidence_threshold)
|
||||||
|
with db.transaction() as c:
|
||||||
|
book = db.get_book(c, book_id)
|
||||||
|
if not book:
|
||||||
|
raise BookNotFoundError(book_id)
|
||||||
|
cand: CandidateRecord = {
|
||||||
|
"source": plugin.plugin_id,
|
||||||
|
"title": (result.get("title") or "").strip(),
|
||||||
|
"author": (result.get("author") or "").strip(),
|
||||||
|
"year": (result.get("year") or "").strip(),
|
||||||
|
"isbn": (result.get("isbn") or "").strip(),
|
||||||
|
"publisher": (result.get("publisher") or "").strip(),
|
||||||
|
}
|
||||||
|
existing: list[CandidateRecord] = json.loads(book.candidates or "[]")
|
||||||
|
existing = [cd for cd in existing if cd.get("source") != plugin.plugin_id]
|
||||||
|
existing.append(cand)
|
||||||
|
db.set_book_candidates(c, book_id, json.dumps(existing))
|
||||||
|
updated = db.get_book(c, book_id)
|
||||||
|
if not updated:
|
||||||
|
raise BookNotFoundError(book_id)
|
||||||
|
return updated
|
||||||
107
src/logic/images.py
Normal file
107
src/logic/images.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""Image utilities: in-place crop, base64 encoding, and streaming serve."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
|
from errors import ImageFileNotFoundError, ImageReadError
|
||||||
|
|
||||||
|
|
||||||
|
def crop_save(path: Path, x: int, y: int, w: int, h: int) -> None:
|
||||||
|
"""Crop an image file in-place, replacing it with the cropped version.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to the image file.
|
||||||
|
x: Left pixel coordinate of the crop box.
|
||||||
|
y: Top pixel coordinate of the crop box.
|
||||||
|
w: Width of the crop box in pixels.
|
||||||
|
h: Height of the crop box in pixels.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ImageFileNotFoundError: If the file does not exist.
|
||||||
|
ImageReadError: If the file cannot be opened, decoded, or written back.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with Image.open(path) as img:
|
||||||
|
cropped = img.crop((x, y, x + w, y + h))
|
||||||
|
cropped.save(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise ImageFileNotFoundError(path) from None
|
||||||
|
except (OSError, UnidentifiedImageError) as exc:
|
||||||
|
raise ImageReadError(path, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def prep_img_b64(
|
||||||
|
path: Path,
|
||||||
|
crop_frac: tuple[float, float, float, float] | None = None,
|
||||||
|
max_px: int = 1600,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Load an image, optionally crop it, downscale to max_px on the longest side, and encode as base64.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to the source image file.
|
||||||
|
crop_frac: Optional (x0, y0, x1, y1) fractions in [0, 1] to crop before scaling.
|
||||||
|
max_px: Maximum pixel count for the longest dimension (default 1600).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(base64_string, mime_type) — mime_type is always 'image/png'.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ImageFileNotFoundError: If the file does not exist.
|
||||||
|
ImageReadError: If the file cannot be opened or decoded.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with Image.open(path) as img:
|
||||||
|
img = img.convert("RGB")
|
||||||
|
if crop_frac is not None:
|
||||||
|
x0f, y0f, x1f, y1f = crop_frac
|
||||||
|
iw, ih = img.size
|
||||||
|
box = (int(x0f * iw), int(y0f * ih), int(x1f * iw), int(y1f * ih))
|
||||||
|
img = img.crop(box)
|
||||||
|
w, h = img.size
|
||||||
|
if max(w, h) > max_px:
|
||||||
|
size: tuple[int, int] = (max_px, int(h * max_px / w)) if w >= h else (int(w * max_px / h), max_px)
|
||||||
|
img = img.resize(size, Image.Resampling.LANCZOS) # pyright: ignore[reportUnknownMemberType]
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||||
|
return b64, "image/png"
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise ImageFileNotFoundError(path) from None
|
||||||
|
except (OSError, UnidentifiedImageError) as exc:
|
||||||
|
raise ImageReadError(path, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def serve_crop(path: Path, crop_frac: tuple[float, float, float, float] | None) -> StreamingResponse:
|
||||||
|
"""Serve an image (optionally cropped) as a JPEG streaming HTTP response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to the source image file.
|
||||||
|
crop_frac: Optional (x0, y0, x1, y1) fractions in [0, 1] to crop before serving.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StreamingResponse with media_type 'image/jpeg'.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ImageFileNotFoundError: If the file does not exist.
|
||||||
|
ImageReadError: If the file cannot be opened or decoded.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with Image.open(path) as img:
|
||||||
|
img = img.convert("RGB")
|
||||||
|
if crop_frac is not None:
|
||||||
|
x0f, y0f, x1f, y1f = crop_frac
|
||||||
|
iw, ih = img.size
|
||||||
|
box = (int(x0f * iw), int(y0f * ih), int(x1f * iw), int(y1f * ih))
|
||||||
|
img = img.crop(box)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="JPEG", quality=90)
|
||||||
|
buf.seek(0)
|
||||||
|
return StreamingResponse(io.BytesIO(buf.getvalue()), media_type="image/jpeg")
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise ImageFileNotFoundError(path) from None
|
||||||
|
except (OSError, UnidentifiedImageError) as exc:
|
||||||
|
raise ImageReadError(path, str(exc)) from exc
|
||||||
241
src/models.py
Normal file
241
src/models.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""Shared types: entity dataclasses, API payload dataclasses, plugin Protocols."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Literal, Protocol, TypedDict
|
||||||
|
|
||||||
|
# ── AI plugin result shapes ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class BoundaryDetectResult(TypedDict, total=False):
|
||||||
|
boundaries: list[float]
|
||||||
|
confidence: float
|
||||||
|
|
||||||
|
|
||||||
|
class TextRecognizeResult(TypedDict, total=False):
|
||||||
|
raw_text: str
|
||||||
|
title: str
|
||||||
|
author: str
|
||||||
|
year: str
|
||||||
|
publisher: str
|
||||||
|
other: str
|
||||||
|
|
||||||
|
|
||||||
|
class AIIdentifyResult(TypedDict, total=False):
|
||||||
|
title: str
|
||||||
|
author: str
|
||||||
|
year: str
|
||||||
|
isbn: str
|
||||||
|
publisher: str
|
||||||
|
confidence: float
|
||||||
|
|
||||||
|
|
||||||
|
# ── Candidate + AI config ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateRecord(TypedDict):
|
||||||
|
source: str
|
||||||
|
title: str
|
||||||
|
author: str
|
||||||
|
year: str
|
||||||
|
isbn: str
|
||||||
|
publisher: str
|
||||||
|
|
||||||
|
|
||||||
|
class AIConfig(TypedDict):
|
||||||
|
base_url: str
|
||||||
|
api_key: str
|
||||||
|
model: str
|
||||||
|
max_image_px: int
|
||||||
|
confidence_threshold: float
|
||||||
|
extra_body: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Application state ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class BatchState(TypedDict):
|
||||||
|
running: bool
|
||||||
|
total: int
|
||||||
|
done: int
|
||||||
|
errors: int
|
||||||
|
current: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── Plugin manifest ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _PluginManifestBase(TypedDict):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
category: str
|
||||||
|
auto_queue: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PluginManifestEntry(_PluginManifestBase, total=False):
|
||||||
|
target: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── Plugin Protocols ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class BoundaryDetectorPlugin(Protocol):
|
||||||
|
plugin_id: str
|
||||||
|
name: str
|
||||||
|
auto_queue: bool
|
||||||
|
target: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_image_px(self) -> int: ...
|
||||||
|
|
||||||
|
def detect(self, image_b64: str, image_mime: str) -> BoundaryDetectResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
class TextRecognizerPlugin(Protocol):
|
||||||
|
plugin_id: str
|
||||||
|
name: str
|
||||||
|
auto_queue: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_image_px(self) -> int: ...
|
||||||
|
|
||||||
|
def recognize(self, image_b64: str, image_mime: str) -> TextRecognizeResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
class BookIdentifierPlugin(Protocol):
|
||||||
|
plugin_id: str
|
||||||
|
name: str
|
||||||
|
auto_queue: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def confidence_threshold(self) -> float: ...
|
||||||
|
|
||||||
|
def identify(self, raw_text: str) -> AIIdentifyResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveSearcherPlugin(Protocol):
|
||||||
|
plugin_id: str
|
||||||
|
name: str
|
||||||
|
auto_queue: bool
|
||||||
|
|
||||||
|
def search(self, query: str) -> list[CandidateRecord]: ...
|
||||||
|
|
||||||
|
|
||||||
|
# ── Discriminated union for plugin dispatch ───────────────────────────────────
|
||||||
|
|
||||||
|
BDPluginResult = tuple[Literal["boundary_detector"], BoundaryDetectorPlugin]
|
||||||
|
TRPluginResult = tuple[Literal["text_recognizer"], TextRecognizerPlugin]
|
||||||
|
BIPluginResult = tuple[Literal["book_identifier"], BookIdentifierPlugin]
|
||||||
|
ASPluginResult = tuple[Literal["archive_searcher"], ArchiveSearcherPlugin]
|
||||||
|
NotFoundResult = tuple[None, None]
|
||||||
|
|
||||||
|
PluginLookupResult = BDPluginResult | TRPluginResult | BIPluginResult | ASPluginResult | NotFoundResult
|
||||||
|
|
||||||
|
|
||||||
|
# ── Entity dataclasses (typed DB rows) ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _list_f() -> list[float]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _list_s() -> list[str]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RoomRow:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
position: int
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CabinetRow:
|
||||||
|
id: str
|
||||||
|
room_id: str
|
||||||
|
name: str
|
||||||
|
photo_filename: str | None
|
||||||
|
shelf_boundaries: str | None
|
||||||
|
ai_shelf_boundaries: str | None
|
||||||
|
position: int
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ShelfRow:
|
||||||
|
id: str
|
||||||
|
cabinet_id: str
|
||||||
|
name: str
|
||||||
|
photo_filename: str | None
|
||||||
|
book_boundaries: str | None
|
||||||
|
ai_book_boundaries: str | None
|
||||||
|
position: int
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BookRow:
|
||||||
|
id: str
|
||||||
|
shelf_id: str
|
||||||
|
position: int
|
||||||
|
image_filename: str | None
|
||||||
|
title: str
|
||||||
|
author: str
|
||||||
|
year: str
|
||||||
|
isbn: str
|
||||||
|
publisher: str
|
||||||
|
notes: str
|
||||||
|
raw_text: str
|
||||||
|
ai_title: str
|
||||||
|
ai_author: str
|
||||||
|
ai_year: str
|
||||||
|
ai_isbn: str
|
||||||
|
ai_publisher: str
|
||||||
|
identification_status: str
|
||||||
|
title_confidence: float
|
||||||
|
analyzed_at: str | None
|
||||||
|
created_at: str
|
||||||
|
candidates: str | None
|
||||||
|
|
||||||
|
|
||||||
|
# ── API request payload dataclasses ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UpdateNamePayload:
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UpdateBookPayload:
|
||||||
|
title: str = ""
|
||||||
|
author: str = ""
|
||||||
|
year: str = ""
|
||||||
|
isbn: str = ""
|
||||||
|
publisher: str = ""
|
||||||
|
notes: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BoundariesPayload:
|
||||||
|
boundaries: list[float] = field(default_factory=_list_f)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CropPayload:
|
||||||
|
x: int = 0
|
||||||
|
y: int = 0
|
||||||
|
w: int = 0
|
||||||
|
h: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DismissFieldPayload:
|
||||||
|
field: str = ""
|
||||||
|
value: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReorderPayload:
|
||||||
|
ids: list[str] = field(default_factory=_list_s)
|
||||||
241
src/plugins/__init__.py
Normal file
241
src/plugins/__init__.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""Plugin registry for bookshelf automations.
|
||||||
|
|
||||||
|
Functions are loaded from config at startup via load_plugins().
|
||||||
|
Four categories: boundary_detectors, text_recognizers, book_identifiers, archive_searchers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Literal, overload
|
||||||
|
|
||||||
|
from config import AIFunctionConfig, AppConfig, CredentialConfig, ModelConfig
|
||||||
|
from models import (
|
||||||
|
AIConfig,
|
||||||
|
ASPluginResult,
|
||||||
|
ArchiveSearcherPlugin,
|
||||||
|
BDPluginResult,
|
||||||
|
BIPluginResult,
|
||||||
|
BookIdentifierPlugin,
|
||||||
|
BoundaryDetectorPlugin,
|
||||||
|
NotFoundResult,
|
||||||
|
PluginLookupResult,
|
||||||
|
PluginManifestEntry,
|
||||||
|
TextRecognizerPlugin,
|
||||||
|
TRPluginResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .rate_limiter import RateLimiter
|
||||||
|
|
||||||
|
RATE_LIMITER = RateLimiter()
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Typed per-category registries ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
_boundary_detectors: dict[str, BoundaryDetectorPlugin] = {}
|
||||||
|
_text_recognizers: dict[str, TextRecognizerPlugin] = {}
|
||||||
|
_book_identifiers: dict[str, BookIdentifierPlugin] = {}
|
||||||
|
_archive_searchers: dict[str, ArchiveSearcherPlugin] = {}
|
||||||
|
|
||||||
|
_type_to_class: dict[str, Any] = {} # populated lazily on first call
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_classes() -> dict[str, Any]:
|
||||||
|
if not _type_to_class:
|
||||||
|
from .archives.html_scraper import HtmlScraperPlugin
|
||||||
|
from .archives.openlibrary import OpenLibraryPlugin
|
||||||
|
from .archives.rsl import RSLPlugin
|
||||||
|
from .archives.sru_catalog import SRUCatalogPlugin
|
||||||
|
|
||||||
|
_type_to_class.update(
|
||||||
|
{
|
||||||
|
"openlibrary": OpenLibraryPlugin,
|
||||||
|
"rsl": RSLPlugin,
|
||||||
|
"html_scraper": HtmlScraperPlugin,
|
||||||
|
"sru_catalog": SRUCatalogPlugin,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return _type_to_class
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ai_cfg(model_cfg: ModelConfig, cred_cfg: CredentialConfig, func: AIFunctionConfig) -> AIConfig:
|
||||||
|
"""Assemble runtime AIConfig from the 3-layer config (credentials → models → functions)."""
|
||||||
|
return AIConfig(
|
||||||
|
base_url=cred_cfg.base_url,
|
||||||
|
api_key=cred_cfg.api_key,
|
||||||
|
model=model_cfg.model,
|
||||||
|
max_image_px=func.max_image_px,
|
||||||
|
confidence_threshold=func.confidence_threshold,
|
||||||
|
extra_body=model_cfg.extra_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_plugins(config: AppConfig) -> None:
|
||||||
|
"""Populate the plugin registry from a typed AppConfig."""
|
||||||
|
from .ai_compat import (
|
||||||
|
BookIdentifierPlugin as BIClass,
|
||||||
|
BoundaryDetectorBooksPlugin,
|
||||||
|
BoundaryDetectorShelvesPlugin,
|
||||||
|
TextRecognizerPlugin as TRClass,
|
||||||
|
)
|
||||||
|
|
||||||
|
_boundary_detectors.clear()
|
||||||
|
_text_recognizers.clear()
|
||||||
|
_book_identifiers.clear()
|
||||||
|
_archive_searchers.clear()
|
||||||
|
|
||||||
|
archive_cls = _archive_classes()
|
||||||
|
|
||||||
|
for key, func in config.functions.boundary_detectors.items():
|
||||||
|
if key == "shelves":
|
||||||
|
bd_cls = BoundaryDetectorShelvesPlugin
|
||||||
|
elif key == "books":
|
||||||
|
bd_cls = BoundaryDetectorBooksPlugin
|
||||||
|
else:
|
||||||
|
_logger.warning("Unknown boundary_detector key %r — must be 'shelves' or 'books'", key)
|
||||||
|
continue
|
||||||
|
m = config.models.get(func.model)
|
||||||
|
if m is None:
|
||||||
|
_logger.warning("Skipping boundary_detector %r: model %r not found", key, func.model)
|
||||||
|
continue
|
||||||
|
c = config.credentials.get(m.credentials)
|
||||||
|
if c is None:
|
||||||
|
_logger.warning("Skipping boundary_detector %r: credential %r not found", key, m.credentials)
|
||||||
|
continue
|
||||||
|
_boundary_detectors[key] = bd_cls(
|
||||||
|
plugin_id=key,
|
||||||
|
name=func.name or key.replace("_", " ").title(),
|
||||||
|
ai_config=_build_ai_cfg(m, c, func),
|
||||||
|
prompt_text=m.prompt,
|
||||||
|
auto_queue=func.auto_queue,
|
||||||
|
rate_limit_seconds=func.rate_limit_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
for key, func in config.functions.text_recognizers.items():
|
||||||
|
m = config.models.get(func.model)
|
||||||
|
if m is None:
|
||||||
|
_logger.warning("Skipping text_recognizer %r: model %r not found", key, func.model)
|
||||||
|
continue
|
||||||
|
c = config.credentials.get(m.credentials)
|
||||||
|
if c is None:
|
||||||
|
_logger.warning("Skipping text_recognizer %r: credential %r not found", key, m.credentials)
|
||||||
|
continue
|
||||||
|
_text_recognizers[key] = TRClass(
|
||||||
|
plugin_id=key,
|
||||||
|
name=func.name or key.replace("_", " ").title(),
|
||||||
|
ai_config=_build_ai_cfg(m, c, func),
|
||||||
|
prompt_text=m.prompt,
|
||||||
|
auto_queue=func.auto_queue,
|
||||||
|
rate_limit_seconds=func.rate_limit_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
for key, func in config.functions.book_identifiers.items():
|
||||||
|
m = config.models.get(func.model)
|
||||||
|
if m is None:
|
||||||
|
_logger.warning("Skipping book_identifier %r: model %r not found", key, func.model)
|
||||||
|
continue
|
||||||
|
c = config.credentials.get(m.credentials)
|
||||||
|
if c is None:
|
||||||
|
_logger.warning("Skipping book_identifier %r: credential %r not found", key, m.credentials)
|
||||||
|
continue
|
||||||
|
_book_identifiers[key] = BIClass(
|
||||||
|
plugin_id=key,
|
||||||
|
name=func.name or key.replace("_", " ").title(),
|
||||||
|
ai_config=_build_ai_cfg(m, c, func),
|
||||||
|
prompt_text=m.prompt,
|
||||||
|
auto_queue=func.auto_queue,
|
||||||
|
rate_limit_seconds=func.rate_limit_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
for key, func in config.functions.archive_searchers.items():
|
||||||
|
cls = archive_cls.get(func.type)
|
||||||
|
if cls is None:
|
||||||
|
_logger.warning("Skipping archive_searcher %r: unknown type %r", key, func.type)
|
||||||
|
continue
|
||||||
|
_archive_searchers[key] = cls(
|
||||||
|
plugin_id=key,
|
||||||
|
name=func.name or key.replace("_", " ").title(),
|
||||||
|
rate_limiter=RATE_LIMITER,
|
||||||
|
rate_limit_seconds=func.rate_limit_seconds,
|
||||||
|
auto_queue=func.auto_queue,
|
||||||
|
timeout=func.timeout,
|
||||||
|
config=func.config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest() -> list[PluginManifestEntry]:
|
||||||
|
"""Return list of plugin descriptors for the frontend."""
|
||||||
|
result: list[PluginManifestEntry] = []
|
||||||
|
for pid, p in _boundary_detectors.items():
|
||||||
|
result.append(
|
||||||
|
PluginManifestEntry(
|
||||||
|
id=pid, name=p.name, category="boundary_detector", auto_queue=p.auto_queue, target=p.target
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for pid, p in _text_recognizers.items():
|
||||||
|
result.append(PluginManifestEntry(id=pid, name=p.name, category="text_recognizer", auto_queue=p.auto_queue))
|
||||||
|
for pid, p in _book_identifiers.items():
|
||||||
|
result.append(PluginManifestEntry(id=pid, name=p.name, category="book_identifier", auto_queue=p.auto_queue))
|
||||||
|
for pid, p in _archive_searchers.items():
|
||||||
|
result.append(PluginManifestEntry(id=pid, name=p.name, category="archive_searcher", auto_queue=p.auto_queue))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_auto_queue(category: Literal["boundary_detectors", "boundary_detector"]) -> list[BoundaryDetectorPlugin]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_auto_queue(category: Literal["text_recognizers", "text_recognizer"]) -> list[TextRecognizerPlugin]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_auto_queue(category: Literal["book_identifiers", "book_identifier"]) -> list[BookIdentifierPlugin]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_auto_queue(category: Literal["archive_searchers", "archive_searcher"]) -> list[ArchiveSearcherPlugin]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_auto_queue(
|
||||||
|
category: str,
|
||||||
|
) -> (
|
||||||
|
list[BoundaryDetectorPlugin] | list[TextRecognizerPlugin] | list[BookIdentifierPlugin] | list[ArchiveSearcherPlugin]
|
||||||
|
): ...
|
||||||
|
|
||||||
|
|
||||||
|
def get_auto_queue(
|
||||||
|
category: str,
|
||||||
|
) -> (
|
||||||
|
list[BoundaryDetectorPlugin] | list[TextRecognizerPlugin] | list[BookIdentifierPlugin] | list[ArchiveSearcherPlugin]
|
||||||
|
):
|
||||||
|
"""Return plugin instances for a category that have auto_queue=True."""
|
||||||
|
match category:
|
||||||
|
case "boundary_detectors" | "boundary_detector":
|
||||||
|
return [p for p in _boundary_detectors.values() if p.auto_queue]
|
||||||
|
case "text_recognizers" | "text_recognizer":
|
||||||
|
return [p for p in _text_recognizers.values() if p.auto_queue]
|
||||||
|
case "book_identifiers" | "book_identifier":
|
||||||
|
return [p for p in _book_identifiers.values() if p.auto_queue]
|
||||||
|
case "archive_searchers" | "archive_searcher":
|
||||||
|
return [p for p in _archive_searchers.values() if p.auto_queue]
|
||||||
|
case _:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugin(plugin_id: str) -> PluginLookupResult:
|
||||||
|
"""Find a plugin by ID across all categories. Returns a discriminated (category, plugin) tuple."""
|
||||||
|
if plugin_id in _boundary_detectors:
|
||||||
|
bd: BDPluginResult = ("boundary_detector", _boundary_detectors[plugin_id])
|
||||||
|
return bd
|
||||||
|
if plugin_id in _text_recognizers:
|
||||||
|
tr: TRPluginResult = ("text_recognizer", _text_recognizers[plugin_id])
|
||||||
|
return tr
|
||||||
|
if plugin_id in _book_identifiers:
|
||||||
|
bi: BIPluginResult = ("book_identifier", _book_identifiers[plugin_id])
|
||||||
|
return bi
|
||||||
|
if plugin_id in _archive_searchers:
|
||||||
|
asr: ASPluginResult = ("archive_searcher", _archive_searchers[plugin_id])
|
||||||
|
return asr
|
||||||
|
nf: NotFoundResult = (None, None)
|
||||||
|
return nf
|
||||||
21
src/plugins/ai_compat/__init__.py
Normal file
21
src/plugins/ai_compat/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""AI plugin classes using OpenAI-compatible APIs.
|
||||||
|
|
||||||
|
Submodules:
|
||||||
|
_client.py — shared _AIClient + HTTP helpers (private)
|
||||||
|
boundary_detector_shelves.py — BoundaryDetectorShelvesPlugin (cabinet → shelf bounds)
|
||||||
|
boundary_detector_books.py — BoundaryDetectorBooksPlugin (shelf → book bounds)
|
||||||
|
text_recognizer.py — TextRecognizerPlugin (spine image → raw text + fields)
|
||||||
|
book_identifier.py — BookIdentifierPlugin (raw text → bibliographic metadata)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .boundary_detector_books import BoundaryDetectorBooksPlugin
|
||||||
|
from .boundary_detector_shelves import BoundaryDetectorShelvesPlugin
|
||||||
|
from .book_identifier import BookIdentifierPlugin
|
||||||
|
from .text_recognizer import TextRecognizerPlugin
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BoundaryDetectorShelvesPlugin",
|
||||||
|
"BoundaryDetectorBooksPlugin",
|
||||||
|
"TextRecognizerPlugin",
|
||||||
|
"BookIdentifierPlugin",
|
||||||
|
]
|
||||||
94
src/plugins/ai_compat/_client.py
Normal file
94
src/plugins/ai_compat/_client.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""Internal OpenAI-compatible HTTP client shared by all AI plugins.
|
||||||
|
|
||||||
|
Caches openai.OpenAI instances per (base_url, api_key) to avoid re-creating on each call.
|
||||||
|
AIClient wraps the raw API call: fills prompt template, encodes images, parses JSON response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from string import Template
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import openai
|
||||||
|
from openai.types.chat import ChatCompletionMessageParam
|
||||||
|
from openai.types.chat.chat_completion_content_part_image_param import (
|
||||||
|
ChatCompletionContentPartImageParam,
|
||||||
|
ImageURL,
|
||||||
|
)
|
||||||
|
from openai.types.chat.chat_completion_content_part_text_param import ChatCompletionContentPartTextParam
|
||||||
|
|
||||||
|
from models import AIConfig
|
||||||
|
|
||||||
|
# Module-level cache of openai.OpenAI instances keyed by (base_url, api_key)
|
||||||
|
_clients: dict[tuple[str, str], openai.OpenAI] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client(base_url: str, api_key: str) -> openai.OpenAI:
|
||||||
|
key = (base_url, api_key)
|
||||||
|
if key not in _clients:
|
||||||
|
_clients[key] = openai.OpenAI(base_url=base_url, api_key=api_key)
|
||||||
|
return _clients[key]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json(text: str) -> dict[str, Any]:
|
||||||
|
"""Extract and parse the first JSON object found in text.
|
||||||
|
|
||||||
|
Raises ValueError if no JSON object is found or the JSON is malformed.
|
||||||
|
"""
|
||||||
|
text = text.strip()
|
||||||
|
m = re.search(r"\{.*\}", text, re.DOTALL)
|
||||||
|
if not m:
|
||||||
|
raise ValueError(f"No JSON object found in AI response: {text[:200]!r}")
|
||||||
|
try:
|
||||||
|
result = json.loads(m.group())
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(f"Failed to parse AI response as JSON: {exc}") from exc
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise ValueError(f"Expected JSON object, got {type(result).__name__}")
|
||||||
|
return cast(dict[str, Any], result)
|
||||||
|
|
||||||
|
|
||||||
|
ContentPart = ChatCompletionContentPartImageParam | ChatCompletionContentPartTextParam
|
||||||
|
|
||||||
|
|
||||||
|
class AIClient:
|
||||||
|
"""AI client bound to a specific provider config and output format.
|
||||||
|
|
||||||
|
cfg must contain: base_url, api_key, model, max_image_px, confidence_threshold.
|
||||||
|
output_format is the hardcoded JSON schema string injected as ${OUTPUT_FORMAT}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cfg: AIConfig, output_format: str):
|
||||||
|
self.cfg = cfg
|
||||||
|
self.output_format = output_format
|
||||||
|
|
||||||
|
def call(
|
||||||
|
self,
|
||||||
|
prompt_template: str,
|
||||||
|
images: list[tuple[str, str]],
|
||||||
|
text_vars: dict[str, str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Substitute template vars, call API with optional images, return parsed JSON.
|
||||||
|
|
||||||
|
images: list of (base64_str, mime_type) tuples.
|
||||||
|
text_vars: extra ${KEY} substitutions beyond ${OUTPUT_FORMAT}.
|
||||||
|
"""
|
||||||
|
vars_: dict[str, str] = {"OUTPUT_FORMAT": self.output_format}
|
||||||
|
if text_vars:
|
||||||
|
vars_.update(text_vars)
|
||||||
|
prompt = Template(prompt_template).safe_substitute(vars_)
|
||||||
|
client = _get_client(self.cfg["base_url"], self.cfg["api_key"])
|
||||||
|
parts: list[ContentPart] = [
|
||||||
|
ChatCompletionContentPartImageParam(
|
||||||
|
type="image_url",
|
||||||
|
image_url=ImageURL(url=f"data:{mt};base64,{b64}"),
|
||||||
|
)
|
||||||
|
for b64, mt in images
|
||||||
|
]
|
||||||
|
parts.append(ChatCompletionContentPartTextParam(type="text", text=prompt))
|
||||||
|
messages: list[ChatCompletionMessageParam] = [{"role": "user", "content": parts}]
|
||||||
|
r = client.chat.completions.create(
|
||||||
|
model=self.cfg["model"], max_tokens=2048, messages=messages, extra_body=self.cfg["extra_body"]
|
||||||
|
)
|
||||||
|
raw = r.choices[0].message.content or ""
|
||||||
|
return _parse_json(raw)
|
||||||
56
src/plugins/ai_compat/book_identifier.py
Normal file
56
src/plugins/ai_compat/book_identifier.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Book identifier plugin — raw spine text → bibliographic metadata.
|
||||||
|
|
||||||
|
Input: raw_text string (from text_recognizer).
|
||||||
|
Output: {"title": "...", "author": "...", "year": "...", "isbn": "...",
|
||||||
|
"publisher": "...", "confidence": 0.95}
|
||||||
|
confidence — float 0-1; results below confidence_threshold are discarded by logic.py.
|
||||||
|
Result added to books.candidates and books.ai_* fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from models import AIConfig, AIIdentifyResult
|
||||||
|
|
||||||
|
from ._client import AIClient
|
||||||
|
|
||||||
|
|
||||||
|
class BookIdentifierPlugin:
|
||||||
|
"""Identifies a book from spine text using a VLM with web-search capability."""
|
||||||
|
|
||||||
|
category = "book_identifiers"
|
||||||
|
OUTPUT_FORMAT = (
|
||||||
|
'{"title": "...", "author": "...", "year": "...", ' '"isbn": "...", "publisher": "...", "confidence": 0.95}'
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
name: str,
|
||||||
|
ai_config: AIConfig,
|
||||||
|
prompt_text: str,
|
||||||
|
auto_queue: bool,
|
||||||
|
rate_limit_seconds: float,
|
||||||
|
):
|
||||||
|
self.plugin_id = plugin_id
|
||||||
|
self.name = name
|
||||||
|
self.auto_queue = auto_queue
|
||||||
|
self.rate_limit_seconds = rate_limit_seconds
|
||||||
|
self._client = AIClient(ai_config, self.OUTPUT_FORMAT)
|
||||||
|
self._prompt_text = prompt_text
|
||||||
|
|
||||||
|
def identify(self, raw_text: str) -> AIIdentifyResult:
|
||||||
|
"""Returns AIIdentifyResult with title/author/year/isbn/publisher/confidence."""
|
||||||
|
raw = self._client.call(self._prompt_text, [], text_vars={"RAW_TEXT": raw_text})
|
||||||
|
result = AIIdentifyResult(
|
||||||
|
title=str(raw.get("title") or ""),
|
||||||
|
author=str(raw.get("author") or ""),
|
||||||
|
year=str(raw.get("year") or ""),
|
||||||
|
isbn=str(raw.get("isbn") or ""),
|
||||||
|
publisher=str(raw.get("publisher") or ""),
|
||||||
|
)
|
||||||
|
conf = raw.get("confidence")
|
||||||
|
if conf is not None:
|
||||||
|
result["confidence"] = float(conf)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@property
|
||||||
|
def confidence_threshold(self) -> float:
|
||||||
|
return self._client.cfg["confidence_threshold"]
|
||||||
46
src/plugins/ai_compat/boundary_detector_books.py
Normal file
46
src/plugins/ai_compat/boundary_detector_books.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Boundary detector plugin for book spine detection.
|
||||||
|
|
||||||
|
Input: shelf image (full or cropped from cabinet photo).
|
||||||
|
Output: {"boundaries": [x0, x1, ...]}
|
||||||
|
boundaries — interior x-fractions (0=left, 1=right), excluding 0 and 1.
|
||||||
|
Results stored in shelves.ai_book_boundaries[plugin_id].
|
||||||
|
"""
|
||||||
|
|
||||||
|
from models import AIConfig, BoundaryDetectResult
|
||||||
|
|
||||||
|
from ._client import AIClient
|
||||||
|
|
||||||
|
|
||||||
|
class BoundaryDetectorBooksPlugin:
|
||||||
|
"""Detects vertical book-spine boundaries in a shelf image using a VLM."""
|
||||||
|
|
||||||
|
category = "boundary_detectors"
|
||||||
|
target = "books" # operates on shelf images; stored in ai_book_boundaries
|
||||||
|
OUTPUT_FORMAT = '{"boundaries": [0.08, 0.16, 0.24, 0.32]}'
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
name: str,
|
||||||
|
ai_config: AIConfig,
|
||||||
|
prompt_text: str,
|
||||||
|
auto_queue: bool,
|
||||||
|
rate_limit_seconds: float,
|
||||||
|
):
|
||||||
|
self.plugin_id = plugin_id
|
||||||
|
self.name = name
|
||||||
|
self.auto_queue = auto_queue
|
||||||
|
self.rate_limit_seconds = rate_limit_seconds
|
||||||
|
self._client = AIClient(ai_config, self.OUTPUT_FORMAT)
|
||||||
|
self._prompt_text = prompt_text
|
||||||
|
|
||||||
|
def detect(self, image_b64: str, image_mime: str) -> BoundaryDetectResult:
|
||||||
|
"""Returns BoundaryDetectResult with 'boundaries' (list[float])."""
|
||||||
|
raw = self._client.call(self._prompt_text, [(image_b64, image_mime)])
|
||||||
|
raw_bounds: list[object] = raw.get("boundaries") or []
|
||||||
|
boundaries: list[float] = [float(b) for b in raw_bounds if isinstance(b, (int, float))]
|
||||||
|
return BoundaryDetectResult(boundaries=boundaries)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_image_px(self) -> int:
|
||||||
|
return self._client.cfg["max_image_px"]
|
||||||
51
src/plugins/ai_compat/boundary_detector_shelves.py
Normal file
51
src/plugins/ai_compat/boundary_detector_shelves.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Boundary detector plugin for shelf detection.
|
||||||
|
|
||||||
|
Input: cabinet photo (full image).
|
||||||
|
Output: {"boundaries": [y0, y1, ...], "confidence": 0.x}
|
||||||
|
boundaries — interior y-fractions (0=top, 1=bottom), excluding 0 and 1.
|
||||||
|
confidence — optional float 0-1.
|
||||||
|
Results stored in cabinets.ai_shelf_boundaries[plugin_id].
|
||||||
|
"""
|
||||||
|
|
||||||
|
from models import AIConfig, BoundaryDetectResult
|
||||||
|
|
||||||
|
from ._client import AIClient
|
||||||
|
|
||||||
|
|
||||||
|
class BoundaryDetectorShelvesPlugin:
|
||||||
|
"""Detects horizontal shelf boundaries in a cabinet photo using a VLM."""
|
||||||
|
|
||||||
|
category = "boundary_detectors"
|
||||||
|
target = "shelves" # operates on cabinet images; stored in ai_shelf_boundaries
|
||||||
|
OUTPUT_FORMAT = '{"boundaries": [0.24, 0.48, 0.72], "confidence": 0.92}'
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
name: str,
|
||||||
|
ai_config: AIConfig,
|
||||||
|
prompt_text: str,
|
||||||
|
auto_queue: bool,
|
||||||
|
rate_limit_seconds: float,
|
||||||
|
):
|
||||||
|
self.plugin_id = plugin_id
|
||||||
|
self.name = name
|
||||||
|
self.auto_queue = auto_queue
|
||||||
|
self.rate_limit_seconds = rate_limit_seconds
|
||||||
|
self._client = AIClient(ai_config, self.OUTPUT_FORMAT)
|
||||||
|
self._prompt_text = prompt_text
|
||||||
|
|
||||||
|
def detect(self, image_b64: str, image_mime: str) -> BoundaryDetectResult:
|
||||||
|
"""Returns BoundaryDetectResult with 'boundaries' and optionally 'confidence'."""
|
||||||
|
raw = self._client.call(self._prompt_text, [(image_b64, image_mime)])
|
||||||
|
raw_bounds: list[object] = raw.get("boundaries") or []
|
||||||
|
boundaries: list[float] = [float(b) for b in raw_bounds if isinstance(b, (int, float))]
|
||||||
|
result = BoundaryDetectResult(boundaries=boundaries)
|
||||||
|
conf = raw.get("confidence")
|
||||||
|
if conf is not None:
|
||||||
|
result["confidence"] = float(conf)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_image_px(self) -> int:
|
||||||
|
return self._client.cfg["max_image_px"]
|
||||||
56
src/plugins/ai_compat/text_recognizer.py
Normal file
56
src/plugins/ai_compat/text_recognizer.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Text recognizer plugin — spine image → raw text + structured fields.
|
||||||
|
|
||||||
|
Input: book spine image.
|
||||||
|
Output: {"raw_text": "...", "title": "...", "author": "...", "year": "...",
|
||||||
|
"publisher": "...", "other": "..."}
|
||||||
|
raw_text — all visible text verbatim, line-break separated.
|
||||||
|
other fields — VLM interpretation of raw_text.
|
||||||
|
Result added to books.candidates and books.raw_text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from models import AIConfig, TextRecognizeResult
|
||||||
|
|
||||||
|
from ._client import AIClient
|
||||||
|
|
||||||
|
|
||||||
|
class TextRecognizerPlugin:
|
||||||
|
"""Reads text from a book spine image using a VLM."""
|
||||||
|
|
||||||
|
category = "text_recognizers"
|
||||||
|
OUTPUT_FORMAT = (
|
||||||
|
'{"raw_text": "The Great Gatsby\\nF. Scott Fitzgerald\\nScribner", '
|
||||||
|
'"title": "The Great Gatsby", "author": "F. Scott Fitzgerald", '
|
||||||
|
'"year": "", "publisher": "Scribner", "other": ""}'
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
name: str,
|
||||||
|
ai_config: AIConfig,
|
||||||
|
prompt_text: str,
|
||||||
|
auto_queue: bool,
|
||||||
|
rate_limit_seconds: float,
|
||||||
|
):
|
||||||
|
self.plugin_id = plugin_id
|
||||||
|
self.name = name
|
||||||
|
self.auto_queue = auto_queue
|
||||||
|
self.rate_limit_seconds = rate_limit_seconds
|
||||||
|
self._client = AIClient(ai_config, self.OUTPUT_FORMAT)
|
||||||
|
self._prompt_text = prompt_text
|
||||||
|
|
||||||
|
def recognize(self, image_b64: str, image_mime: str) -> TextRecognizeResult:
|
||||||
|
"""Returns TextRecognizeResult with raw_text, title, author, year, publisher, other."""
|
||||||
|
raw = self._client.call(self._prompt_text, [(image_b64, image_mime)])
|
||||||
|
return TextRecognizeResult(
|
||||||
|
raw_text=str(raw.get("raw_text") or ""),
|
||||||
|
title=str(raw.get("title") or ""),
|
||||||
|
author=str(raw.get("author") or ""),
|
||||||
|
year=str(raw.get("year") or ""),
|
||||||
|
publisher=str(raw.get("publisher") or ""),
|
||||||
|
other=str(raw.get("other") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_image_px(self) -> int:
|
||||||
|
return self._client.cfg["max_image_px"]
|
||||||
0
src/plugins/archives/__init__.py
Normal file
0
src/plugins/archives/__init__.py
Normal file
121
src/plugins/archives/html_scraper.py
Normal file
121
src/plugins/archives/html_scraper.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""Config-driven HTML scraper for archive sites (rusneb, alib, shpl, etc.)."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from models import CandidateRecord
|
||||||
|
|
||||||
|
from ..rate_limiter import RateLimiter
|
||||||
|
|
||||||
|
_YEAR_RE = re.compile(r"\b(1[0-9]{3}|20[012][0-9])\b")
|
||||||
|
|
||||||
|
|
||||||
|
def _cls_re(cls_frag: str, min_len: int = 3, max_len: int = 120) -> re.Pattern[str]:
|
||||||
|
return re.compile(rf'class="[^"]*{re.escape(cls_frag)}[^"]*"[^>]*>([^<]{{{min_len},{max_len}}})<')
|
||||||
|
|
||||||
|
|
||||||
|
class HtmlScraperPlugin:
|
||||||
|
"""
|
||||||
|
Config-driven HTML scraper. Supported config keys:
|
||||||
|
url — search URL
|
||||||
|
search_param — query param name
|
||||||
|
extra_params — dict of fixed extra query parameters
|
||||||
|
title_class — CSS class fragment for title elements (class-based strategy)
|
||||||
|
author_class — CSS class fragment for author elements
|
||||||
|
link_href_pattern — href regex to find title <a> links (link strategy, e.g. alib)
|
||||||
|
brief_class — CSS class for brief record rows (brief strategy, e.g. shpl)
|
||||||
|
"""
|
||||||
|
|
||||||
|
category = "archive_searchers"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
name: str,
|
||||||
|
rate_limiter: RateLimiter,
|
||||||
|
rate_limit_seconds: float,
|
||||||
|
auto_queue: bool,
|
||||||
|
timeout: int,
|
||||||
|
config: dict[str, Any],
|
||||||
|
):
|
||||||
|
self.plugin_id = plugin_id
|
||||||
|
self.name = name
|
||||||
|
self._rl = rate_limiter
|
||||||
|
self.rate_limit_seconds = rate_limit_seconds
|
||||||
|
self.auto_queue = auto_queue
|
||||||
|
self.timeout = timeout
|
||||||
|
self.config = config
|
||||||
|
self._domain: str = urlparse(str(config.get("url") or "")).netloc or plugin_id
|
||||||
|
|
||||||
|
def search(self, query: str) -> list[CandidateRecord]:
|
||||||
|
cfg = self.config
|
||||||
|
self._rl.wait_and_record(self._domain, self.rate_limit_seconds)
|
||||||
|
params: dict[str, Any] = dict(cfg.get("extra_params") or {})
|
||||||
|
params[cfg["search_param"]] = query
|
||||||
|
r = httpx.get(
|
||||||
|
cfg["url"],
|
||||||
|
params=params,
|
||||||
|
timeout=self.timeout,
|
||||||
|
headers={"User-Agent": "Mozilla/5.0"},
|
||||||
|
)
|
||||||
|
html = r.text
|
||||||
|
years = _YEAR_RE.findall(html)
|
||||||
|
|
||||||
|
# Strategy: link_href_pattern (alib-style)
|
||||||
|
if "link_href_pattern" in cfg:
|
||||||
|
return self._parse_link(html, years, cfg)
|
||||||
|
|
||||||
|
# Strategy: brief_class (shpl-style)
|
||||||
|
if "brief_class" in cfg:
|
||||||
|
return self._parse_brief(html, years, cfg)
|
||||||
|
|
||||||
|
# Strategy: title_class + author_class (rusneb-style)
|
||||||
|
return self._parse_class(html, years, cfg)
|
||||||
|
|
||||||
|
def _parse_class(self, html: str, years: list[str], cfg: dict[str, Any]) -> list[CandidateRecord]:
|
||||||
|
titles = _cls_re(cfg.get("title_class", "title")).findall(html)[:3]
|
||||||
|
authors = _cls_re(cfg.get("author_class", "author"), 3, 80).findall(html)[:3]
|
||||||
|
return [
|
||||||
|
CandidateRecord(
|
||||||
|
source=self.plugin_id,
|
||||||
|
title=title.strip(),
|
||||||
|
author=authors[i].strip() if i < len(authors) else "",
|
||||||
|
year=years[i] if i < len(years) else "",
|
||||||
|
isbn="",
|
||||||
|
publisher="",
|
||||||
|
)
|
||||||
|
for i, title in enumerate(titles)
|
||||||
|
]
|
||||||
|
|
||||||
|
def _parse_link(self, html: str, years: list[str], cfg: dict[str, Any]) -> list[CandidateRecord]:
|
||||||
|
href_pat = cfg.get("link_href_pattern", r"")
|
||||||
|
titles = re.findall(rf'<a[^>]+href="[^"]*{href_pat}[^"]*"[^>]*>([^<]{{3,120}})</a>', html)[:3]
|
||||||
|
authors = _cls_re(cfg.get("author_class", "author"), 3, 80).findall(html)[:3]
|
||||||
|
return [
|
||||||
|
CandidateRecord(
|
||||||
|
source=self.plugin_id,
|
||||||
|
title=title.strip(),
|
||||||
|
author=authors[i].strip() if i < len(authors) else "",
|
||||||
|
year=years[i] if i < len(years) else "",
|
||||||
|
isbn="",
|
||||||
|
publisher="",
|
||||||
|
)
|
||||||
|
for i, title in enumerate(titles)
|
||||||
|
]
|
||||||
|
|
||||||
|
def _parse_brief(self, html: str, years: list[str], cfg: dict[str, Any]) -> list[CandidateRecord]:
|
||||||
|
titles = _cls_re(cfg.get("brief_class", "brief"), 3, 120).findall(html)[:3]
|
||||||
|
return [
|
||||||
|
CandidateRecord(
|
||||||
|
source=self.plugin_id,
|
||||||
|
title=t.strip(),
|
||||||
|
author="",
|
||||||
|
year=years[i] if i < len(years) else "",
|
||||||
|
isbn="",
|
||||||
|
publisher="",
|
||||||
|
)
|
||||||
|
for i, t in enumerate(titles)
|
||||||
|
]
|
||||||
54
src/plugins/archives/openlibrary.py
Normal file
54
src/plugins/archives/openlibrary.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""OpenLibrary JSON search API plugin (openlibrary.org/search.json)."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from models import CandidateRecord
|
||||||
|
|
||||||
|
from ..rate_limiter import RateLimiter
|
||||||
|
|
||||||
|
_DOMAIN = "openlibrary.org"
|
||||||
|
|
||||||
|
|
||||||
|
class OpenLibraryPlugin:
|
||||||
|
category = "archive_searchers"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
name: str,
|
||||||
|
rate_limiter: RateLimiter,
|
||||||
|
rate_limit_seconds: float,
|
||||||
|
auto_queue: bool,
|
||||||
|
timeout: int,
|
||||||
|
config: dict[str, Any],
|
||||||
|
):
|
||||||
|
self.plugin_id = plugin_id
|
||||||
|
self.name = name
|
||||||
|
self._rl = rate_limiter
|
||||||
|
self.rate_limit_seconds = rate_limit_seconds
|
||||||
|
self.auto_queue = auto_queue
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def search(self, query: str) -> list[CandidateRecord]:
|
||||||
|
self._rl.wait_and_record(_DOMAIN, self.rate_limit_seconds)
|
||||||
|
r = httpx.get(
|
||||||
|
"https://openlibrary.org/search.json",
|
||||||
|
params={"q": query, "limit": 5, "fields": "title,author_name,first_publish_year,isbn,publisher"},
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
docs: list[dict[str, Any]] = r.json().get("docs", [])
|
||||||
|
out: list[CandidateRecord] = []
|
||||||
|
for d in docs[:3]:
|
||||||
|
out.append(
|
||||||
|
CandidateRecord(
|
||||||
|
source=self.plugin_id,
|
||||||
|
title=(str(d.get("title") or "")).strip(),
|
||||||
|
author=", ".join(d.get("author_name") or []).strip(),
|
||||||
|
year=str(d.get("first_publish_year") or "").strip(),
|
||||||
|
isbn=((d.get("isbn") or [""])[0]).strip(),
|
||||||
|
publisher=((d.get("publisher") or [""])[0]).strip(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
59
src/plugins/archives/rsl.py
Normal file
59
src/plugins/archives/rsl.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""RSL (Russian State Library) AJAX JSON search API plugin (search.rsl.ru)."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from models import CandidateRecord
|
||||||
|
|
||||||
|
from ..rate_limiter import RateLimiter
|
||||||
|
|
||||||
|
_DOMAIN = "search.rsl.ru"
|
||||||
|
|
||||||
|
|
||||||
|
class RSLPlugin:
|
||||||
|
category = "archive_searchers"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
name: str,
|
||||||
|
rate_limiter: RateLimiter,
|
||||||
|
rate_limit_seconds: float,
|
||||||
|
auto_queue: bool,
|
||||||
|
timeout: int,
|
||||||
|
config: dict[str, Any],
|
||||||
|
):
|
||||||
|
self.plugin_id = plugin_id
|
||||||
|
self.name = name
|
||||||
|
self._rl = rate_limiter
|
||||||
|
self.rate_limit_seconds = rate_limit_seconds
|
||||||
|
self.auto_queue = auto_queue
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def search(self, query: str) -> list[CandidateRecord]:
|
||||||
|
self._rl.wait_and_record(_DOMAIN, self.rate_limit_seconds)
|
||||||
|
r = httpx.get(
|
||||||
|
"https://search.rsl.ru/site/ajax-search",
|
||||||
|
params={"language": "ru", "q": query, "page": 1, "perPage": 5},
|
||||||
|
timeout=self.timeout,
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
data: dict[str, Any] = r.json()
|
||||||
|
records: list[dict[str, Any]] = data.get("records") or data.get("items") or data.get("data") or []
|
||||||
|
out: list[CandidateRecord] = []
|
||||||
|
for rec in records[:3]:
|
||||||
|
title = (str(rec.get("title") or rec.get("name") or "")).strip()
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
CandidateRecord(
|
||||||
|
source=self.plugin_id,
|
||||||
|
title=title,
|
||||||
|
author=(str(rec.get("author") or rec.get("authors") or "")).strip(),
|
||||||
|
year=str(rec.get("year") or rec.get("pubyear") or "").strip(),
|
||||||
|
isbn=(str(rec.get("isbn") or "")).strip(),
|
||||||
|
publisher=(str(rec.get("publisher") or "")).strip(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
71
src/plugins/archives/sru_catalog.py
Normal file
71
src/plugins/archives/sru_catalog.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""SRU XML catalog plugin (NLR and similar SRU-compliant catalogs)."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from models import CandidateRecord
|
||||||
|
|
||||||
|
from ..rate_limiter import RateLimiter
|
||||||
|
|
||||||
|
|
||||||
|
class SRUCatalogPlugin:
|
||||||
|
"""
|
||||||
|
Config-driven SRU catalog searcher. Config keys:
|
||||||
|
url — SRU endpoint URL
|
||||||
|
query_prefix — SRU query prefix prepended to search term (e.g. 'title=')
|
||||||
|
"""
|
||||||
|
|
||||||
|
category = "archive_searchers"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
name: str,
|
||||||
|
rate_limiter: RateLimiter,
|
||||||
|
rate_limit_seconds: float,
|
||||||
|
auto_queue: bool,
|
||||||
|
timeout: int,
|
||||||
|
config: dict[str, Any],
|
||||||
|
):
|
||||||
|
self.plugin_id = plugin_id
|
||||||
|
self.name = name
|
||||||
|
self._rl = rate_limiter
|
||||||
|
self.rate_limit_seconds = rate_limit_seconds
|
||||||
|
self.auto_queue = auto_queue
|
||||||
|
self.timeout = timeout
|
||||||
|
self.config = config
|
||||||
|
self._domain: str = urlparse(str(config.get("url") or "")).netloc or plugin_id
|
||||||
|
|
||||||
|
def search(self, query: str) -> list[CandidateRecord]:
|
||||||
|
cfg = self.config
|
||||||
|
self._rl.wait_and_record(self._domain, self.rate_limit_seconds)
|
||||||
|
sru_query = f'{cfg.get("query_prefix", "")}{query}'
|
||||||
|
r = httpx.get(
|
||||||
|
cfg["url"],
|
||||||
|
params={
|
||||||
|
"operation": "searchRetrieve",
|
||||||
|
"version": "1.1",
|
||||||
|
"query": sru_query,
|
||||||
|
"maximumRecords": "5",
|
||||||
|
"recordSchema": "dc",
|
||||||
|
},
|
||||||
|
timeout=self.timeout,
|
||||||
|
headers={"User-Agent": "Mozilla/5.0"},
|
||||||
|
)
|
||||||
|
titles = re.findall(r"<dc:title>([^<]+)</dc:title>", r.text)[:3]
|
||||||
|
authors = re.findall(r"<dc:creator>([^<]+)</dc:creator>", r.text)[:3]
|
||||||
|
years = re.findall(r"<dc:date>(\d{4})</dc:date>", r.text)[:3]
|
||||||
|
return [
|
||||||
|
CandidateRecord(
|
||||||
|
source=self.plugin_id,
|
||||||
|
title=title.strip(),
|
||||||
|
author=authors[i].strip() if i < len(authors) else "",
|
||||||
|
year=years[i] if i < len(years) else "",
|
||||||
|
isbn="",
|
||||||
|
publisher="",
|
||||||
|
)
|
||||||
|
for i, title in enumerate(titles)
|
||||||
|
]
|
||||||
23
src/plugins/rate_limiter.py
Normal file
23
src/plugins/rate_limiter.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""Thread-safe in-memory per-domain rate limiter shared across all archive plugin threads."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimiter:
|
||||||
|
"""Thread-safe per-domain rate limiter. Shared across all archive plugin threads."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._lock = Lock()
|
||||||
|
self._next: dict[str, float] = {}
|
||||||
|
|
||||||
|
def wait_and_record(self, domain: str, rate_s: float):
|
||||||
|
"""Block until rate limit for domain has passed, then record next allowed time."""
|
||||||
|
if rate_s <= 0:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
now = time.time()
|
||||||
|
delay = self._next.get(domain, 0) - now
|
||||||
|
self._next[domain] = max(now, self._next.get(domain, now)) + rate_s
|
||||||
|
if delay > 0:
|
||||||
|
time.sleep(delay)
|
||||||
9
static/css/base.css
Normal file
9
static/css/base.css
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/*
|
||||||
|
* base.css
|
||||||
|
* Global CSS reset, body defaults, and single utility class used throughout
|
||||||
|
* the app. Must load before all other stylesheets.
|
||||||
|
*/
|
||||||
|
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f1f5f9;color:#1e293b;min-height:100vh}
|
||||||
|
.hidden{display:none!important}
|
||||||
44
static/css/forms.css
Normal file
44
static/css/forms.css
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* forms.css
|
||||||
|
* Generic button variants, the book detail right panel, image/canvas wrapper,
|
||||||
|
* crop selection overlay, book image display boxes, and the identification form
|
||||||
|
* (card, labels, inputs, textarea, danger zone).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Buttons ── */
|
||||||
|
.btn{display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:7px 13px;border-radius:7px;border:none;cursor:pointer;font-size:.83rem;font-weight:500}
|
||||||
|
.btn:active{opacity:.82}
|
||||||
|
.btn:disabled{opacity:.4;cursor:default}
|
||||||
|
.btn-p{background:#2563eb;color:white}
|
||||||
|
.btn-s{background:#e2e8f0;color:#475569}
|
||||||
|
.btn-g{background:#16a34a;color:white}
|
||||||
|
.btn-r{background:#ef4444;color:white}
|
||||||
|
.btn-w{width:100%;margin-bottom:7px}
|
||||||
|
.btn-row{display:flex;gap:7px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
|
||||||
|
|
||||||
|
/* ── Right panel ── */
|
||||||
|
.det-empty{display:flex;align-items:center;justify-content:center;height:100%;color:#94a3b8;font-size:.95rem}
|
||||||
|
|
||||||
|
/* ── Image + canvas overlay ── */
|
||||||
|
.img-wrap{position:relative;display:inline-block;max-width:100%;line-height:0;border-radius:7px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.15)}
|
||||||
|
.img-wrap img{display:block;max-width:100%;max-height:calc(100vh - 200px);object-fit:contain}
|
||||||
|
.img-wrap canvas{position:absolute;inset:0;width:100%;height:100%}
|
||||||
|
|
||||||
|
/* ── Crop overlay ── */
|
||||||
|
.crop-sel{position:absolute;border:2px solid #38bdf8;background:rgba(56,189,248,.12);pointer-events:none}
|
||||||
|
|
||||||
|
/* ── Book detail panel ── */
|
||||||
|
.book-panel{display:flex;flex-direction:column;gap:14px}
|
||||||
|
.book-img-box{border-radius:7px;overflow:hidden;background:#0f172a;line-height:0;box-shadow:0 1px 4px rgba(0,0,0,.2);margin-bottom:8px}
|
||||||
|
.book-img-box img{max-width:100%;max-height:260px;object-fit:contain;display:block;margin:0 auto}
|
||||||
|
.book-img-label{font-size:.7rem;color:#64748b;margin-bottom:4px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}
|
||||||
|
|
||||||
|
/* ── Form ── */
|
||||||
|
.card{background:white;border-radius:10px;padding:13px;margin-bottom:10px;box-shadow:0 1px 3px rgba(0,0,0,.07)}
|
||||||
|
.flabel{display:block;font-size:.7rem;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.04em;margin-bottom:3px}
|
||||||
|
.finput{width:100%;padding:8px 10px;border:1.5px solid #e2e8f0;border-radius:6px;font-size:.88rem;color:#1e293b;background:white;-webkit-appearance:none}
|
||||||
|
.finput:focus{outline:none;border-color:#2563eb}
|
||||||
|
textarea.finput{height:64px;resize:vertical}
|
||||||
|
.fgroup{margin-bottom:9px}
|
||||||
|
.dz{border:1.5px solid #fecaca;border-radius:8px;padding:11px;margin-top:12px}
|
||||||
|
.dz-h{color:#dc2626;font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;margin-bottom:7px}
|
||||||
36
static/css/layout.css
Normal file
36
static/css/layout.css
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/*
|
||||||
|
* layout.css
|
||||||
|
* Top-level layout: sticky header bar, two-column desktop layout
|
||||||
|
* (300px sidebar + flex main panel), mobile single-column default,
|
||||||
|
* and the contenteditable header span used for inline entity renaming.
|
||||||
|
*
|
||||||
|
* Breakpoint: ≥768px = desktop two-column; <768px = mobile accordion.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.hdr{background:#1e3a5f;color:white;padding:10px 14px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:100;box-shadow:0 2px 6px rgba(0,0,0,.3);flex-shrink:0}
|
||||||
|
.hdr h1{flex:1;font-size:.96rem;font-weight:600}
|
||||||
|
.hbtn{background:none;border:none;color:white;min-width:34px;min-height:34px;border-radius:50%;cursor:pointer;font-size:1rem;display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||||
|
.hbtn:active{background:rgba(255,255,255,.2)}
|
||||||
|
|
||||||
|
/* ── Mobile layout (default) ── */
|
||||||
|
.layout{display:flex;flex-direction:column;min-height:100vh}
|
||||||
|
.sidebar{flex:1}
|
||||||
|
.main-panel{display:none}
|
||||||
|
|
||||||
|
/* ── Desktop layout ── */
|
||||||
|
@media(min-width:768px){
|
||||||
|
body{overflow:hidden}
|
||||||
|
.layout{flex-direction:row;height:100vh;overflow:hidden}
|
||||||
|
.sidebar{width:300px;display:flex;flex-direction:column;border-right:1px solid #cbd5e1;overflow:hidden;flex-shrink:0}
|
||||||
|
.sidebar .hdr{padding:9px 12px}
|
||||||
|
.sidebar-body{flex:1;overflow-y:auto;padding:8px 10px 16px}
|
||||||
|
.main-panel{flex:1;display:flex;flex-direction:column;overflow:hidden;background:#e8eef5}
|
||||||
|
.main-hdr{background:#1e3a5f;color:white;padding:9px 14px;display:flex;align-items:center;gap:8px;flex-shrink:0}
|
||||||
|
.main-hdr h2{flex:1;font-size:.9rem;font-weight:500;opacity:.9;min-width:0}
|
||||||
|
.main-body{flex:1;overflow:auto;padding:14px}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Detail header editable name ── */
|
||||||
|
.hdr-edit{display:block;outline:none;cursor:text;border-radius:3px;padding:1px 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.hdr-edit:focus{background:rgba(255,255,255,.15);white-space:normal;overflow:visible}
|
||||||
31
static/css/overlays.css
Normal file
31
static/css/overlays.css
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
* overlays.css
|
||||||
|
* Fixed-position overlays that appear above all other content:
|
||||||
|
* - Toast notification (bottom-center slide-in)
|
||||||
|
* - Loading spinner and empty-state placeholder
|
||||||
|
* - Photo Queue overlay: full-screen mobile flow for photographing
|
||||||
|
* unidentified books in sequence (spine preview + camera button)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Toast / loading ── */
|
||||||
|
.toast{position:fixed;bottom:16px;left:50%;transform:translateX(-50%) translateY(120px);background:#1e293b;color:white;padding:7px 15px;border-radius:6px;font-size:.82rem;transition:transform .25s;z-index:9999;pointer-events:none;white-space:nowrap}
|
||||||
|
.toast.on{transform:translateX(-50%) translateY(0)}
|
||||||
|
.loading{display:flex;align-items:center;justify-content:center;padding:30px;gap:8px;color:#64748b;font-size:.88rem}
|
||||||
|
.spinner{width:20px;height:20px;border:3px solid #e2e8f0;border-top-color:#2563eb;border-radius:50%;animation:spin .8s linear infinite;flex-shrink:0}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
.empty{text-align:center;padding:36px 12px;color:#94a3b8}
|
||||||
|
.empty .ei{font-size:2.4rem;margin-bottom:7px}
|
||||||
|
|
||||||
|
/* ── Photo Queue Overlay ── */
|
||||||
|
#photo-queue-overlay{position:fixed;inset:0;background:#0f172a;z-index:200;flex-direction:column;color:white}
|
||||||
|
.pq-hdr{display:flex;align-items:center;gap:8px;padding:12px 14px;background:#1e3a5f;flex-shrink:0;box-shadow:0 2px 6px rgba(0,0,0,.3)}
|
||||||
|
.pq-hdr-title{flex:1;font-size:.9rem;font-weight:600;text-align:center}
|
||||||
|
.pq-spine-wrap{flex:1;display:flex;align-items:center;justify-content:center;padding:16px;min-height:0;flex-direction:column;gap:12px;overflow:hidden}
|
||||||
|
.pq-spine-img{max-width:100%;max-height:52vh;object-fit:contain;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.6)}
|
||||||
|
.pq-book-name{font-size:.85rem;color:#94a3b8;text-align:center;max-width:90%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.pq-actions{display:flex;align-items:center;justify-content:center;gap:24px;padding:20px 16px;flex-shrink:0;background:#1e293b;border-top:1px solid rgba(255,255,255,.1)}
|
||||||
|
.pq-camera-btn{background:#2563eb;color:white;border:none;border-radius:50%;width:76px;height:76px;font-size:2rem;display:flex;align-items:center;justify-content:center;cursor:pointer;flex-shrink:0;box-shadow:0 4px 16px rgba(37,99,235,.4)}
|
||||||
|
.pq-camera-btn:active{background:#1d4ed8;transform:scale(.94)}
|
||||||
|
.pq-skip-btn{background:rgba(255,255,255,.1);color:#cbd5e1;border:none;border-radius:8px;padding:12px 18px;font-size:.85rem;cursor:pointer;min-width:70px}
|
||||||
|
.pq-skip-btn:active{background:rgba(255,255,255,.2)}
|
||||||
|
.pq-processing{position:absolute;inset:0;background:rgba(15,23,42,.88);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;font-size:.9rem}
|
||||||
70
static/css/tree.css
Normal file
70
static/css/tree.css
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
* tree.css
|
||||||
|
* Styles for the sidebar tree: room/cabinet/shelf/book node rows,
|
||||||
|
* selection highlight, segment-hover highlight (synced with boundary canvas),
|
||||||
|
* drag handle, expand toggle, inline name spans, per-level action icon buttons,
|
||||||
|
* book thumbnail + metadata layout, status badges, AI suggestion rows,
|
||||||
|
* source badges (per archive/plugin), and the "+ Add Room" dashed button.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Tree nodes ── */
|
||||||
|
.node{margin-bottom:2px}
|
||||||
|
.nrow{display:flex;align-items:center;gap:4px;padding:10px 8px;border-radius:7px;user-select:none}
|
||||||
|
.nrow-room {background:#1e3a5f;color:white;cursor:pointer}
|
||||||
|
.nrow-cabinet{background:#234e85;color:white;cursor:pointer}
|
||||||
|
@media(min-width:768px){
|
||||||
|
.nrow{padding:6px 7px}
|
||||||
|
}
|
||||||
|
.nrow-shelf {background:#f8fafc;border:1px solid #e2e8f0;cursor:pointer}
|
||||||
|
.nrow-book {background:white;cursor:pointer}
|
||||||
|
.nrow.sel {outline:2px solid #38bdf8;outline-offset:-2px}
|
||||||
|
.nrow.seg-hover{background:#fef9c3!important}
|
||||||
|
.nrow-room.seg-hover{background:#2d5b9e!important}
|
||||||
|
.nrow-cabinet.seg-hover{background:#2d5b9e!important}
|
||||||
|
.nchildren{padding-left:16px;margin-top:2px}
|
||||||
|
|
||||||
|
/* ── Drag handle ── */
|
||||||
|
.drag-h{color:#94a3b8;cursor:grab;font-size:.9rem;flex-shrink:0;padding:1px 2px;user-select:none;touch-action:none}
|
||||||
|
.nrow-room .drag-h,.nrow-cabinet .drag-h{color:rgba(255,255,255,.4)}
|
||||||
|
.drag-ghost{opacity:.4}
|
||||||
|
|
||||||
|
/* ── Toggle ── */
|
||||||
|
.tbtn{background:none;border:none;cursor:pointer;font-size:.75rem;padding:2px;flex-shrink:0;color:inherit;transition:transform .15s;line-height:1}
|
||||||
|
.tbtn.col{transform:rotate(-90deg)}
|
||||||
|
|
||||||
|
/* ── Name (editable) ── */
|
||||||
|
.nname{flex:1;min-width:0;font-size:.84rem;font-weight:500;outline:none;cursor:inherit;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;border-radius:3px;padding:1px 3px}
|
||||||
|
.nname:focus{background:rgba(255,255,255,.18);white-space:normal;overflow:visible}
|
||||||
|
.nrow-shelf .nname,.nrow-book .nname{color:#334155;font-weight:400}
|
||||||
|
|
||||||
|
/* ── Action buttons ── */
|
||||||
|
.nacts{display:flex;align-items:center;gap:1px;flex-shrink:0}
|
||||||
|
.ibtn{background:none;border:none;cursor:pointer;font-size:1.1rem;padding:4px 6px;border-radius:4px;color:inherit;opacity:.8;line-height:1;flex-shrink:0;min-width:48px;min-height:48px;display:flex;align-items:center;justify-content:center}
|
||||||
|
.ibtn:active{background:rgba(0,0,0,.12)}
|
||||||
|
.ibtn:disabled{opacity:.3;cursor:default}
|
||||||
|
@media(min-width:768px){
|
||||||
|
.ibtn{min-width:24px;min-height:24px;font-size:.82rem;padding:2px 4px}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Book row (sidebar) ── */
|
||||||
|
.bthumb{width:22px;height:30px;object-fit:cover;border-radius:2px;flex-shrink:0}
|
||||||
|
.bthumb-ph{width:22px;height:30px;background:#e2e8f0;border-radius:2px;display:flex;align-items:center;justify-content:center;font-size:.65rem;color:#94a3b8;flex-shrink:0}
|
||||||
|
.binfo{flex:1;min-width:0}
|
||||||
|
.bttl{font-size:.8rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.bsub{font-size:.7rem;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.sbadge{display:inline-block;font-size:.6rem;font-weight:700;padding:1px 5px;border-radius:3px;flex-shrink:0;text-transform:uppercase;letter-spacing:.03em}
|
||||||
|
.s-unid{background:#e2e8f0;color:#64748b}.s-aiid{background:#fef3c7;color:#b45309}.s-appr{background:#dcfce7;color:#15803d}
|
||||||
|
.ai-sug{display:flex;align-items:center;gap:5px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:5px;padding:3px 8px;margin-bottom:4px;font-size:.8rem}
|
||||||
|
.ai-sug em{flex:1;color:#1e40af;font-style:normal;min-width:0;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.src-badge{display:inline-block;font-size:.58rem;font-weight:700;padding:1px 4px;border-radius:3px;text-transform:uppercase;letter-spacing:.03em;white-space:nowrap;flex-shrink:0}
|
||||||
|
.src-vlm,.src-vlm_spine,.src-vlm_shelves,.src-vlm_books{background:#ede9fe;color:#7c3aed}
|
||||||
|
.src-ai,.src-ai_search{background:#fef3c7;color:#b45309}
|
||||||
|
.src-openlibrary{background:#dbeafe;color:#1d4ed8}
|
||||||
|
.src-rsl{background:#dcfce7;color:#15803d}
|
||||||
|
.src-rusneb{background:#fce7f3;color:#be185d}
|
||||||
|
.src-alib,.src-alib_web,.src-alib_telegram{background:#fff7ed;color:#c2410c}
|
||||||
|
.src-nlr{background:#f1f5f9;color:#475569}.src-shpl{background:#f1f5f9;color:#475569}
|
||||||
|
|
||||||
|
/* ── Add-root button ── */
|
||||||
|
.add-root{display:block;width:100%;padding:9px;background:#f1f5f9;border:2px dashed #94a3b8;border-radius:7px;color:#64748b;font-size:.84rem;cursor:pointer;margin-top:8px;text-align:center}
|
||||||
|
.add-root:active{background:#e2e8f0}
|
||||||
81
static/index.html
Normal file
81
static/index.html
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
||||||
|
<meta name="theme-color" content="#1e3a5f">
|
||||||
|
<title>Bookshelf</title>
|
||||||
|
|
||||||
|
<!-- Global reset, body font/background, and .hidden utility -->
|
||||||
|
<link rel="stylesheet" href="css/base.css">
|
||||||
|
<!-- Sticky header, two-column desktop layout, mobile single-column default -->
|
||||||
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
|
<!-- Sidebar tree: node rows, drag handle, toggle, name, action buttons, book thumbnails, status/source badges -->
|
||||||
|
<link rel="stylesheet" href="css/tree.css">
|
||||||
|
<!-- Generic button variants, book detail panel, image/canvas wrapper, identification form -->
|
||||||
|
<link rel="stylesheet" href="css/forms.css">
|
||||||
|
<!-- Fixed-position overlays: toast notification, loading spinner, photo queue -->
|
||||||
|
<link rel="stylesheet" href="css/overlays.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Main app mount point — entire UI is rendered here by render() in js/init.js -->
|
||||||
|
<div id="app"></div>
|
||||||
|
|
||||||
|
<!-- Photo queue overlay: full-screen mobile UI for sequential book photography.
|
||||||
|
Lives outside #app so its event listener does not conflict with the tree. -->
|
||||||
|
<div id="photo-queue-overlay" style="display:none"></div>
|
||||||
|
|
||||||
|
<!-- Shared file input for all photo uploads (cabinet, shelf, book, room).
|
||||||
|
Triggered programmatically by triggerPhoto() in js/photo.js. -->
|
||||||
|
<input type="file" id="gphoto" accept="image/*" class="hidden">
|
||||||
|
|
||||||
|
<!-- Slide-in toast notification; text set by toast() in js/helpers.js -->
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<!-- SortableJS: drag-and-drop reordering for rooms, cabinets, shelves, and books -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
|
||||||
|
|
||||||
|
<!-- All mutable application state (S, _plugins, _batchState, _bnd, _photoQueue).
|
||||||
|
Must load first — every subsequent module reads these globals. -->
|
||||||
|
<script src="js/state.js"></script>
|
||||||
|
|
||||||
|
<!-- Pure utilities: esc(), toast(), isDesktop(). No dependencies. -->
|
||||||
|
<script src="js/helpers.js"></script>
|
||||||
|
|
||||||
|
<!-- Fetch wrapper req(). Throws on non-2xx with server detail message. -->
|
||||||
|
<script src="js/api.js"></script>
|
||||||
|
|
||||||
|
<!-- Boundary-line canvas editor: draw, drag, snap-to-AI, Ctrl+Alt+Click to add.
|
||||||
|
Defines _bnd structure — loaded before detail-render.js which reads it. -->
|
||||||
|
<script src="js/canvas-boundary.js"></script>
|
||||||
|
|
||||||
|
<!-- Tree HTML generators: vApp(), vRoom/Cabinet/Shelf/Book(), vBatchBtn(),
|
||||||
|
plugin helpers, candidate suggestion rows, walkTree/findNode/removeNode. -->
|
||||||
|
<script src="js/tree-render.js"></script>
|
||||||
|
|
||||||
|
<!-- Detail panel HTML generators: vDetailBody(), vCabinetDetail(),
|
||||||
|
vShelfDetail(), vBookDetail(). Reads _bnd for boundary plugin selection. -->
|
||||||
|
<script src="js/detail-render.js"></script>
|
||||||
|
|
||||||
|
<!-- In-place crop tool: draggable rectangle on canvas, POSTs pixel coords.
|
||||||
|
Calls drawBnd() on cancel to restore the boundary overlay. -->
|
||||||
|
<script src="js/canvas-crop.js"></script>
|
||||||
|
|
||||||
|
<!-- Inline contenteditable name editing (blur-to-save) and SortableJS wiring. -->
|
||||||
|
<script src="js/editing.js"></script>
|
||||||
|
|
||||||
|
<!-- Photo upload for all entity types and mobile Photo Queue feature.
|
||||||
|
Registers the gphoto 'change' handler at parse time. -->
|
||||||
|
<script src="js/photo.js"></script>
|
||||||
|
|
||||||
|
<!-- Event delegation on #app and #photo-queue-overlay; central handle() dispatcher
|
||||||
|
with all action cases; accordion expand helpers. -->
|
||||||
|
<script src="js/events.js"></script>
|
||||||
|
|
||||||
|
<!-- render(), renderDetail(), loadConfig(), startBatchPolling(), loadTree(),
|
||||||
|
and the bootstrap Promise.all([loadConfig(), loadTree()]) call. -->
|
||||||
|
<script src="js/init.js"></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
static/js/api.js
Normal file
23
static/js/api.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* api.js
|
||||||
|
* Single fetch wrapper used for all server communication.
|
||||||
|
* Throws an Error with the server's detail message on non-2xx responses.
|
||||||
|
*
|
||||||
|
* Provides: req(method, url, body?, isForm?)
|
||||||
|
* Depends on: nothing
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── API ──────────────────────────────────────────────────────────────────────
|
||||||
|
async function req(method, url, body = null, isForm = false) {
|
||||||
|
const opts = {method};
|
||||||
|
if (body) {
|
||||||
|
if (isForm) { opts.body = body; }
|
||||||
|
else { opts.headers = {'Content-Type':'application/json'}; opts.body = JSON.stringify(body); }
|
||||||
|
}
|
||||||
|
const r = await fetch(url, opts);
|
||||||
|
if (!r.ok) {
|
||||||
|
const e = await r.json().catch(() => ({detail:'Request failed'}));
|
||||||
|
throw new Error(e.detail || 'Request failed');
|
||||||
|
}
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
271
static/js/canvas-boundary.js
Normal file
271
static/js/canvas-boundary.js
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
/*
|
||||||
|
* canvas-boundary.js
|
||||||
|
* Boundary-line editor rendered on a <canvas> overlaid on cabinet/shelf images.
|
||||||
|
* Handles:
|
||||||
|
* - Parsing boundary JSON from tree nodes
|
||||||
|
* - Drawing segment fills, labels, user boundary lines, and AI suggestion
|
||||||
|
* overlays (dashed lines per plugin, or all-plugins combined)
|
||||||
|
* - Pointer drag to move existing boundary lines
|
||||||
|
* - Ctrl+Alt+Click to add a new boundary line (and create a new child entity)
|
||||||
|
* - Mouse hover to highlight the corresponding tree row (seg-hover)
|
||||||
|
* - Snap-to-AI-guide when releasing a drag near a plugin boundary
|
||||||
|
*
|
||||||
|
* Reads: S, _bnd (state.js); req, toast, render (api.js / init.js)
|
||||||
|
* Writes: _bnd (state.js)
|
||||||
|
* Provides: parseBounds(), parseBndPluginResults(), SEG_FILLS, SEG_STROKES,
|
||||||
|
* setupDetailCanvas(), drawBnd(), clearSegHover()
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Boundary parsing helpers ─────────────────────────────────────────────────
|
||||||
|
function parseBounds(json) {
|
||||||
|
if (!json) return [];
|
||||||
|
try { return JSON.parse(json) || []; } catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBndPluginResults(json) {
|
||||||
|
if (!json) return {};
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(json);
|
||||||
|
if (Array.isArray(v) || !v || typeof v !== 'object') return {};
|
||||||
|
return v;
|
||||||
|
} catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEG_FILLS = ['rgba(59,130,246,.14)','rgba(16,185,129,.14)','rgba(245,158,11,.14)','rgba(239,68,68,.14)','rgba(168,85,247,.14)'];
|
||||||
|
const SEG_STROKES = ['#3b82f6','#10b981','#f59e0b','#ef4444','#a855f7'];
|
||||||
|
|
||||||
|
// ── Canvas setup ─────────────────────────────────────────────────────────────
|
||||||
|
function setupDetailCanvas() {
|
||||||
|
const wrap = document.getElementById('bnd-wrap');
|
||||||
|
const img = document.getElementById('bnd-img');
|
||||||
|
const canvas = document.getElementById('bnd-canvas');
|
||||||
|
if (!wrap || !img || !canvas || !S.selected) return;
|
||||||
|
const {type, id} = S.selected;
|
||||||
|
const node = findNode(id);
|
||||||
|
if (!node || (type !== 'cabinet' && type !== 'shelf')) return;
|
||||||
|
|
||||||
|
const axis = type === 'cabinet' ? 'y' : 'x';
|
||||||
|
const boundaries = parseBounds(type === 'cabinet' ? node.shelf_boundaries : node.book_boundaries);
|
||||||
|
const pluginResults = parseBndPluginResults(type === 'cabinet' ? node.ai_shelf_boundaries : node.ai_book_boundaries);
|
||||||
|
const pluginIds = Object.keys(pluginResults);
|
||||||
|
const segments = type === 'cabinet'
|
||||||
|
? node.shelves.map((s,i) => ({id:s.id, label:s.name||`Shelf ${i+1}`}))
|
||||||
|
: node.books.map((b,i) => ({id:b.id, label:b.title||`Book ${i+1}`}));
|
||||||
|
|
||||||
|
const hasChildren = type === 'cabinet' ? node.shelves.length > 0 : node.books.length > 0;
|
||||||
|
const prevSel = (_bnd?.nodeId === id) ? _bnd.selectedPlugin
|
||||||
|
: (hasChildren ? null : pluginIds[0] ?? null);
|
||||||
|
|
||||||
|
_bnd = {wrap, img, canvas, axis, boundaries:[...boundaries],
|
||||||
|
pluginResults, selectedPlugin: prevSel, segments, nodeId:id, nodeType:type};
|
||||||
|
|
||||||
|
function sizeAndDraw() {
|
||||||
|
canvas.width = img.offsetWidth;
|
||||||
|
canvas.height = img.offsetHeight;
|
||||||
|
drawBnd();
|
||||||
|
}
|
||||||
|
if (img.complete && img.offsetWidth > 0) sizeAndDraw();
|
||||||
|
else img.addEventListener('load', sizeAndDraw);
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerdown', bndPointerDown);
|
||||||
|
canvas.addEventListener('pointermove', bndPointerMove);
|
||||||
|
canvas.addEventListener('pointerup', bndPointerUp);
|
||||||
|
canvas.addEventListener('click', bndClick);
|
||||||
|
canvas.addEventListener('mousemove', bndHover);
|
||||||
|
canvas.addEventListener('mouseleave', () => clearSegHover());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw ─────────────────────────────────────────────────────────────────────
|
||||||
|
function drawBnd(dragIdx = -1, dragVal = null) {
|
||||||
|
if (!_bnd || S._cropMode) return;
|
||||||
|
const {canvas, axis, boundaries, segments} = _bnd;
|
||||||
|
const W = canvas.width, H = canvas.height;
|
||||||
|
if (!W || !H) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Build working boundary list with optional live drag value
|
||||||
|
const full = [0, ...boundaries, 1];
|
||||||
|
if (dragIdx >= 0 && dragIdx < boundaries.length) {
|
||||||
|
const lo = full[dragIdx] + 0.005;
|
||||||
|
const hi = full[dragIdx + 2] - 0.005;
|
||||||
|
full[dragIdx + 1] = Math.max(lo, Math.min(hi, dragVal));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw segments
|
||||||
|
for (let i = 0; i < full.length - 1; i++) {
|
||||||
|
const a = full[i], b = full[i + 1];
|
||||||
|
const ci = i % SEG_FILLS.length;
|
||||||
|
ctx.fillStyle = SEG_FILLS[ci];
|
||||||
|
if (axis === 'y') ctx.fillRect(0, a*H, W, (b-a)*H);
|
||||||
|
else ctx.fillRect(a*W, 0, (b-a)*W, H);
|
||||||
|
// Label
|
||||||
|
const seg = segments[i];
|
||||||
|
if (seg) {
|
||||||
|
ctx.font = '11px system-ui,sans-serif';
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,.5)';
|
||||||
|
const lbl = seg.label.slice(0, 24);
|
||||||
|
if (axis === 'y') {
|
||||||
|
ctx.fillText(lbl, 4, a*H + 14);
|
||||||
|
} else {
|
||||||
|
ctx.save(); ctx.translate(a*W + 12, 14); ctx.rotate(Math.PI/2);
|
||||||
|
ctx.fillText(lbl, 0, 0); ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw interior user boundary lines
|
||||||
|
ctx.setLineDash([5, 3]);
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
for (let i = 0; i < boundaries.length; i++) {
|
||||||
|
const val = (dragIdx === i && dragVal !== null) ? full[i+1] : boundaries[i];
|
||||||
|
ctx.strokeStyle = '#1e3a5f';
|
||||||
|
ctx.beginPath();
|
||||||
|
if (axis === 'y') { ctx.moveTo(0, val*H); ctx.lineTo(W, val*H); }
|
||||||
|
else { ctx.moveTo(val*W, 0); ctx.lineTo(val*W, H); }
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw plugin boundary suggestions (dashed, non-interactive)
|
||||||
|
const {pluginResults, selectedPlugin} = _bnd;
|
||||||
|
const pluginIds = Object.keys(pluginResults);
|
||||||
|
if (selectedPlugin && pluginIds.length) {
|
||||||
|
ctx.setLineDash([3, 6]);
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
const drawPluginBounds = (bounds, color) => {
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
for (const ab of (bounds || [])) {
|
||||||
|
ctx.beginPath();
|
||||||
|
if (axis === 'y') { ctx.moveTo(0, ab*H); ctx.lineTo(W, ab*H); }
|
||||||
|
else { ctx.moveTo(ab*W, 0); ctx.lineTo(ab*W, H); }
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (selectedPlugin === 'all') {
|
||||||
|
pluginIds.forEach((pid, i) => drawPluginBounds(pluginResults[pid], SEG_STROKES[i % SEG_STROKES.length]));
|
||||||
|
} else if (pluginResults[selectedPlugin]) {
|
||||||
|
drawPluginBounds(pluginResults[selectedPlugin], 'rgba(234,88,12,0.8)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drag machinery ───────────────────────────────────────────────────────────
|
||||||
|
let _dragIdx = -1, _dragging = false;
|
||||||
|
|
||||||
|
function fracFromEvt(e) {
|
||||||
|
const r = _bnd.canvas.getBoundingClientRect();
|
||||||
|
const x = (e.clientX - r.left) / r.width;
|
||||||
|
const y = (e.clientY - r.top) / r.height;
|
||||||
|
return _bnd.axis === 'y' ? y : x;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestBnd(frac) {
|
||||||
|
const {boundaries, canvas, axis} = _bnd;
|
||||||
|
const r = canvas.getBoundingClientRect();
|
||||||
|
const dim = axis === 'y' ? r.height : r.width;
|
||||||
|
const thresh = (window._grabPx ?? 14) / dim;
|
||||||
|
let best = -1, bestD = thresh;
|
||||||
|
boundaries.forEach((b,i) => { const d=Math.abs(b-frac); if(d<bestD){bestD=d;best=i;} });
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapToAi(frac) {
|
||||||
|
if (!_bnd?.selectedPlugin) return frac;
|
||||||
|
const {pluginResults, selectedPlugin} = _bnd;
|
||||||
|
const snapBounds = selectedPlugin === 'all'
|
||||||
|
? Object.values(pluginResults).flat()
|
||||||
|
: (pluginResults[selectedPlugin] || []);
|
||||||
|
if (!snapBounds.length) return frac;
|
||||||
|
const r = _bnd.canvas.getBoundingClientRect();
|
||||||
|
const dim = _bnd.axis === 'y' ? r.height : r.width;
|
||||||
|
const thresh = (window._grabPx ?? 14) / dim;
|
||||||
|
let best = frac, bestD = thresh;
|
||||||
|
snapBounds.forEach(ab => { const d = Math.abs(ab - frac); if (d < bestD) { bestD = d; best = ab; } });
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bndPointerDown(e) {
|
||||||
|
if (!_bnd || S._cropMode) return;
|
||||||
|
const frac = fracFromEvt(e);
|
||||||
|
const idx = nearestBnd(frac);
|
||||||
|
if (idx >= 0) {
|
||||||
|
_dragIdx = idx; _dragging = true;
|
||||||
|
_bnd.canvas.setPointerCapture(e.pointerId);
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bndPointerMove(e) {
|
||||||
|
if (!_bnd || S._cropMode) return;
|
||||||
|
const frac = fracFromEvt(e);
|
||||||
|
const near = nearestBnd(frac);
|
||||||
|
_bnd.canvas.style.cursor = (near >= 0 || _dragging)
|
||||||
|
? (_bnd.axis==='y' ? 'ns-resize' : 'ew-resize') : 'default';
|
||||||
|
if (_dragging && _dragIdx >= 0) drawBnd(_dragIdx, frac);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bndPointerUp(e) {
|
||||||
|
if (!_dragging || !_bnd || S._cropMode) return;
|
||||||
|
const frac = fracFromEvt(e);
|
||||||
|
_dragging = false;
|
||||||
|
const {boundaries, nodeId, nodeType} = _bnd;
|
||||||
|
const full = [0, ...boundaries, 1];
|
||||||
|
const clamped = Math.max(full[_dragIdx]+0.005, Math.min(full[_dragIdx+2]-0.005, frac));
|
||||||
|
boundaries[_dragIdx] = Math.round(snapToAi(clamped) * 10000) / 10000;
|
||||||
|
_bnd.boundaries = [...boundaries];
|
||||||
|
_dragIdx = -1;
|
||||||
|
drawBnd();
|
||||||
|
const url = nodeType==='cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`;
|
||||||
|
try {
|
||||||
|
await req('PATCH', url, {boundaries});
|
||||||
|
const node = findNode(nodeId);
|
||||||
|
if (node) {
|
||||||
|
if (nodeType==='cabinet') node.shelf_boundaries = JSON.stringify(boundaries);
|
||||||
|
else node.book_boundaries = JSON.stringify(boundaries);
|
||||||
|
}
|
||||||
|
} catch(err) { toast('Save failed: ' + err.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bndClick(e) {
|
||||||
|
if (!_bnd || _dragging || S._cropMode) return;
|
||||||
|
if (!e.ctrlKey || !e.altKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const frac = snapToAi(fracFromEvt(e));
|
||||||
|
const {boundaries, nodeId, nodeType} = _bnd;
|
||||||
|
const newBounds = [...boundaries, frac].sort((a,b)=>a-b);
|
||||||
|
_bnd.boundaries = newBounds;
|
||||||
|
const url = nodeType==='cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`;
|
||||||
|
try {
|
||||||
|
await req('PATCH', url, {boundaries: newBounds});
|
||||||
|
if (nodeType === 'cabinet') {
|
||||||
|
const s = await req('POST', `/api/cabinets/${nodeId}/shelves`, null);
|
||||||
|
S.tree.forEach(r=>r.cabinets.forEach(c=>{ if(c.id===nodeId){
|
||||||
|
c.shelf_boundaries=JSON.stringify(newBounds); c.shelves.push({...s,books:[]});
|
||||||
|
}}));
|
||||||
|
} else {
|
||||||
|
const b = await req('POST', `/api/shelves/${nodeId}/books`);
|
||||||
|
S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>{ if(s.id===nodeId){
|
||||||
|
s.book_boundaries=JSON.stringify(newBounds); s.books.push(b);
|
||||||
|
}})));
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
} catch(err) { toast('Error: ' + err.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function bndHover(e) {
|
||||||
|
if (!_bnd || S._cropMode) return;
|
||||||
|
const frac = fracFromEvt(e);
|
||||||
|
const {boundaries, segments} = _bnd;
|
||||||
|
const full = [0, ...boundaries, 1];
|
||||||
|
let segIdx = -1;
|
||||||
|
for (let i = 0; i < full.length-1; i++) { if(frac>=full[i]&&frac<full[i+1]){segIdx=i;break;} }
|
||||||
|
clearSegHover();
|
||||||
|
if (segIdx>=0 && segments[segIdx]) {
|
||||||
|
document.querySelector(`.node[data-id="${segments[segIdx].id}"] .nrow`)?.classList.add('seg-hover');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSegHover() {
|
||||||
|
document.querySelectorAll('.seg-hover').forEach(el=>el.classList.remove('seg-hover'));
|
||||||
|
}
|
||||||
166
static/js/canvas-crop.js
Normal file
166
static/js/canvas-crop.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/*
|
||||||
|
* canvas-crop.js
|
||||||
|
* In-place crop tool for cabinet and shelf photos.
|
||||||
|
* Renders a draggable crop rectangle on the boundary canvas overlay,
|
||||||
|
* then POSTs pixel coordinates to the server to permanently crop the image.
|
||||||
|
*
|
||||||
|
* Entry point: startCropMode(type, id) — called from events.js 'crop-start'.
|
||||||
|
* Disables boundary drag events while active (checked via S._cropMode).
|
||||||
|
*
|
||||||
|
* Depends on: S (state.js); req, toast (api.js / helpers.js);
|
||||||
|
* drawBnd (canvas-boundary.js) — called in cancelCrop to restore
|
||||||
|
* the boundary overlay after the crop UI is dismissed
|
||||||
|
* Provides: startCropMode(), cancelCrop(), confirmCrop()
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Crop state ───────────────────────────────────────────────────────────────
|
||||||
|
let _cropState = null; // {x1,y1,x2,y2} fractions; null = not in crop mode
|
||||||
|
let _cropDragPart = null; // 'tl','tr','bl','br','t','b','l','r','move' | null
|
||||||
|
let _cropDragStart = null; // {fx,fy,x1,y1,x2,y2} snapshot at drag start
|
||||||
|
|
||||||
|
// ── Public entry point ───────────────────────────────────────────────────────
|
||||||
|
function startCropMode(type, id) {
|
||||||
|
const canvas = document.getElementById('bnd-canvas');
|
||||||
|
const wrap = document.getElementById('bnd-wrap');
|
||||||
|
if (!canvas || !wrap) return;
|
||||||
|
S._cropMode = {type, id};
|
||||||
|
_cropState = {x1: 0.05, y1: 0.05, x2: 0.95, y2: 0.95};
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerdown', cropPointerDown);
|
||||||
|
canvas.addEventListener('pointermove', cropPointerMove);
|
||||||
|
canvas.addEventListener('pointerup', cropPointerUp);
|
||||||
|
|
||||||
|
document.getElementById('crop-bar')?.remove();
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.id = 'crop-bar';
|
||||||
|
bar.style.cssText = 'margin-top:10px;display:flex;gap:8px';
|
||||||
|
bar.innerHTML = '<button class="btn btn-p" id="crop-ok">Confirm crop</button><button class="btn btn-s" id="crop-cancel">Cancel</button>';
|
||||||
|
wrap.after(bar);
|
||||||
|
document.getElementById('crop-ok').addEventListener('click', confirmCrop);
|
||||||
|
document.getElementById('crop-cancel').addEventListener('click', cancelCrop);
|
||||||
|
|
||||||
|
drawCropOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drawing ──────────────────────────────────────────────────────────────────
|
||||||
|
function drawCropOverlay() {
|
||||||
|
const canvas = document.getElementById('bnd-canvas');
|
||||||
|
if (!canvas || !_cropState) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const W = canvas.width, H = canvas.height;
|
||||||
|
const {x1, y1, x2, y2} = _cropState;
|
||||||
|
const px1=x1*W, py1=y1*H, px2=x2*W, py2=y2*H;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
// Dark shadow outside crop rect
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.55)';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
ctx.clearRect(px1, py1, px2-px1, py2-py1);
|
||||||
|
// Bright border
|
||||||
|
ctx.strokeStyle = '#38bdf8'; ctx.lineWidth = 2; ctx.setLineDash([]);
|
||||||
|
ctx.strokeRect(px1, py1, px2-px1, py2-py1);
|
||||||
|
// Corner handles
|
||||||
|
const hs = 9;
|
||||||
|
ctx.fillStyle = '#38bdf8';
|
||||||
|
[[px1,py1],[px2,py1],[px1,py2],[px2,py2]].forEach(([x,y]) => ctx.fillRect(x-hs/2, y-hs/2, hs, hs));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hit testing ──────────────────────────────────────────────────────────────
|
||||||
|
function _cropFracFromEvt(e) {
|
||||||
|
const canvas = document.getElementById('bnd-canvas');
|
||||||
|
const r = canvas.getBoundingClientRect();
|
||||||
|
return {fx: (e.clientX-r.left)/r.width, fy: (e.clientY-r.top)/r.height};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getCropPart(fx, fy) {
|
||||||
|
if (!_cropState) return null;
|
||||||
|
const {x1, y1, x2, y2} = _cropState;
|
||||||
|
const th = 0.05;
|
||||||
|
const inX=fx>=x1&&fx<=x2, inY=fy>=y1&&fy<=y2;
|
||||||
|
const nX1=Math.abs(fx-x1)<th, nX2=Math.abs(fx-x2)<th;
|
||||||
|
const nY1=Math.abs(fy-y1)<th, nY2=Math.abs(fy-y2)<th;
|
||||||
|
if (nX1&&nY1) return 'tl'; if (nX2&&nY1) return 'tr';
|
||||||
|
if (nX1&&nY2) return 'bl'; if (nX2&&nY2) return 'br';
|
||||||
|
if (nY1&&inX) return 't'; if (nY2&&inX) return 'b';
|
||||||
|
if (nX1&&inY) return 'l'; if (nX2&&inY) return 'r';
|
||||||
|
if (inX&&inY) return 'move';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _cropPartCursor(part) {
|
||||||
|
if (!part) return 'crosshair';
|
||||||
|
if (part==='move') return 'move';
|
||||||
|
if (part==='tl'||part==='br') return 'nwse-resize';
|
||||||
|
if (part==='tr'||part==='bl') return 'nesw-resize';
|
||||||
|
if (part==='t'||part==='b') return 'ns-resize';
|
||||||
|
return 'ew-resize';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pointer events ───────────────────────────────────────────────────────────
|
||||||
|
function cropPointerDown(e) {
|
||||||
|
if (!_cropState) return;
|
||||||
|
const {fx, fy} = _cropFracFromEvt(e);
|
||||||
|
const part = _getCropPart(fx, fy);
|
||||||
|
if (part) {
|
||||||
|
_cropDragPart = part;
|
||||||
|
_cropDragStart = {fx, fy, ..._cropState};
|
||||||
|
document.getElementById('bnd-canvas').setPointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cropPointerMove(e) {
|
||||||
|
if (!_cropState) return;
|
||||||
|
const canvas = document.getElementById('bnd-canvas');
|
||||||
|
const {fx, fy} = _cropFracFromEvt(e);
|
||||||
|
if (_cropDragPart && _cropDragStart) {
|
||||||
|
const dx=fx-_cropDragStart.fx, dy=fy-_cropDragStart.fy;
|
||||||
|
const s = {..._cropState};
|
||||||
|
if (_cropDragPart==='move') {
|
||||||
|
const w=_cropDragStart.x2-_cropDragStart.x1, h=_cropDragStart.y2-_cropDragStart.y1;
|
||||||
|
s.x1=Math.max(0,Math.min(1-w,_cropDragStart.x1+dx)); s.y1=Math.max(0,Math.min(1-h,_cropDragStart.y1+dy));
|
||||||
|
s.x2=s.x1+w; s.y2=s.y1+h;
|
||||||
|
} else {
|
||||||
|
if (_cropDragPart.includes('l')) s.x1=Math.max(0,Math.min(_cropDragStart.x2-0.05,_cropDragStart.x1+dx));
|
||||||
|
if (_cropDragPart.includes('r')) s.x2=Math.min(1,Math.max(_cropDragStart.x1+0.05,_cropDragStart.x2+dx));
|
||||||
|
if (_cropDragPart.includes('t')) s.y1=Math.max(0,Math.min(_cropDragStart.y2-0.05,_cropDragStart.y1+dy));
|
||||||
|
if (_cropDragPart.includes('b')) s.y2=Math.min(1,Math.max(_cropDragStart.y1+0.05,_cropDragStart.y2+dy));
|
||||||
|
}
|
||||||
|
_cropState = s;
|
||||||
|
drawCropOverlay();
|
||||||
|
canvas.style.cursor = _cropPartCursor(_cropDragPart);
|
||||||
|
} else {
|
||||||
|
canvas.style.cursor = _cropPartCursor(_getCropPart(fx, fy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cropPointerUp() { _cropDragPart = null; _cropDragStart = null; }
|
||||||
|
|
||||||
|
// ── Confirm / cancel ─────────────────────────────────────────────────────────
|
||||||
|
async function confirmCrop() {
|
||||||
|
if (!_cropState || !S._cropMode) return;
|
||||||
|
const img = document.getElementById('bnd-img');
|
||||||
|
if (!img) return;
|
||||||
|
const {x1, y1, x2, y2} = _cropState;
|
||||||
|
const W=img.naturalWidth, H=img.naturalHeight;
|
||||||
|
const px = {x:Math.round(x1*W), y:Math.round(y1*H), w:Math.round((x2-x1)*W), h:Math.round((y2-y1)*H)};
|
||||||
|
if (px.w<10||px.h<10) { toast('Selection too small'); return; }
|
||||||
|
const {type, id} = S._cropMode;
|
||||||
|
const url = type==='cabinet' ? `/api/cabinets/${id}/crop` : `/api/shelves/${id}/crop`;
|
||||||
|
try {
|
||||||
|
await req('POST', url, px);
|
||||||
|
toast('Cropped'); cancelCrop(); render();
|
||||||
|
} catch(err) { toast('Crop failed: '+err.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelCrop() {
|
||||||
|
S._cropMode = null; _cropState = null; _cropDragPart = null; _cropDragStart = null;
|
||||||
|
document.getElementById('crop-bar')?.remove();
|
||||||
|
const canvas = document.getElementById('bnd-canvas');
|
||||||
|
if (canvas) {
|
||||||
|
canvas.removeEventListener('pointerdown', cropPointerDown);
|
||||||
|
canvas.removeEventListener('pointermove', cropPointerMove);
|
||||||
|
canvas.removeEventListener('pointerup', cropPointerUp);
|
||||||
|
canvas.style.cursor = '';
|
||||||
|
}
|
||||||
|
drawBnd(); // restore boundary overlay
|
||||||
|
}
|
||||||
166
static/js/detail-render.js
Normal file
166
static/js/detail-render.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/*
|
||||||
|
* detail-render.js
|
||||||
|
* HTML-string generators for the right-side detail panel (desktop) and
|
||||||
|
* the selected-entity view (mobile). Covers all four entity types.
|
||||||
|
*
|
||||||
|
* Depends on: S, _bnd (state.js); esc (helpers.js);
|
||||||
|
* pluginsByCategory, pluginsByTarget, vPluginBtn, getBookStats,
|
||||||
|
* vAiProgressBar, candidateSugRows, _STATUS_BADGE (tree-render.js);
|
||||||
|
* parseBounds, parseBndPluginResults (canvas-boundary.js)
|
||||||
|
* Provides: vDetailBody(), vRoomDetail(), vCabinetDetail(),
|
||||||
|
* vShelfDetail(), vBookDetail()
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Room detail ──────────────────────────────────────────────────────────────
|
||||||
|
function vRoomDetail(r) {
|
||||||
|
const stats = getBookStats(r, 'room');
|
||||||
|
const totalBooks = stats.total;
|
||||||
|
return `<div>
|
||||||
|
${vAiProgressBar(stats)}
|
||||||
|
<p style="font-size:.72rem;color:#64748b">${r.cabinets.length} cabinet${r.cabinets.length!==1?'s':''} · ${totalBooks} book${totalBooks!==1?'s':''}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detail body (right panel) ────────────────────────────────────────────────
|
||||||
|
function vDetailBody() {
|
||||||
|
if (!S.selected) return '<div class="det-empty">← Select a room, cabinet or shelf from the tree</div>';
|
||||||
|
const {type, id} = S.selected;
|
||||||
|
const node = findNode(id);
|
||||||
|
if (!node) return '<div class="det-empty">Not found</div>';
|
||||||
|
if (type === 'room') return vRoomDetail(node);
|
||||||
|
if (type === 'cabinet') return vCabinetDetail(node);
|
||||||
|
if (type === 'shelf') return vShelfDetail(node);
|
||||||
|
if (type === 'book') return vBookDetail(node);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cabinet detail ───────────────────────────────────────────────────────────
|
||||||
|
function vCabinetDetail(cab) {
|
||||||
|
const bounds = parseBounds(cab.shelf_boundaries);
|
||||||
|
const hasPhoto = !!cab.photo_filename;
|
||||||
|
const stats = getBookStats(cab, 'cabinet');
|
||||||
|
const bndPlugins = pluginsByTarget('boundary_detector', 'shelves');
|
||||||
|
const pluginResults = parseBndPluginResults(cab.ai_shelf_boundaries);
|
||||||
|
const pluginIds = Object.keys(pluginResults);
|
||||||
|
const sel = (_bnd?.nodeId === cab.id) ? _bnd.selectedPlugin
|
||||||
|
: (cab.shelves.length > 0 ? null : pluginIds[0] ?? null);
|
||||||
|
const selOpts = [
|
||||||
|
`<option value="">None</option>`,
|
||||||
|
...pluginIds.map(pid => `<option value="${pid}"${sel===pid?' selected':''}>${pid}</option>`),
|
||||||
|
...(pluginIds.length > 1 ? [`<option value="all"${sel==='all'?' selected':''}>All</option>`] : []),
|
||||||
|
].join('');
|
||||||
|
return `<div>
|
||||||
|
${vAiProgressBar(stats)}
|
||||||
|
${hasPhoto
|
||||||
|
? `<div class="img-wrap" id="bnd-wrap" data-type="cabinet" data-id="${cab.id}">
|
||||||
|
<img id="bnd-img" src="/images/${cab.photo_filename}?t=${Date.now()}" alt="">
|
||||||
|
<canvas id="bnd-canvas"></canvas>
|
||||||
|
</div>`
|
||||||
|
: `<div class="empty"><div class="ei">📷</div><div>Upload a cabinet photo (📷 in header) to get started</div></div>`}
|
||||||
|
${hasPhoto ? `<p style="font-size:.72rem;color:#94a3b8;margin-top:8px">Drag lines · Ctrl+Alt+Click to add · Snap to AI guides</p>` : ''}
|
||||||
|
${hasPhoto ? `<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:6px">
|
||||||
|
${bounds.length ? `<span style="font-size:.72rem;color:#64748b">${cab.shelves.length} shelf${cab.shelves.length!==1?'s':''} · ${bounds.length} boundar${bounds.length!==1?'ies':'y'}</span>` : ''}
|
||||||
|
<div style="display:flex;gap:4px;align-items:center;flex-wrap:wrap;margin-left:auto">
|
||||||
|
${bndPlugins.map(p => vPluginBtn(p, cab.id, 'cabinets')).join('')}
|
||||||
|
<select data-a="select-bnd-plugin" style="font-size:.72rem;padding:2px 5px;border:1px solid #e2e8f0;border-radius:4px;background:white;color:#475569">${selOpts}</select>
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shelf detail ─────────────────────────────────────────────────────────────
|
||||||
|
function vShelfDetail(shelf) {
|
||||||
|
const bounds = parseBounds(shelf.book_boundaries);
|
||||||
|
const stats = getBookStats(shelf, 'shelf');
|
||||||
|
const bndPlugins = pluginsByTarget('boundary_detector', 'books');
|
||||||
|
const pluginResults = parseBndPluginResults(shelf.ai_book_boundaries);
|
||||||
|
const pluginIds = Object.keys(pluginResults);
|
||||||
|
const sel = (_bnd?.nodeId === shelf.id) ? _bnd.selectedPlugin
|
||||||
|
: (shelf.books.length > 0 ? null : pluginIds[0] ?? null);
|
||||||
|
const selOpts = [
|
||||||
|
`<option value="">None</option>`,
|
||||||
|
...pluginIds.map(pid => `<option value="${pid}"${sel===pid?' selected':''}>${pid}</option>`),
|
||||||
|
...(pluginIds.length > 1 ? [`<option value="all"${sel==='all'?' selected':''}>All</option>`] : []),
|
||||||
|
].join('');
|
||||||
|
return `<div>
|
||||||
|
${vAiProgressBar(stats)}
|
||||||
|
<div class="img-wrap" id="bnd-wrap" data-type="shelf" data-id="${shelf.id}">
|
||||||
|
<img id="bnd-img" src="/api/shelves/${shelf.id}/image?t=${Date.now()}" alt=""
|
||||||
|
onerror="this.parentElement.innerHTML='<div class=empty style=padding:40px><div class=ei>📷</div><div>No image available — upload a cabinet photo first</div></div>'">
|
||||||
|
<canvas id="bnd-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:.72rem;color:#94a3b8;margin-top:8px">Drag lines · Ctrl+Alt+Click to add · Snap to AI guides</p>
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:6px">
|
||||||
|
${bounds.length ? `<span style="font-size:.72rem;color:#64748b">${shelf.books.length} book${shelf.books.length!==1?'s':''} · ${bounds.length} boundary${bounds.length!==1?'ies':''}</span>` : ''}
|
||||||
|
<div style="display:flex;gap:4px;align-items:center;flex-wrap:wrap;margin-left:auto">
|
||||||
|
${bndPlugins.map(p => vPluginBtn(p, shelf.id, 'shelves')).join('')}
|
||||||
|
<select data-a="select-bnd-plugin" style="font-size:.72rem;padding:2px 5px;border:1px solid #e2e8f0;border-radius:4px;background:white;color:#475569">${selOpts}</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Book detail ──────────────────────────────────────────────────────────────
|
||||||
|
function vBookDetail(b) {
|
||||||
|
const [sc, sl] = _STATUS_BADGE[b.identification_status] ?? _STATUS_BADGE.unidentified;
|
||||||
|
const recognizers = pluginsByCategory('text_recognizer');
|
||||||
|
const identifiers = pluginsByCategory('book_identifier');
|
||||||
|
const searchers = pluginsByCategory('archive_searcher');
|
||||||
|
const hasRawText = !!(b.raw_text || '').trim();
|
||||||
|
return `<div class="book-panel">
|
||||||
|
<div>
|
||||||
|
<div class="book-img-label">Spine</div>
|
||||||
|
<div class="book-img-box"><img src="/api/books/${b.id}/spine?t=${Date.now()}" alt=""
|
||||||
|
onerror="this.style.display='none'"></div>
|
||||||
|
${b.image_filename
|
||||||
|
? `<div class="book-img-label">Title page</div>
|
||||||
|
<div class="book-img-box"><img src="/images/${b.image_filename}" alt=""></div>`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||||
|
<span class="sbadge ${sc}" style="font-size:.7rem;padding:2px 7px">${sl}</span>
|
||||||
|
<span style="font-size:.72rem;color:#64748b">${b.identification_status ?? 'unidentified'}</span>
|
||||||
|
${b.analyzed_at ? `<span style="font-size:.68rem;color:#94a3b8;margin-left:auto">Identified ${b.analyzed_at.slice(0,10)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="fgroup">
|
||||||
|
<label class="flabel" style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
||||||
|
Recognition
|
||||||
|
${recognizers.map(p => vPluginBtn(p, b.id, 'books')).join('')}
|
||||||
|
${identifiers.map(p => vPluginBtn(p, b.id, 'books', !hasRawText)).join('')}
|
||||||
|
</label>
|
||||||
|
<textarea class="finput" id="d-raw-text" style="height:72px;font-family:monospace;font-size:.8rem" readonly>${esc(b.raw_text ?? '')}</textarea>
|
||||||
|
</div>
|
||||||
|
${searchers.length ? `<div class="fgroup">
|
||||||
|
<label class="flabel" style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
||||||
|
Archives
|
||||||
|
${searchers.map(p => vPluginBtn(p, b.id, 'books')).join('')}
|
||||||
|
</label>
|
||||||
|
</div>` : ''}
|
||||||
|
<div class="fgroup">
|
||||||
|
${candidateSugRows(b, 'title', 'd-title')}
|
||||||
|
<label class="flabel">Title</label>
|
||||||
|
<input class="finput" id="d-title" value="${esc(b.title ?? '')}"></div>
|
||||||
|
<div class="fgroup">
|
||||||
|
${candidateSugRows(b, 'author', 'd-author')}
|
||||||
|
<label class="flabel">Author</label>
|
||||||
|
<input class="finput" id="d-author" value="${esc(b.author ?? '')}"></div>
|
||||||
|
<div class="fgroup">
|
||||||
|
${candidateSugRows(b, 'year', 'd-year')}
|
||||||
|
<label class="flabel">Year</label>
|
||||||
|
<input class="finput" id="d-year" value="${esc(b.year ?? '')}" inputmode="numeric"></div>
|
||||||
|
<div class="fgroup">
|
||||||
|
${candidateSugRows(b, 'isbn', 'd-isbn')}
|
||||||
|
<label class="flabel">ISBN</label>
|
||||||
|
<input class="finput" id="d-isbn" value="${esc(b.isbn ?? '')}" inputmode="numeric"></div>
|
||||||
|
<div class="fgroup">
|
||||||
|
${candidateSugRows(b, 'publisher', 'd-pub')}
|
||||||
|
<label class="flabel">Publisher</label>
|
||||||
|
<input class="finput" id="d-pub" value="${esc(b.publisher ?? '')}"></div>
|
||||||
|
<div class="fgroup"><label class="flabel">Notes</label>
|
||||||
|
<textarea class="finput" id="d-notes">${esc(b.notes ?? '')}</textarea></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
65
static/js/editing.js
Normal file
65
static/js/editing.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/*
|
||||||
|
* editing.js
|
||||||
|
* Inline contenteditable name editing for tree nodes (blur-to-save, strips
|
||||||
|
* leading emoji prefix) and SortableJS drag-and-drop reorder wiring.
|
||||||
|
*
|
||||||
|
* SortableJS is loaded as an external CDN script (must precede this file).
|
||||||
|
* _sortables is managed entirely within this module; render() in init.js
|
||||||
|
* only needs to call initSortables() to refresh after a full re-render.
|
||||||
|
*
|
||||||
|
* Depends on: S (state.js); req, toast (api.js / helpers.js);
|
||||||
|
* walkTree (tree-render.js); Sortable (CDN global)
|
||||||
|
* Provides: attachEditables(), initSortables()
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── SortableJS instances (destroyed and recreated on each render) ─────────────
|
||||||
|
let _sortables = [];
|
||||||
|
|
||||||
|
// ── Inline name editing ──────────────────────────────────────────────────────
|
||||||
|
function attachEditables() {
|
||||||
|
document.querySelectorAll('[contenteditable=true]').forEach(el => {
|
||||||
|
el.dataset.orig = el.textContent.trim();
|
||||||
|
el.addEventListener('keydown', e => {
|
||||||
|
if (e.key==='Enter') { e.preventDefault(); el.blur(); }
|
||||||
|
if (e.key==='Escape') { el.textContent=el.dataset.orig; el.blur(); }
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
el.addEventListener('blur', async () => {
|
||||||
|
const val = el.textContent.trim();
|
||||||
|
if (!val||val===el.dataset.orig) { if(!val) el.textContent=el.dataset.orig; return; }
|
||||||
|
const newName = val.replace(/^[🏠📚]\s*/u,'').trim();
|
||||||
|
const {type, id} = el.dataset;
|
||||||
|
const url = {room:`/api/rooms/${id}`,cabinet:`/api/cabinets/${id}`,shelf:`/api/shelves/${id}`}[type];
|
||||||
|
if (!url) return;
|
||||||
|
try {
|
||||||
|
await req('PUT', url, {name: newName});
|
||||||
|
el.dataset.orig = el.textContent.trim();
|
||||||
|
walkTree(n=>{ if(n.id===id) n.name=newName; });
|
||||||
|
// Update sidebar label if editing from header (sidebar has non-editable nname spans)
|
||||||
|
const sideLabel = document.querySelector(`.node[data-id="${id}"] .nname`);
|
||||||
|
if (sideLabel && sideLabel !== el) {
|
||||||
|
const prefix = type==='room' ? '🏠 ' : type==='cabinet' ? '📚 ' : '';
|
||||||
|
sideLabel.textContent = prefix + newName;
|
||||||
|
}
|
||||||
|
} catch(err) { el.textContent=el.dataset.orig; toast('Rename failed: '+err.message); }
|
||||||
|
});
|
||||||
|
el.addEventListener('click', e=>e.stopPropagation());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SortableJS drag-and-drop ─────────────────────────────────────────────────
|
||||||
|
function initSortables() {
|
||||||
|
_sortables.forEach(s=>{ try{s.destroy();}catch(_){} });
|
||||||
|
_sortables = [];
|
||||||
|
document.querySelectorAll('.sortable-list').forEach(el => {
|
||||||
|
const type = el.dataset.type;
|
||||||
|
_sortables.push(Sortable.create(el, {
|
||||||
|
handle:'.drag-h', animation:120, ghostClass:'drag-ghost',
|
||||||
|
onEnd: async () => {
|
||||||
|
const ids = [...el.querySelectorAll(':scope > .node')].map(n=>n.dataset.id);
|
||||||
|
try { await req('PATCH',`/api/${type}/reorder`,{ids}); }
|
||||||
|
catch(err) { toast('Reorder failed'); await loadTree(); }
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
283
static/js/events.js
Normal file
283
static/js/events.js
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
/*
|
||||||
|
* events.js
|
||||||
|
* Event delegation and the central action dispatcher.
|
||||||
|
*
|
||||||
|
* Two delegated listeners (click + change) are attached to #app.
|
||||||
|
* A third click listener is attached to #photo-queue-overlay (outside #app).
|
||||||
|
* Both delegate through handle(action, dataset, event).
|
||||||
|
*
|
||||||
|
* Accordion helpers (getSiblingIds, accordionExpand) implement mobile
|
||||||
|
* expand-only behaviour: opening one node collapses its siblings.
|
||||||
|
*
|
||||||
|
* Depends on: S, _bnd, _batchState, _photoQueue (state.js);
|
||||||
|
* req (api.js); toast, isDesktop (helpers.js);
|
||||||
|
* walkTree, removeNode, findNode, parseBounds (tree-render.js /
|
||||||
|
* canvas-boundary.js); render, renderDetail, startBatchPolling
|
||||||
|
* (init.js); startCropMode (canvas-crop.js);
|
||||||
|
* triggerPhoto, collectQueueBooks, renderPhotoQueue (photo.js);
|
||||||
|
* drawBnd (canvas-boundary.js)
|
||||||
|
* Provides: handle(), getSiblingIds(), accordionExpand()
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Accordion helpers ────────────────────────────────────────────────────────
|
||||||
|
function getSiblingIds(id, type) {
|
||||||
|
if (!S.tree) return [];
|
||||||
|
if (type === 'room') return S.tree.filter(r => r.id !== id).map(r => r.id);
|
||||||
|
for (const r of S.tree) {
|
||||||
|
if (type === 'cabinet' && r.cabinets.some(c => c.id === id))
|
||||||
|
return r.cabinets.filter(c => c.id !== id).map(c => c.id);
|
||||||
|
for (const c of r.cabinets) {
|
||||||
|
if (type === 'shelf' && c.shelves.some(s => s.id === id))
|
||||||
|
return c.shelves.filter(s => s.id !== id).map(s => s.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function accordionExpand(id, type) {
|
||||||
|
if (!isDesktop()) getSiblingIds(id, type).forEach(sid => S.expanded.delete(sid));
|
||||||
|
S.expanded.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event delegation ─────────────────────────────────────────────────────────
|
||||||
|
document.getElementById('app').addEventListener('click', async e => {
|
||||||
|
const el = e.target.closest('[data-a]');
|
||||||
|
if (!el) return;
|
||||||
|
const d = el.dataset;
|
||||||
|
try { await handle(d.a, d, e); }
|
||||||
|
catch(err) { toast('Error: '+err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('app').addEventListener('change', async e => {
|
||||||
|
const el = e.target.closest('[data-a]');
|
||||||
|
if (!el) return;
|
||||||
|
const d = el.dataset;
|
||||||
|
try { await handle(d.a, d, e); }
|
||||||
|
catch(err) { toast('Error: '+err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Photo queue overlay is outside #app so needs its own listener
|
||||||
|
document.getElementById('photo-queue-overlay').addEventListener('click', async e => {
|
||||||
|
const el = e.target.closest('[data-a]');
|
||||||
|
if (!el) return;
|
||||||
|
const d = el.dataset;
|
||||||
|
try { await handle(d.a, d, e); }
|
||||||
|
catch(err) { toast('Error: ' + err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Action dispatcher ────────────────────────────────────────────────────────
|
||||||
|
async function handle(action, d, e) {
|
||||||
|
switch (action) {
|
||||||
|
|
||||||
|
case 'select': {
|
||||||
|
// Ignore if the click hit a button or editable inside the row
|
||||||
|
if (e?.target?.closest('button,[contenteditable]')) return;
|
||||||
|
if (!isDesktop()) {
|
||||||
|
// Mobile: room/cabinet/shelf → expand-only (accordion); books → nothing
|
||||||
|
if (d.type === 'room' || d.type === 'cabinet' || d.type === 'shelf') {
|
||||||
|
accordionExpand(d.id, d.type);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
S.selected = {type: d.type, id: d.id};
|
||||||
|
S._loading = {};
|
||||||
|
render(); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'deselect': {
|
||||||
|
S.selected = null;
|
||||||
|
render(); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'toggle': {
|
||||||
|
if (!isDesktop()) {
|
||||||
|
// Mobile: expand-only (no collapse to avoid accidental mistaps)
|
||||||
|
accordionExpand(d.id, d.type);
|
||||||
|
} else {
|
||||||
|
if (S.expanded.has(d.id)) { S.expanded.delete(d.id); }
|
||||||
|
else { S.expanded.add(d.id); }
|
||||||
|
}
|
||||||
|
render(); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rooms
|
||||||
|
case 'add-room': {
|
||||||
|
const r = await req('POST','/api/rooms');
|
||||||
|
if (!S.tree) S.tree=[];
|
||||||
|
S.tree.push({...r, cabinets:[]});
|
||||||
|
S.expanded.add(r.id); render(); break;
|
||||||
|
}
|
||||||
|
case 'del-room': {
|
||||||
|
if (!confirm('Delete room and all contents?')) break;
|
||||||
|
await req('DELETE',`/api/rooms/${d.id}`);
|
||||||
|
removeNode('room',d.id);
|
||||||
|
if (S.selected?.id===d.id) S.selected=null;
|
||||||
|
render(); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cabinets
|
||||||
|
case 'add-cabinet': {
|
||||||
|
const c = await req('POST',`/api/rooms/${d.id}/cabinets`);
|
||||||
|
S.tree.forEach(r=>{ if(r.id===d.id) r.cabinets.push({...c,shelves:[]}); });
|
||||||
|
S.expanded.add(d.id); render(); break; // expand parent room
|
||||||
|
}
|
||||||
|
case 'del-cabinet': {
|
||||||
|
if (!confirm('Delete cabinet and all contents?')) break;
|
||||||
|
await req('DELETE',`/api/cabinets/${d.id}`);
|
||||||
|
removeNode('cabinet',d.id);
|
||||||
|
if (S.selected?.id===d.id) S.selected=null;
|
||||||
|
render(); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shelves
|
||||||
|
case 'add-shelf': {
|
||||||
|
const cab = findNode(d.id);
|
||||||
|
const prevCount = cab ? cab.shelves.length : 0;
|
||||||
|
const s = await req('POST',`/api/cabinets/${d.id}/shelves`);
|
||||||
|
S.tree.forEach(r=>r.cabinets.forEach(c=>{ if(c.id===d.id) c.shelves.push({...s,books:[]}); }));
|
||||||
|
if (prevCount > 0) {
|
||||||
|
// Split last segment in half to make room for new shelf
|
||||||
|
const bounds = parseBounds(cab.shelf_boundaries);
|
||||||
|
const lastStart = bounds.length ? bounds[bounds.length-1] : 0.0;
|
||||||
|
const newBound = Math.round((lastStart + 1.0) / 2 * 10000) / 10000;
|
||||||
|
const newBounds = [...bounds, newBound];
|
||||||
|
await req('PATCH', `/api/cabinets/${d.id}/boundaries`, {boundaries: newBounds});
|
||||||
|
S.tree.forEach(r=>r.cabinets.forEach(c=>{ if(c.id===d.id) c.shelf_boundaries=JSON.stringify(newBounds); }));
|
||||||
|
}
|
||||||
|
S.expanded.add(d.id); render(); break; // expand parent cabinet
|
||||||
|
}
|
||||||
|
case 'del-shelf': {
|
||||||
|
if (!confirm('Delete shelf and all books?')) break;
|
||||||
|
await req('DELETE',`/api/shelves/${d.id}`);
|
||||||
|
removeNode('shelf',d.id);
|
||||||
|
if (S.selected?.id===d.id) S.selected=null;
|
||||||
|
render(); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Books
|
||||||
|
case 'add-book': {
|
||||||
|
const shelf = findNode(d.id);
|
||||||
|
const prevCount = shelf ? shelf.books.length : 0;
|
||||||
|
const b = await req('POST',`/api/shelves/${d.id}/books`);
|
||||||
|
S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>{ if(s.id===d.id) s.books.push(b); })));
|
||||||
|
if (prevCount > 0) {
|
||||||
|
// Split last segment in half to make room for new book
|
||||||
|
const bounds = parseBounds(shelf.book_boundaries);
|
||||||
|
const lastStart = bounds.length ? bounds[bounds.length-1] : 0.0;
|
||||||
|
const newBound = Math.round((lastStart + 1.0) / 2 * 10000) / 10000;
|
||||||
|
const newBounds = [...bounds, newBound];
|
||||||
|
await req('PATCH', `/api/shelves/${d.id}/boundaries`, {boundaries: newBounds});
|
||||||
|
S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>{ if(s.id===d.id) s.book_boundaries=JSON.stringify(newBounds); })));
|
||||||
|
}
|
||||||
|
S.expanded.add(d.id); render(); break; // expand parent shelf
|
||||||
|
}
|
||||||
|
case 'del-book': {
|
||||||
|
if (!confirm('Delete this book?')) break;
|
||||||
|
await req('DELETE',`/api/books/${d.id}`);
|
||||||
|
removeNode('book',d.id);
|
||||||
|
if (S.selected?.id===d.id) S.selected=null;
|
||||||
|
render(); break;
|
||||||
|
}
|
||||||
|
case 'del-book-confirm': {
|
||||||
|
if (!confirm('Delete this book?')) break;
|
||||||
|
await req('DELETE',`/api/books/${d.id}`);
|
||||||
|
removeNode('book',d.id);
|
||||||
|
S.selected=null; render(); break;
|
||||||
|
}
|
||||||
|
case 'save-book': {
|
||||||
|
const data = {
|
||||||
|
title: document.getElementById('d-title')?.value || '',
|
||||||
|
author: document.getElementById('d-author')?.value || '',
|
||||||
|
year: document.getElementById('d-year')?.value || '',
|
||||||
|
isbn: document.getElementById('d-isbn')?.value || '',
|
||||||
|
publisher: document.getElementById('d-pub')?.value || '',
|
||||||
|
notes: document.getElementById('d-notes')?.value || '',
|
||||||
|
};
|
||||||
|
const res = await req('PUT',`/api/books/${d.id}`,data);
|
||||||
|
walkTree(n => {
|
||||||
|
if (n.id === d.id) {
|
||||||
|
Object.assign(n, data);
|
||||||
|
n.ai_title = data.title; n.ai_author = data.author; n.ai_year = data.year;
|
||||||
|
n.ai_isbn = data.isbn; n.ai_publisher = data.publisher;
|
||||||
|
n.identification_status = res.identification_status ?? n.identification_status;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
toast('Saved'); render(); break;
|
||||||
|
}
|
||||||
|
case 'run-plugin': {
|
||||||
|
const key = `${d.plugin}:${d.id}`;
|
||||||
|
S._loading[key] = true; renderDetail();
|
||||||
|
try {
|
||||||
|
const res = await req('POST', `/api/${d.etype}/${d.id}/plugin/${d.plugin}`);
|
||||||
|
walkTree(n => { if (n.id === d.id) Object.assign(n, res); });
|
||||||
|
} catch(err) { toast(`${d.plugin} failed: ${err.message}`); }
|
||||||
|
delete S._loading[key]; renderDetail();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'select-bnd-plugin': {
|
||||||
|
if (_bnd) { _bnd.selectedPlugin = e.target.value || null; drawBnd(); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'accept-field': {
|
||||||
|
const inp = document.getElementById(d.input);
|
||||||
|
if (inp) inp.value = d.value;
|
||||||
|
walkTree(n => { if (n.id === d.id) n[d.field] = d.value; });
|
||||||
|
renderDetail(); break;
|
||||||
|
}
|
||||||
|
case 'dismiss-field': {
|
||||||
|
const res = await req('POST', `/api/books/${d.id}/dismiss-field`, {field: d.field, value: d.value || ''});
|
||||||
|
walkTree(n => {
|
||||||
|
if (n.id === d.id) {
|
||||||
|
n.candidates = JSON.stringify(res.candidates || []);
|
||||||
|
if (!d.value) n[`ai_${d.field}`] = n[d.field] || '';
|
||||||
|
n.identification_status = res.identification_status ?? n.identification_status;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
renderDetail(); break;
|
||||||
|
}
|
||||||
|
case 'batch-start': {
|
||||||
|
const res = await req('POST', '/api/batch');
|
||||||
|
if (res.already_running) { toast('Batch already running'); break; }
|
||||||
|
if (!res.started) { toast('No unidentified books'); break; }
|
||||||
|
_batchState = {running: true, total: res.total, done: 0, errors: 0, current: ''};
|
||||||
|
startBatchPolling(); renderDetail(); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photo
|
||||||
|
case 'photo': triggerPhoto(d.type, d.id); break;
|
||||||
|
|
||||||
|
// Crop
|
||||||
|
case 'crop-start': startCropMode(d.type, d.id); break;
|
||||||
|
|
||||||
|
// Photo queue
|
||||||
|
case 'photo-queue-start': {
|
||||||
|
const node = findNode(d.id);
|
||||||
|
if (!node) break;
|
||||||
|
const books = collectQueueBooks(node, d.type);
|
||||||
|
if (!books.length) { toast('No unidentified books'); break; }
|
||||||
|
_photoQueue = {books, index: 0, processing: false};
|
||||||
|
renderPhotoQueue();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'photo-queue-take': {
|
||||||
|
if (!_photoQueue) break;
|
||||||
|
const book = _photoQueue.books[_photoQueue.index];
|
||||||
|
if (!book) break;
|
||||||
|
triggerPhoto('book', book.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'photo-queue-skip': {
|
||||||
|
if (!_photoQueue) break;
|
||||||
|
_photoQueue.index++;
|
||||||
|
renderPhotoQueue();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'photo-queue-close': {
|
||||||
|
_photoQueue = null;
|
||||||
|
renderPhotoQueue();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
21
static/js/helpers.js
Normal file
21
static/js/helpers.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* helpers.js
|
||||||
|
* Pure utility functions with no dependencies on other application modules.
|
||||||
|
* Safe to call from any JS file.
|
||||||
|
*
|
||||||
|
* Provides: esc(), toast(), isDesktop()
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg, dur = 2800) {
|
||||||
|
const el = document.getElementById('toast');
|
||||||
|
el.textContent = msg; el.classList.add('on');
|
||||||
|
clearTimeout(toast._t);
|
||||||
|
toast._t = setTimeout(() => el.classList.remove('on'), dur);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDesktop() { return window.innerWidth >= 768; }
|
||||||
82
static/js/init.js
Normal file
82
static/js/init.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/*
|
||||||
|
* init.js
|
||||||
|
* Application bootstrap: full render, partial detail re-render, config and
|
||||||
|
* tree loading, batch-status polling, and the initial Promise.all boot call.
|
||||||
|
*
|
||||||
|
* render() is the single source of truth for full repaints — it replaces
|
||||||
|
* #app innerHTML, re-attaches editables, reinitialises Sortable instances,
|
||||||
|
* and (on desktop) schedules the boundary canvas setup.
|
||||||
|
*
|
||||||
|
* renderDetail() does a cheaper in-place update of the right panel only,
|
||||||
|
* used during plugin runs and field edits to avoid re-rendering the sidebar.
|
||||||
|
*
|
||||||
|
* Depends on: S, _plugins, _batchState, _batchPollTimer (state.js);
|
||||||
|
* req, toast (api.js / helpers.js); isDesktop (helpers.js);
|
||||||
|
* vApp, vDetailBody, mainTitle, mainHeaderBtns, vBatchBtn
|
||||||
|
* (tree-render.js / detail-render.js);
|
||||||
|
* attachEditables, initSortables (editing.js);
|
||||||
|
* setupDetailCanvas (canvas-boundary.js)
|
||||||
|
* Provides: render(), renderDetail(), loadConfig(), startBatchPolling(),
|
||||||
|
* loadTree()
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Full re-render ────────────────────────────────────────────────────────────
|
||||||
|
function render() {
|
||||||
|
if (document.activeElement?.contentEditable === 'true') return;
|
||||||
|
const sy = window.scrollY;
|
||||||
|
document.getElementById('app').innerHTML = vApp();
|
||||||
|
window.scrollTo(0, sy);
|
||||||
|
attachEditables();
|
||||||
|
initSortables();
|
||||||
|
if (isDesktop()) requestAnimationFrame(setupDetailCanvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Right-panel partial re-render ─────────────────────────────────────────────
|
||||||
|
// Used during plugin runs and field edits to avoid re-rendering the sidebar.
|
||||||
|
function renderDetail() {
|
||||||
|
const body = document.getElementById('main-body');
|
||||||
|
if (body) body.innerHTML = vDetailBody();
|
||||||
|
const t = document.getElementById('main-title');
|
||||||
|
if (t) t.innerHTML = mainTitle(); // innerHTML: mainTitle() returns an HTML span
|
||||||
|
const hb = document.getElementById('main-hdr-btns');
|
||||||
|
if (hb) hb.innerHTML = mainHeaderBtns();
|
||||||
|
const bb = document.getElementById('main-hdr-batch');
|
||||||
|
if (bb) bb.innerHTML = vBatchBtn();
|
||||||
|
attachEditables(); // pick up the new editable span in the header
|
||||||
|
requestAnimationFrame(setupDetailCanvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data loading ──────────────────────────────────────────────────────────────
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const cfg = await req('GET','/api/config');
|
||||||
|
window._grabPx = cfg.boundary_grab_px ?? 14;
|
||||||
|
window._confidenceThreshold = cfg.confidence_threshold ?? 0.8;
|
||||||
|
_plugins = cfg.plugins || [];
|
||||||
|
} catch { window._grabPx = 14; window._confidenceThreshold = 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startBatchPolling() {
|
||||||
|
if (_batchPollTimer) clearInterval(_batchPollTimer);
|
||||||
|
_batchPollTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const st = await req('GET', '/api/batch/status');
|
||||||
|
_batchState = st;
|
||||||
|
const bb = document.getElementById('main-hdr-batch');
|
||||||
|
if (bb) bb.innerHTML = vBatchBtn();
|
||||||
|
if (!st.running) {
|
||||||
|
clearInterval(_batchPollTimer); _batchPollTimer = null;
|
||||||
|
toast(`Batch: ${st.done} done, ${st.errors} errors`);
|
||||||
|
await loadTree();
|
||||||
|
}
|
||||||
|
} catch { /* ignore poll errors */ }
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTree() {
|
||||||
|
S.tree = await req('GET','/api/tree');
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||||
|
Promise.all([loadConfig(), loadTree()]);
|
||||||
138
static/js/photo.js
Normal file
138
static/js/photo.js
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/*
|
||||||
|
* photo.js
|
||||||
|
* Photo upload for all entity types and the mobile Photo Queue feature.
|
||||||
|
*
|
||||||
|
* Photo upload:
|
||||||
|
* triggerPhoto(type, id) — opens the hidden file input, sets _photoTarget.
|
||||||
|
* The 'change' handler uploads via multipart POST, updates the tree node,
|
||||||
|
* and on mobile automatically runs the full AI pipeline for books
|
||||||
|
* (POST /api/books/{id}/process).
|
||||||
|
*
|
||||||
|
* Photo Queue (mobile-only UI):
|
||||||
|
* collectQueueBooks(node, type) — collects all non-approved books in tree
|
||||||
|
* order (top-to-bottom within each shelf, left-to-right across shelves).
|
||||||
|
* renderPhotoQueue() — updates the #photo-queue-overlay DOM in-place.
|
||||||
|
* Queue flow: show spine → tap camera → upload + process → auto-advance.
|
||||||
|
* Queue is stored in _photoQueue (state.js) so events.js can control it.
|
||||||
|
*
|
||||||
|
* Depends on: S, _photoQueue (state.js); req, toast (api.js / helpers.js);
|
||||||
|
* walkTree, findNode, esc (tree-render.js / helpers.js);
|
||||||
|
* isDesktop, render (helpers.js / init.js)
|
||||||
|
* Provides: collectQueueBooks(), renderPhotoQueue(), triggerPhoto()
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Photo Queue ──────────────────────────────────────────────────────────────
|
||||||
|
function collectQueueBooks(node, type) {
|
||||||
|
const books = [];
|
||||||
|
function collect(n, t) {
|
||||||
|
if (t === 'book') {
|
||||||
|
if (n.identification_status !== 'user_approved') books.push(n);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t === 'room') n.cabinets.forEach(c => collect(c, 'cabinet'));
|
||||||
|
if (t === 'cabinet') n.shelves.forEach(s => collect(s, 'shelf'));
|
||||||
|
if (t === 'shelf') n.books.forEach(b => collect(b, 'book'));
|
||||||
|
}
|
||||||
|
collect(node, type);
|
||||||
|
return books;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPhotoQueue() {
|
||||||
|
const el = document.getElementById('photo-queue-overlay');
|
||||||
|
if (!el) return;
|
||||||
|
if (!_photoQueue) { el.style.display = 'none'; el.innerHTML = ''; return; }
|
||||||
|
const {books, index, processing} = _photoQueue;
|
||||||
|
el.style.display = 'flex';
|
||||||
|
if (index >= books.length) {
|
||||||
|
el.innerHTML = `<div class="pq-hdr">
|
||||||
|
<button class="hbtn" data-a="photo-queue-close">✕</button>
|
||||||
|
<span class="pq-hdr-title">Photo Queue</span>
|
||||||
|
<span style="min-width:34px"></span>
|
||||||
|
</div>
|
||||||
|
<div class="pq-spine-wrap" style="text-align:center">
|
||||||
|
<div style="font-size:3rem">✓</div>
|
||||||
|
<div style="font-size:1.1rem;color:#86efac;font-weight:600">All done!</div>
|
||||||
|
<div style="font-size:.82rem;color:#94a3b8;margin-top:4px">All ${books.length} book${books.length !== 1 ? 's' : ''} photographed</div>
|
||||||
|
<button class="btn btn-p" style="margin-top:20px" data-a="photo-queue-close">Close</button>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const book = books[index];
|
||||||
|
el.innerHTML = `<div class="pq-hdr">
|
||||||
|
<button class="hbtn" data-a="photo-queue-close">✕</button>
|
||||||
|
<span class="pq-hdr-title">${index + 1} / ${books.length}</span>
|
||||||
|
<span style="min-width:34px"></span>
|
||||||
|
</div>
|
||||||
|
<div class="pq-spine-wrap">
|
||||||
|
<img class="pq-spine-img" src="/api/books/${book.id}/spine?t=${Date.now()}" alt="Spine"
|
||||||
|
onerror="this.style.display='none'">
|
||||||
|
<div class="pq-book-name">${esc(book.title || '—')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="pq-actions">
|
||||||
|
<button class="pq-skip-btn" data-a="photo-queue-skip">Skip</button>
|
||||||
|
<button class="pq-camera-btn" data-a="photo-queue-take">📷</button>
|
||||||
|
</div>
|
||||||
|
${processing ? '<div class="pq-processing"><div class="spinner"></div><span>Processing…</span></div>' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo upload ─────────────────────────────────────────────────────────────
|
||||||
|
const gphoto = document.getElementById('gphoto');
|
||||||
|
|
||||||
|
function triggerPhoto(type, id) {
|
||||||
|
S._photoTarget = {type, id};
|
||||||
|
if (/Android|iPhone|iPad/i.test(navigator.userAgent)) gphoto.setAttribute('capture','environment');
|
||||||
|
else gphoto.removeAttribute('capture');
|
||||||
|
gphoto.value = '';
|
||||||
|
gphoto.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
gphoto.addEventListener('change', async () => {
|
||||||
|
const file = gphoto.files[0];
|
||||||
|
if (!file || !S._photoTarget) return;
|
||||||
|
const {type, id} = S._photoTarget;
|
||||||
|
S._photoTarget = null;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('image', file, file.name); // HD — no client-side compression
|
||||||
|
const urls = {
|
||||||
|
room: `/api/rooms/${id}/photo`,
|
||||||
|
cabinet: `/api/cabinets/${id}/photo`,
|
||||||
|
shelf: `/api/shelves/${id}/photo`,
|
||||||
|
book: `/api/books/${id}/photo`,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await req('POST', urls[type], fd, true);
|
||||||
|
const key = type==='book' ? 'image_filename' : 'photo_filename';
|
||||||
|
walkTree(n=>{ if(n.id===id) n[key]=res[key]; });
|
||||||
|
// Photo queue mode: process and advance without full re-render
|
||||||
|
if (_photoQueue && type === 'book') {
|
||||||
|
_photoQueue.processing = true;
|
||||||
|
renderPhotoQueue();
|
||||||
|
const book = findNode(id);
|
||||||
|
if (book && book.identification_status !== 'user_approved') {
|
||||||
|
try {
|
||||||
|
const br = await req('POST', `/api/books/${id}/process`);
|
||||||
|
walkTree(n => { if (n.id === id) Object.assign(n, br); });
|
||||||
|
} catch { /* continue queue on process error */ }
|
||||||
|
}
|
||||||
|
_photoQueue.processing = false;
|
||||||
|
_photoQueue.index++;
|
||||||
|
renderPhotoQueue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
// Mobile: auto-queue AI after photo upload (books only)
|
||||||
|
if (!isDesktop()) {
|
||||||
|
if (type === 'book') {
|
||||||
|
const book = findNode(id);
|
||||||
|
if (book && book.identification_status !== 'user_approved') {
|
||||||
|
try {
|
||||||
|
const br = await req('POST', `/api/books/${id}/process`);
|
||||||
|
walkTree(n => { if(n.id===id) Object.assign(n, br); });
|
||||||
|
toast(`Photo saved · Identified (${br.identification_status})`);
|
||||||
|
render();
|
||||||
|
} catch { toast('Photo saved'); }
|
||||||
|
} else { toast('Photo saved'); }
|
||||||
|
} else { toast('Photo saved'); }
|
||||||
|
} else { toast('Photo saved'); }
|
||||||
|
} catch(err) { toast('Upload failed: '+err.message); }
|
||||||
|
});
|
||||||
41
static/js/state.js
Normal file
41
static/js/state.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* state.js
|
||||||
|
* All mutable application state — loaded first so every other module
|
||||||
|
* can read and write these globals without forward-reference issues.
|
||||||
|
*
|
||||||
|
* Provides:
|
||||||
|
* S — main UI state (tree data, selection, loading flags)
|
||||||
|
* _plugins — plugin manifest populated from GET /api/config
|
||||||
|
* _batchState — current batch-processing progress
|
||||||
|
* _batchPollTimer — setInterval handle for batch polling
|
||||||
|
* _bnd — live boundary-canvas state (written by canvas-boundary.js,
|
||||||
|
* read by detail-render.js)
|
||||||
|
* _photoQueue — photo queue session state (written by photo.js,
|
||||||
|
* read by events.js)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Main UI state ───────────────────────────────────────────────────────────
|
||||||
|
let S = {
|
||||||
|
tree: null,
|
||||||
|
expanded: new Set(),
|
||||||
|
selected: null, // {type:'cabinet'|'shelf'|'book', id}
|
||||||
|
_photoTarget: null, // {type, id}
|
||||||
|
_loading: {}, // {`${pluginId}:${entityId}`: true}
|
||||||
|
_cropMode: null, // {type, id} while crop UI is active
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Plugin registry ─────────────────────────────────────────────────────────
|
||||||
|
let _plugins = []; // populated from GET /api/config
|
||||||
|
|
||||||
|
// ── Batch processing state ──────────────────────────────────────────────────
|
||||||
|
let _batchState = {running: false, total: 0, done: 0, errors: 0, current: ''};
|
||||||
|
let _batchPollTimer = null;
|
||||||
|
|
||||||
|
// ── Boundary canvas live state ───────────────────────────────────────────────
|
||||||
|
// Owned by canvas-boundary.js; declared here so detail-render.js can read it
|
||||||
|
// without a circular load dependency.
|
||||||
|
let _bnd = null; // {wrap,img,canvas,axis,boundaries[],pluginResults{},selectedPlugin,segments[],nodeId,nodeType}
|
||||||
|
|
||||||
|
// ── Photo queue session state ────────────────────────────────────────────────
|
||||||
|
// Owned by photo.js; declared here so events.js can read/write it.
|
||||||
|
let _photoQueue = null; // {books:[...], index:0, processing:false}
|
||||||
321
static/js/tree-render.js
Normal file
321
static/js/tree-render.js
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
/*
|
||||||
|
* tree-render.js
|
||||||
|
* HTML-string generators for the entire sidebar tree and the app shell.
|
||||||
|
* Also owns the tree-mutation helpers (walkTree, removeNode, findNode)
|
||||||
|
* and plugin query helpers (pluginsByCategory, pluginsByTarget, isLoading).
|
||||||
|
*
|
||||||
|
* Depends on: S, _plugins, _batchState (state.js); esc, isDesktop (helpers.js)
|
||||||
|
* Provides: walkTree(), removeNode(), findNode(), pluginsByCategory(),
|
||||||
|
* pluginsByTarget(), isLoading(), vPluginBtn(), vBatchBtn(),
|
||||||
|
* SOURCE_LABELS, getSourceLabel(), parseCandidates(),
|
||||||
|
* candidateSugRows(), vApp(), mainTitle(), mainHeaderBtns(),
|
||||||
|
* vTreeBody(), vRoom(), vCabinet(), vShelf(), _STATUS_BADGE,
|
||||||
|
* vBook(), getBookStats(), vAiProgressBar()
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Plugin helpers ───────────────────────────────────────────────────────────
|
||||||
|
function pluginsByCategory(cat) { return _plugins.filter(p => p.category === cat); }
|
||||||
|
function pluginsByTarget(cat, target) { return _plugins.filter(p => p.category === cat && p.target === target); }
|
||||||
|
function isLoading(pluginId, entityId) { return !!S._loading[`${pluginId}:${entityId}`]; }
|
||||||
|
|
||||||
|
function vPluginBtn(plugin, entityId, entityType, extraDisabled = false) {
|
||||||
|
const loading = isLoading(plugin.id, entityId);
|
||||||
|
const label = loading ? '⏳' : esc(plugin.name);
|
||||||
|
return `<button class="btn btn-s" style="padding:2px 7px;font-size:.78rem;min-height:0"
|
||||||
|
data-a="run-plugin" data-plugin="${plugin.id}" data-id="${entityId}"
|
||||||
|
data-etype="${entityType}"${(loading||extraDisabled)?' disabled':''}
|
||||||
|
title="${esc(plugin.name)}">${label}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Batch button ─────────────────────────────────────────────────────────────
|
||||||
|
function vBatchBtn() {
|
||||||
|
if (_batchState.running)
|
||||||
|
return `<span style="font-size:.72rem;opacity:.8">${_batchState.done}/${_batchState.total} ⏳</span>`;
|
||||||
|
return `<button class="hbtn" data-a="batch-start" title="Analyze all unidentified books">🔄</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Candidate suggestion rows ────────────────────────────────────────────────
|
||||||
|
const SOURCE_LABELS = {
|
||||||
|
vlm: 'VLM', ai: 'AI', openlibrary: 'OpenLib',
|
||||||
|
rsl: 'РГБ', rusneb: 'НЭБ', alib: 'Alib', nlr: 'НЛР', shpl: 'ШПИЛ',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSourceLabel(source) {
|
||||||
|
if (SOURCE_LABELS[source]) return SOURCE_LABELS[source];
|
||||||
|
const p = _plugins.find(pl => pl.id === source);
|
||||||
|
return p ? p.name : source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCandidates(json) {
|
||||||
|
if (!json) return [];
|
||||||
|
try { return JSON.parse(json) || []; } catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidateSugRows(b, field, inputId) {
|
||||||
|
const userVal = (b[field] || '').trim();
|
||||||
|
const candidates = parseCandidates(b.candidates);
|
||||||
|
|
||||||
|
// Group by normalised value, collecting sources
|
||||||
|
const byVal = new Map(); // lower → {display, sources[]}
|
||||||
|
for (const c of candidates) {
|
||||||
|
const v = (c[field] || '').trim();
|
||||||
|
if (!v) continue;
|
||||||
|
const key = v.toLowerCase();
|
||||||
|
if (!byVal.has(key)) byVal.set(key, {display: v, sources: []});
|
||||||
|
const entry = byVal.get(key);
|
||||||
|
if (!entry.sources.includes(c.source)) entry.sources.push(c.source);
|
||||||
|
}
|
||||||
|
// Fallback: include legacy ai_* field if not already in candidates
|
||||||
|
const aiVal = (b[`ai_${field}`] || '').trim();
|
||||||
|
if (aiVal) {
|
||||||
|
const key = aiVal.toLowerCase();
|
||||||
|
if (!byVal.has(key)) byVal.set(key, {display: aiVal, sources: []});
|
||||||
|
const entry = byVal.get(key);
|
||||||
|
if (!entry.sources.includes('ai')) entry.sources.unshift('ai');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byVal.entries()]
|
||||||
|
.filter(([k]) => k !== userVal.toLowerCase())
|
||||||
|
.map(([, {display, sources}]) => {
|
||||||
|
const badges = sources.map(s =>
|
||||||
|
`<span class="src-badge src-${esc(s)}">${esc(getSourceLabel(s))}</span>`
|
||||||
|
).join(' ');
|
||||||
|
const val = esc(display);
|
||||||
|
return `<div class="ai-sug">
|
||||||
|
${badges} <em>${val}</em>
|
||||||
|
<button class="btn btn-g" style="padding:1px 6px;font-size:.75rem;min-height:0"
|
||||||
|
data-a="accept-field" data-id="${b.id}" data-field="${field}"
|
||||||
|
data-value="${val}" data-input="${inputId}" title="Accept">✓</button>
|
||||||
|
<button class="btn btn-r" style="padding:1px 6px;font-size:.75rem;min-height:0"
|
||||||
|
data-a="dismiss-field" data-id="${b.id}" data-field="${field}"
|
||||||
|
data-value="${val}" title="Dismiss">✗</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App shell ────────────────────────────────────────────────────────────────
|
||||||
|
function vApp() {
|
||||||
|
return `<div class="layout">
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="hdr"><h1 data-a="deselect" style="cursor:pointer" title="Back to overview">📚 Bookshelf</h1></div>
|
||||||
|
<div class="sidebar-body">
|
||||||
|
${vTreeBody()}
|
||||||
|
<button class="add-root" data-a="add-room">+ Add Room</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="main-panel">
|
||||||
|
<div class="main-hdr" id="main-hdr">
|
||||||
|
<h2 id="main-title">${mainTitle()}</h2>
|
||||||
|
<div id="main-hdr-batch">${vBatchBtn()}</div>
|
||||||
|
<div id="main-hdr-btns">${mainHeaderBtns()}</div>
|
||||||
|
</div>
|
||||||
|
<div class="main-body" id="main-body">${vDetailBody()}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mainTitle() {
|
||||||
|
if (!S.selected) return '<span style="opacity:.7">Select a room, cabinet or shelf</span>';
|
||||||
|
const n = findNode(S.selected.id);
|
||||||
|
const {type, id} = S.selected;
|
||||||
|
if (type === 'book') {
|
||||||
|
return `<span>${esc(n?.title || 'Untitled book')}</span>`;
|
||||||
|
}
|
||||||
|
const name = esc(n?.name || '');
|
||||||
|
return `<span class="hdr-edit" contenteditable="true" data-type="${type}" data-id="${id}" spellcheck="false">${name}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mainHeaderBtns() {
|
||||||
|
if (!S.selected) return '';
|
||||||
|
const {type, id} = S.selected;
|
||||||
|
if (type === 'room') {
|
||||||
|
return `<div style="display:flex;gap:2px">
|
||||||
|
<button class="hbtn" data-a="add-cabinet" data-id="${id}" title="Add cabinet">+</button>
|
||||||
|
<button class="hbtn" data-a="del-room" data-id="${id}" title="Delete room">🗑</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (type === 'cabinet') {
|
||||||
|
const cab = findNode(id);
|
||||||
|
return `<div style="display:flex;gap:2px">
|
||||||
|
<button class="hbtn" data-a="photo" data-type="cabinet" data-id="${id}" title="Upload photo">📷</button>
|
||||||
|
${cab?.photo_filename ? `<button class="hbtn" data-a="crop-start" data-type="cabinet" data-id="${id}" title="Crop photo">✂️</button>` : ''}
|
||||||
|
<button class="hbtn" data-a="del-cabinet" data-id="${id}" title="Delete cabinet">🗑</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (type === 'shelf') {
|
||||||
|
const shelf = findNode(id);
|
||||||
|
return `<div style="display:flex;gap:2px">
|
||||||
|
<button class="hbtn" data-a="photo" data-type="shelf" data-id="${id}" title="Upload override photo">📷</button>
|
||||||
|
${shelf?.photo_filename ? `<button class="hbtn" data-a="crop-start" data-type="shelf" data-id="${id}" title="Crop override photo">✂️</button>` : ''}
|
||||||
|
<button class="hbtn" data-a="del-shelf" data-id="${id}" title="Delete shelf">🗑</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (type === 'book') {
|
||||||
|
return `<div style="display:flex;gap:2px">
|
||||||
|
<button class="hbtn" data-a="save-book" data-id="${id}" title="Save">💾</button>
|
||||||
|
<button class="hbtn" data-a="photo" data-type="book" data-id="${id}" title="Upload title page">📷</button>
|
||||||
|
<button class="hbtn" data-a="del-book-confirm" data-id="${id}" title="Delete book">🗑</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tree body ────────────────────────────────────────────────────────────────
|
||||||
|
function vTreeBody() {
|
||||||
|
if (!S.tree) return '<div class="loading"><div class="spinner"></div>Loading…</div>';
|
||||||
|
if (!S.tree.length) return '<div class="empty"><div class="ei">📚</div><div>No rooms yet</div></div>';
|
||||||
|
return `<div class="sortable-list" data-type="rooms">${S.tree.map(vRoom).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vRoom(r) {
|
||||||
|
const exp = S.expanded.has(r.id);
|
||||||
|
const sel = S.selected?.id === r.id;
|
||||||
|
return `<div class="node" data-id="${r.id}" data-type="room">
|
||||||
|
<div class="nrow nrow-room${sel?' sel':''}" data-a="select" data-type="room" data-id="${r.id}">
|
||||||
|
<span class="drag-h">⠿</span>
|
||||||
|
<button class="tbtn ${exp?'':'col'}" data-a="toggle" data-type="room" data-id="${r.id}">▾</button>
|
||||||
|
<span class="nname" data-type="room" data-id="${r.id}">🏠 ${esc(r.name)}</span>
|
||||||
|
<div class="nacts">
|
||||||
|
<button class="ibtn" data-a="add-cabinet" data-id="${r.id}" title="Add cabinet">+</button>
|
||||||
|
<button class="ibtn" data-a="del-room" data-id="${r.id}" title="Delete">🗑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${exp ? `<div class="nchildren"><div class="sortable-list" data-type="cabinets" data-parent="${r.id}">
|
||||||
|
${r.cabinets.map(vCabinet).join('')}
|
||||||
|
</div></div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vCabinet(c) {
|
||||||
|
const exp = S.expanded.has(c.id);
|
||||||
|
const sel = S.selected?.id === c.id;
|
||||||
|
return `<div class="node" data-id="${c.id}" data-type="cabinet">
|
||||||
|
<div class="nrow nrow-cabinet${sel?' sel':''}" data-a="select" data-type="cabinet" data-id="${c.id}">
|
||||||
|
<span class="drag-h">⠿</span>
|
||||||
|
<button class="tbtn ${exp?'':'col'}" data-a="toggle" data-type="cabinet" data-id="${c.id}">▾</button>
|
||||||
|
${c.photo_filename ? `<img src="/images/${c.photo_filename}" style="width:26px;height:32px;object-fit:cover;border-radius:2px;flex-shrink:0" alt="">` : ''}
|
||||||
|
<span class="nname" data-type="cabinet" data-id="${c.id}">📚 ${esc(c.name)}</span>
|
||||||
|
<div class="nacts">
|
||||||
|
${!isDesktop() ? `<button class="ibtn" data-a="photo" data-type="cabinet" data-id="${c.id}" title="Photo">📷</button>` : ''}
|
||||||
|
${!isDesktop() ? `<button class="ibtn" data-a="photo-queue-start" data-type="cabinet" data-id="${c.id}" title="Book photo queue">📸</button>` : ''}
|
||||||
|
<button class="ibtn" data-a="add-shelf" data-id="${c.id}" title="Add shelf">+</button>
|
||||||
|
${!isDesktop() ? `<button class="ibtn" data-a="del-cabinet" data-id="${c.id}" title="Delete">🗑</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${exp ? `<div class="nchildren"><div class="sortable-list" data-type="shelves" data-parent="${c.id}">
|
||||||
|
${c.shelves.map(vShelf).join('')}
|
||||||
|
</div></div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vShelf(s) {
|
||||||
|
const exp = S.expanded.has(s.id);
|
||||||
|
const sel = S.selected?.id === s.id;
|
||||||
|
return `<div class="node" data-id="${s.id}" data-type="shelf">
|
||||||
|
<div class="nrow nrow-shelf${sel?' sel':''}" data-a="select" data-type="shelf" data-id="${s.id}">
|
||||||
|
<span class="drag-h">⠿</span>
|
||||||
|
<button class="tbtn ${exp?'':'col'}" data-a="toggle" data-type="shelf" data-id="${s.id}">▾</button>
|
||||||
|
<span class="nname" data-type="shelf" data-id="${s.id}">${esc(s.name)}</span>
|
||||||
|
<div class="nacts">
|
||||||
|
${!isDesktop() ? `<button class="ibtn" data-a="photo" data-type="shelf" data-id="${s.id}" title="Photo">📷</button>` : ''}
|
||||||
|
${!isDesktop() ? `<button class="ibtn" data-a="photo-queue-start" data-type="shelf" data-id="${s.id}" title="Book photo queue">📸</button>` : ''}
|
||||||
|
<button class="ibtn" data-a="add-book" data-id="${s.id}" title="Add book">+</button>
|
||||||
|
${!isDesktop() ? `<button class="ibtn" data-a="del-shelf" data-id="${s.id}" title="Delete">🗑</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${exp ? `<div class="nchildren"><div class="sortable-list" data-type="books" data-parent="${s.id}">
|
||||||
|
${s.books.map(vBook).join('')}
|
||||||
|
</div></div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _STATUS_BADGE = {
|
||||||
|
unidentified: ['s-unid', '?'],
|
||||||
|
ai_identified: ['s-aiid', 'AI'],
|
||||||
|
user_approved: ['s-appr', '✓'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function vBook(b) {
|
||||||
|
const [sc, sl] = _STATUS_BADGE[b.identification_status] ?? _STATUS_BADGE.unidentified;
|
||||||
|
const sub = [b.author, b.year].filter(Boolean).join(' · ');
|
||||||
|
const sel = S.selected?.id === b.id;
|
||||||
|
return `<div class="node" data-id="${b.id}" data-type="book">
|
||||||
|
<div class="nrow nrow-book${sel?' sel':''}" data-a="select" data-type="book" data-id="${b.id}">
|
||||||
|
<span class="drag-h">⠿</span>
|
||||||
|
<span class="sbadge ${sc}" title="${b.identification_status ?? 'unidentified'}">${sl}</span>
|
||||||
|
${b.image_filename ? `<img src="/images/${b.image_filename}" class="bthumb" alt="">` : `<div class="bthumb-ph">📖</div>`}
|
||||||
|
<div class="binfo">
|
||||||
|
<div class="bttl">${esc(b.title || '—')}</div>
|
||||||
|
${sub ? `<div class="bsub">${esc(sub)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
${!isDesktop() ? `<div class="nacts">
|
||||||
|
<button class="ibtn" data-a="photo" data-type="book" data-id="${b.id}" title="Upload photo">📷</button>
|
||||||
|
<button class="ibtn" data-a="del-book" data-id="${b.id}" title="Delete">🗑</button>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Book stats helper (recursive) ────────────────────────────────────────────
|
||||||
|
function getBookStats(node, type) {
|
||||||
|
const books = [];
|
||||||
|
function collect(n, t) {
|
||||||
|
if (t==='book') { books.push(n); return; }
|
||||||
|
if (t==='room') (n.cabinets||[]).forEach(c => collect(c,'cabinet'));
|
||||||
|
if (t==='cabinet') (n.shelves||[]).forEach(s => collect(s,'shelf'));
|
||||||
|
if (t==='shelf') (n.books||[]).forEach(b => collect(b,'book'));
|
||||||
|
}
|
||||||
|
collect(node, type);
|
||||||
|
return {
|
||||||
|
total: books.length,
|
||||||
|
approved: books.filter(b=>b.identification_status==='user_approved').length,
|
||||||
|
ai: books.filter(b=>b.identification_status==='ai_identified').length,
|
||||||
|
unidentified: books.filter(b=>b.identification_status==='unidentified').length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function vAiProgressBar(stats) {
|
||||||
|
const {total, approved, ai, unidentified} = stats;
|
||||||
|
if (!total || approved === total) return '';
|
||||||
|
const pA = (approved/total*100).toFixed(1);
|
||||||
|
const pI = (ai/total*100).toFixed(1);
|
||||||
|
const pU = (unidentified/total*100).toFixed(1);
|
||||||
|
return `<div style="margin-bottom:10px;background:white;border-radius:8px;padding:10px;box-shadow:0 1px 3px rgba(0,0,0,.07)">
|
||||||
|
<div style="display:flex;gap:8px;font-size:.7rem;margin-bottom:5px">
|
||||||
|
<span style="color:#15803d">✓ ${approved} approved</span><span style="color:#94a3b8">·</span>
|
||||||
|
<span style="color:#b45309">AI ${ai}</span><span style="color:#94a3b8">·</span>
|
||||||
|
<span style="color:#64748b">? ${unidentified} unidentified</span>
|
||||||
|
</div>
|
||||||
|
<div style="height:6px;border-radius:3px;background:#e2e8f0;overflow:hidden;display:flex">
|
||||||
|
<div style="width:${pA}%;background:#15803d;flex-shrink:0"></div>
|
||||||
|
<div style="width:${pI}%;background:#f59e0b;flex-shrink:0"></div>
|
||||||
|
<div style="width:${pU}%;background:#94a3b8;flex-shrink:0"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tree helpers ─────────────────────────────────────────────────────────────
|
||||||
|
function walkTree(fn) {
|
||||||
|
if (!S.tree) return;
|
||||||
|
for (const r of S.tree) { fn(r,'room');
|
||||||
|
for (const c of r.cabinets) { fn(c,'cabinet');
|
||||||
|
for (const s of c.shelves) { fn(s,'shelf');
|
||||||
|
for (const b of s.books) fn(b,'book');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeNode(type, id) {
|
||||||
|
if (!S.tree) return;
|
||||||
|
if (type==='room') S.tree = S.tree.filter(r=>r.id!==id);
|
||||||
|
if (type==='cabinet') S.tree.forEach(r=>r.cabinets=r.cabinets.filter(c=>c.id!==id));
|
||||||
|
if (type==='shelf') S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves=c.shelves.filter(s=>s.id!==id)));
|
||||||
|
if (type==='book') S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>s.books=s.books.filter(b=>b.id!==id))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNode(id) {
|
||||||
|
let found = null;
|
||||||
|
walkTree(n => { if (n.id===id) found=n; });
|
||||||
|
return found;
|
||||||
|
}
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
239
tests/js/pure-functions.test.js
Normal file
239
tests/js/pure-functions.test.js
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* pure-functions.test.js
|
||||||
|
* Unit tests for pure / side-effect-free functions extracted from static/js/*.
|
||||||
|
*
|
||||||
|
* Strategy: use node:vm runInNewContext to execute each browser script in a
|
||||||
|
* fresh sandbox. Function declarations at the top level of a script become
|
||||||
|
* properties of the sandbox context object, which is what we assert against.
|
||||||
|
* Files that reference the DOM at load-time (photo.js) receive a minimal stub.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { runInNewContext } from 'node:vm';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
|
||||||
|
// Values returned from VM sandboxes live in a different V8 realm, so
|
||||||
|
// deepStrictEqual rejects them even when structurally identical.
|
||||||
|
// JSON round-trip moves them into the current realm before comparison.
|
||||||
|
const j = (v) => JSON.parse(JSON.stringify(v));
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a browser script into a fresh VM sandbox and return the sandbox.
|
||||||
|
* A minimal DOM stub is merged with `extra` so top-level DOM calls don't throw.
|
||||||
|
*/
|
||||||
|
function load(relPath, extra = {}) {
|
||||||
|
const code = readFileSync(join(ROOT, relPath), 'utf8');
|
||||||
|
const el = {
|
||||||
|
textContent: '', value: '', files: [], style: {},
|
||||||
|
classList: { add() {}, remove() {} },
|
||||||
|
setAttribute() {}, removeAttribute() {}, click() {}, addEventListener() {},
|
||||||
|
};
|
||||||
|
const ctx = {
|
||||||
|
document: { getElementById: () => el, querySelector: () => null, querySelectorAll: () => [] },
|
||||||
|
window: { innerWidth: 800 },
|
||||||
|
navigator: { userAgent: '' },
|
||||||
|
clearTimeout() {}, setTimeout() {},
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
runInNewContext(code, ctx);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── esc (helpers.js) ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('esc: escapes HTML special characters', () => {
|
||||||
|
const { esc } = load('static/js/helpers.js');
|
||||||
|
assert.equal(esc('<b>text</b>'), '<b>text</b>');
|
||||||
|
assert.equal(esc('"quoted"'), '"quoted"');
|
||||||
|
assert.equal(esc('a & b'), 'a & b');
|
||||||
|
assert.equal(esc('<script>alert("xss")</script>'), '<script>alert("xss")</script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('esc: coerces null/undefined/number to string', () => {
|
||||||
|
const { esc } = load('static/js/helpers.js');
|
||||||
|
assert.equal(esc(null), '');
|
||||||
|
assert.equal(esc(undefined), '');
|
||||||
|
assert.equal(esc(42), '42');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── parseBounds (canvas-boundary.js) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
test('parseBounds: parses valid JSON array of fractions', () => {
|
||||||
|
const { parseBounds } = load('static/js/canvas-boundary.js');
|
||||||
|
assert.deepEqual(j(parseBounds('[0.25, 0.5, 0.75]')), [0.25, 0.5, 0.75]);
|
||||||
|
assert.deepEqual(j(parseBounds('[]')), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseBounds: returns [] for falsy / invalid / null-JSON input', () => {
|
||||||
|
const { parseBounds } = load('static/js/canvas-boundary.js');
|
||||||
|
assert.deepEqual(j(parseBounds(null)), []);
|
||||||
|
assert.deepEqual(j(parseBounds('')), []);
|
||||||
|
assert.deepEqual(j(parseBounds('not-json')), []);
|
||||||
|
assert.deepEqual(j(parseBounds('null')), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── parseBndPluginResults (canvas-boundary.js) ────────────────────────────────
|
||||||
|
|
||||||
|
test('parseBndPluginResults: parses a valid JSON object', () => {
|
||||||
|
const { parseBndPluginResults } = load('static/js/canvas-boundary.js');
|
||||||
|
assert.deepEqual(
|
||||||
|
j(parseBndPluginResults('{"p1":[0.3,0.6],"p2":[0.4]}')),
|
||||||
|
{ p1: [0.3, 0.6], p2: [0.4] }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseBndPluginResults: returns {} for null / array / invalid input', () => {
|
||||||
|
const { parseBndPluginResults } = load('static/js/canvas-boundary.js');
|
||||||
|
assert.deepEqual(j(parseBndPluginResults(null)), {});
|
||||||
|
assert.deepEqual(j(parseBndPluginResults('')), {});
|
||||||
|
assert.deepEqual(j(parseBndPluginResults('[1,2,3]')), {}); // arrays are rejected
|
||||||
|
assert.deepEqual(j(parseBndPluginResults('{bad}')), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── parseCandidates (tree-render.js) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Load tree-render.js with stubs for all globals it references in function bodies. */
|
||||||
|
function loadTreeRender() {
|
||||||
|
return load('static/js/tree-render.js', {
|
||||||
|
S: { selected: null, expanded: new Set(), _loading: {} },
|
||||||
|
_plugins: [],
|
||||||
|
_batchState: { running: false, done: 0, total: 0 },
|
||||||
|
_bnd: null,
|
||||||
|
esc: (s) => String(s ?? ''),
|
||||||
|
isDesktop: () => true,
|
||||||
|
findNode: () => null,
|
||||||
|
vDetailBody: () => '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('parseCandidates: parses a valid JSON array', () => {
|
||||||
|
const { parseCandidates } = loadTreeRender();
|
||||||
|
const input = [{ title: 'Foo', author: 'Bar', source: 'vlm' }];
|
||||||
|
assert.deepEqual(j(parseCandidates(JSON.stringify(input))), input);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseCandidates: returns [] for null / empty / invalid input', () => {
|
||||||
|
const { parseCandidates } = loadTreeRender();
|
||||||
|
assert.deepEqual(j(parseCandidates(null)), []);
|
||||||
|
assert.deepEqual(j(parseCandidates('')), []);
|
||||||
|
assert.deepEqual(j(parseCandidates('bad json')), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── getBookStats (tree-render.js) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeBook(status) {
|
||||||
|
return { id: Math.random(), identification_status: status, title: 'T' };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('getBookStats: counts books by status on a shelf', () => {
|
||||||
|
const { getBookStats } = loadTreeRender();
|
||||||
|
const shelf = {
|
||||||
|
id: 1,
|
||||||
|
books: [
|
||||||
|
makeBook('user_approved'),
|
||||||
|
makeBook('ai_identified'),
|
||||||
|
makeBook('unidentified'),
|
||||||
|
makeBook('unidentified'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const s = getBookStats(shelf, 'shelf');
|
||||||
|
assert.equal(s.total, 4);
|
||||||
|
assert.equal(s.approved, 1);
|
||||||
|
assert.equal(s.ai, 1);
|
||||||
|
assert.equal(s.unidentified, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getBookStats: aggregates across a full room → cabinet → shelf hierarchy', () => {
|
||||||
|
const { getBookStats } = loadTreeRender();
|
||||||
|
const room = {
|
||||||
|
id: 1,
|
||||||
|
cabinets: [{
|
||||||
|
id: 2,
|
||||||
|
shelves: [{
|
||||||
|
id: 3,
|
||||||
|
books: [makeBook('user_approved'), makeBook('unidentified'), makeBook('ai_identified')],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const s = getBookStats(room, 'room');
|
||||||
|
assert.equal(s.total, 3);
|
||||||
|
assert.equal(s.approved, 1);
|
||||||
|
assert.equal(s.ai, 1);
|
||||||
|
assert.equal(s.unidentified, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getBookStats: returns zeros for a book node itself', () => {
|
||||||
|
const { getBookStats } = loadTreeRender();
|
||||||
|
const book = makeBook('user_approved');
|
||||||
|
const s = getBookStats(book, 'book');
|
||||||
|
assert.equal(s.total, 1);
|
||||||
|
assert.equal(s.approved, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── collectQueueBooks (photo.js) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function loadPhoto() {
|
||||||
|
return load('static/js/photo.js', {
|
||||||
|
S: { _photoTarget: null },
|
||||||
|
_photoQueue: null,
|
||||||
|
req: async () => ({}),
|
||||||
|
toast: () => {},
|
||||||
|
walkTree: () => {},
|
||||||
|
findNode: () => null,
|
||||||
|
isDesktop: () => true,
|
||||||
|
render: () => {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('collectQueueBooks: excludes user_approved books from a shelf', () => {
|
||||||
|
const { collectQueueBooks } = loadPhoto();
|
||||||
|
const shelf = {
|
||||||
|
id: 1,
|
||||||
|
books: [
|
||||||
|
{ id: 2, identification_status: 'user_approved', title: 'A' },
|
||||||
|
{ id: 3, identification_status: 'unidentified', title: 'B' },
|
||||||
|
{ id: 4, identification_status: 'ai_identified', title: 'C' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = collectQueueBooks(shelf, 'shelf');
|
||||||
|
assert.equal(result.length, 2);
|
||||||
|
assert.deepEqual(j(result.map((b) => b.id)), [3, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectQueueBooks: collects across room → cabinet → shelf hierarchy', () => {
|
||||||
|
const { collectQueueBooks } = loadPhoto();
|
||||||
|
const room = {
|
||||||
|
id: 1,
|
||||||
|
cabinets: [{
|
||||||
|
id: 2,
|
||||||
|
shelves: [{
|
||||||
|
id: 3,
|
||||||
|
books: [
|
||||||
|
{ id: 4, identification_status: 'user_approved' },
|
||||||
|
{ id: 5, identification_status: 'unidentified' },
|
||||||
|
{ id: 6, identification_status: 'ai_identified' },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const result = collectQueueBooks(room, 'room');
|
||||||
|
assert.equal(result.length, 2);
|
||||||
|
assert.deepEqual(j(result.map((b) => b.id)), [5, 6]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectQueueBooks: returns empty array when all books are approved', () => {
|
||||||
|
const { collectQueueBooks } = loadPhoto();
|
||||||
|
const shelf = {
|
||||||
|
id: 1,
|
||||||
|
books: [
|
||||||
|
{ id: 2, identification_status: 'user_approved' },
|
||||||
|
{ id: 3, identification_status: 'user_approved' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.deepEqual(j(collectQueueBooks(shelf, 'shelf')), []);
|
||||||
|
});
|
||||||
190
tests/test_errors.py
Normal file
190
tests/test_errors.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""Tests for config and image error conditions, and exception attribute contracts."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from errors import (
|
||||||
|
ConfigFileError,
|
||||||
|
ConfigNotLoadedError,
|
||||||
|
ConfigValidationError,
|
||||||
|
ImageFileNotFoundError,
|
||||||
|
ImageReadError,
|
||||||
|
)
|
||||||
|
from logic.images import crop_save, prep_img_b64, serve_crop
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_png(tmp_path: Path, filename: str = "img.png") -> Path:
|
||||||
|
"""Write a minimal 4x4 red PNG to tmp_path and return its path."""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
path = tmp_path / filename
|
||||||
|
img = Image.new("RGB", (4, 4), color=(255, 0, 0))
|
||||||
|
img.save(path, format="PNG")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _make_corrupt(tmp_path: Path, filename: str = "bad.jpg") -> Path:
|
||||||
|
"""Write a file with invalid image bytes and return its path."""
|
||||||
|
path = tmp_path / filename
|
||||||
|
path.write_bytes(b"this is not an image\xff\xfe")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# ── ImageFileNotFoundError ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_prep_img_b64_file_not_found(tmp_path: Path) -> None:
|
||||||
|
missing = tmp_path / "missing.png"
|
||||||
|
with pytest.raises(ImageFileNotFoundError) as exc_info:
|
||||||
|
prep_img_b64(missing)
|
||||||
|
assert exc_info.value.path == missing
|
||||||
|
assert str(missing) in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_crop_save_file_not_found(tmp_path: Path) -> None:
|
||||||
|
missing = tmp_path / "missing.png"
|
||||||
|
with pytest.raises(ImageFileNotFoundError) as exc_info:
|
||||||
|
crop_save(missing, 0, 0, 2, 2)
|
||||||
|
assert exc_info.value.path == missing
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_crop_file_not_found(tmp_path: Path) -> None:
|
||||||
|
missing = tmp_path / "missing.png"
|
||||||
|
with pytest.raises(ImageFileNotFoundError) as exc_info:
|
||||||
|
serve_crop(missing, None)
|
||||||
|
assert exc_info.value.path == missing
|
||||||
|
|
||||||
|
|
||||||
|
# ── ImageReadError ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_prep_img_b64_corrupt_file(tmp_path: Path) -> None:
|
||||||
|
bad = _make_corrupt(tmp_path)
|
||||||
|
with pytest.raises(ImageReadError) as exc_info:
|
||||||
|
prep_img_b64(bad)
|
||||||
|
assert exc_info.value.path == bad
|
||||||
|
assert str(bad) in str(exc_info.value)
|
||||||
|
assert exc_info.value.reason # non-empty reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_crop_save_corrupt_file(tmp_path: Path) -> None:
|
||||||
|
bad = _make_corrupt(tmp_path)
|
||||||
|
with pytest.raises(ImageReadError) as exc_info:
|
||||||
|
crop_save(bad, 0, 0, 2, 2)
|
||||||
|
assert exc_info.value.path == bad
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_crop_corrupt_file(tmp_path: Path) -> None:
|
||||||
|
bad = _make_corrupt(tmp_path)
|
||||||
|
with pytest.raises(ImageReadError) as exc_info:
|
||||||
|
serve_crop(bad, None)
|
||||||
|
assert exc_info.value.path == bad
|
||||||
|
|
||||||
|
|
||||||
|
# ── prep_img_b64 success path ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_prep_img_b64_success(tmp_path: Path) -> None:
|
||||||
|
path = _make_png(tmp_path)
|
||||||
|
b64, mime = prep_img_b64(path)
|
||||||
|
assert mime == "image/png"
|
||||||
|
assert len(b64) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_prep_img_b64_with_crop(tmp_path: Path) -> None:
|
||||||
|
path = _make_png(tmp_path)
|
||||||
|
b64, mime = prep_img_b64(path, crop_frac=(0.0, 0.0, 0.5, 0.5))
|
||||||
|
assert mime == "image/png"
|
||||||
|
assert len(b64) > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Config exception attribute contracts ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_not_loaded_error() -> None:
|
||||||
|
exc = ConfigNotLoadedError()
|
||||||
|
assert "load_config" in str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_file_error() -> None:
|
||||||
|
path = Path("config/missing.yaml")
|
||||||
|
exc = ConfigFileError(path, "file not found")
|
||||||
|
assert exc.path == path
|
||||||
|
assert exc.reason == "file not found"
|
||||||
|
assert "missing.yaml" in str(exc)
|
||||||
|
assert "file not found" in str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_validation_error() -> None:
|
||||||
|
exc = ConfigValidationError("unexpected field 'foo'")
|
||||||
|
assert exc.reason == "unexpected field 'foo'"
|
||||||
|
assert "unexpected field" in str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Config loading errors ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_raises_on_invalid_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
import config as config_module
|
||||||
|
|
||||||
|
cfg_dir = tmp_path / "config"
|
||||||
|
cfg_dir.mkdir()
|
||||||
|
(cfg_dir / "credentials.default.yaml").write_text(": invalid: yaml: {\n")
|
||||||
|
# write empty valid files for other categories
|
||||||
|
for cat in ["models", "functions", "ui"]:
|
||||||
|
(cfg_dir / f"{cat}.default.yaml").write_text(f"{cat}: {{}}\n")
|
||||||
|
|
||||||
|
monkeypatch.setattr(config_module, "_CONFIG_DIR", cfg_dir)
|
||||||
|
with pytest.raises(ConfigFileError) as exc_info:
|
||||||
|
config_module.load_config()
|
||||||
|
assert exc_info.value.path == cfg_dir / "credentials.default.yaml"
|
||||||
|
assert exc_info.value.reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_raises_on_schema_mismatch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
import config as config_module
|
||||||
|
|
||||||
|
cfg_dir = tmp_path / "config"
|
||||||
|
cfg_dir.mkdir()
|
||||||
|
# credentials expects CredentialConfig but we give it a non-dict value
|
||||||
|
(cfg_dir / "credentials.default.yaml").write_text("credentials:\n openrouter: not_a_dict\n")
|
||||||
|
for cat in ["models", "functions", "ui"]:
|
||||||
|
(cfg_dir / f"{cat}.default.yaml").write_text("")
|
||||||
|
|
||||||
|
monkeypatch.setattr(config_module, "_CONFIG_DIR", cfg_dir)
|
||||||
|
with pytest.raises(ConfigValidationError) as exc_info:
|
||||||
|
config_module.load_config()
|
||||||
|
assert exc_info.value.reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_raises_if_not_loaded(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
import config as config_module
|
||||||
|
|
||||||
|
# Clear the holder to simulate unloaded state
|
||||||
|
original = list(config_module.config_holder)
|
||||||
|
config_module.config_holder.clear()
|
||||||
|
try:
|
||||||
|
with pytest.raises(ConfigNotLoadedError):
|
||||||
|
config_module.get_config()
|
||||||
|
finally:
|
||||||
|
config_module.config_holder.extend(original)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Image exception string representation ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_file_not_found_str() -> None:
|
||||||
|
exc = ImageFileNotFoundError(Path("/data/images/img.jpg"))
|
||||||
|
assert exc.path == Path("/data/images/img.jpg")
|
||||||
|
assert "img.jpg" in str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_read_error_str() -> None:
|
||||||
|
exc = ImageReadError(Path("/data/images/img.jpg"), "cannot identify image file")
|
||||||
|
assert exc.path == Path("/data/images/img.jpg")
|
||||||
|
assert exc.reason == "cannot identify image file"
|
||||||
|
assert "img.jpg" in str(exc)
|
||||||
|
assert "cannot identify image file" in str(exc)
|
||||||
585
tests/test_logic.py
Normal file
585
tests/test_logic.py
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
"""Unit tests for logic modules: boundary helpers, identification helpers, build_query, and all error conditions."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import db as db_module
|
||||||
|
import logic
|
||||||
|
from errors import (
|
||||||
|
BookNotFoundError,
|
||||||
|
CabinetNotFoundError,
|
||||||
|
InvalidPluginEntityError,
|
||||||
|
NoCabinetPhotoError,
|
||||||
|
NoRawTextError,
|
||||||
|
NoShelfImageError,
|
||||||
|
PluginNotFoundError,
|
||||||
|
PluginTargetMismatchError,
|
||||||
|
ShelfNotFoundError,
|
||||||
|
)
|
||||||
|
from logic.archive import run_archive_searcher
|
||||||
|
from logic.boundaries import book_spine_source, bounds_for_index, run_boundary_detector, shelf_source
|
||||||
|
from logic.identification import apply_ai_result, build_query, compute_status, dismiss_field, run_book_identifier
|
||||||
|
from models import (
|
||||||
|
AIIdentifyResult,
|
||||||
|
BoundaryDetectResult,
|
||||||
|
BookRow,
|
||||||
|
CandidateRecord,
|
||||||
|
PluginLookupResult,
|
||||||
|
TextRecognizeResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── BookRow factory ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _book(**kwargs: object) -> BookRow:
|
||||||
|
defaults: dict[str, object] = {
|
||||||
|
"id": "b1",
|
||||||
|
"shelf_id": "s1",
|
||||||
|
"position": 0,
|
||||||
|
"image_filename": None,
|
||||||
|
"title": "",
|
||||||
|
"author": "",
|
||||||
|
"year": "",
|
||||||
|
"isbn": "",
|
||||||
|
"publisher": "",
|
||||||
|
"notes": "",
|
||||||
|
"raw_text": "",
|
||||||
|
"ai_title": "",
|
||||||
|
"ai_author": "",
|
||||||
|
"ai_year": "",
|
||||||
|
"ai_isbn": "",
|
||||||
|
"ai_publisher": "",
|
||||||
|
"identification_status": "unidentified",
|
||||||
|
"title_confidence": 0.0,
|
||||||
|
"analyzed_at": None,
|
||||||
|
"created_at": "2024-01-01T00:00:00",
|
||||||
|
"candidates": None,
|
||||||
|
}
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return BookRow(**defaults) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
# ── DB fixture for integration tests ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def seeded_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Temporary DB with a single book row (full parent chain)."""
|
||||||
|
monkeypatch.setattr(db_module, "DB_PATH", tmp_path / "test.db")
|
||||||
|
db_module.init_db()
|
||||||
|
ts = "2024-01-01T00:00:00"
|
||||||
|
c = db_module.conn()
|
||||||
|
c.execute("INSERT INTO rooms VALUES (?,?,?,?)", ["r1", "Room", 1, ts])
|
||||||
|
c.execute("INSERT INTO cabinets VALUES (?,?,?,?,?,?,?,?)", ["c1", "r1", "Cabinet", None, None, None, 1, ts])
|
||||||
|
c.execute("INSERT INTO shelves VALUES (?,?,?,?,?,?,?,?)", ["s1", "c1", "Shelf", None, None, None, 1, ts])
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO books VALUES (?,?,0,NULL,'','','','','','','','','','','','','unidentified',0,NULL,?,NULL)",
|
||||||
|
["b1", "s1", ts],
|
||||||
|
)
|
||||||
|
c.commit()
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stub plugins ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _BoundaryDetectorStub:
|
||||||
|
"""Stub boundary detector that returns empty boundaries."""
|
||||||
|
|
||||||
|
plugin_id = "bd_stub"
|
||||||
|
name = "Stub BD"
|
||||||
|
auto_queue = False
|
||||||
|
target = "books"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_image_px(self) -> int:
|
||||||
|
return 1600
|
||||||
|
|
||||||
|
def detect(self, image_b64: str, image_mime: str) -> BoundaryDetectResult:
|
||||||
|
return {"boundaries": [0.5]}
|
||||||
|
|
||||||
|
|
||||||
|
class _BoundaryDetectorShelvesStub:
|
||||||
|
"""Stub boundary detector targeting shelves (for cabinet entity_type)."""
|
||||||
|
|
||||||
|
plugin_id = "bd_shelves_stub"
|
||||||
|
name = "Stub BD Shelves"
|
||||||
|
auto_queue = False
|
||||||
|
target = "shelves"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_image_px(self) -> int:
|
||||||
|
return 1600
|
||||||
|
|
||||||
|
def detect(self, image_b64: str, image_mime: str) -> BoundaryDetectResult:
|
||||||
|
return {"boundaries": []}
|
||||||
|
|
||||||
|
|
||||||
|
class _TextRecognizerStub:
|
||||||
|
"""Stub text recognizer that returns fixed text."""
|
||||||
|
|
||||||
|
plugin_id = "tr_stub"
|
||||||
|
name = "Stub TR"
|
||||||
|
auto_queue = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_image_px(self) -> int:
|
||||||
|
return 1600
|
||||||
|
|
||||||
|
def recognize(self, image_b64: str, image_mime: str) -> TextRecognizeResult:
|
||||||
|
return {"raw_text": "Stub Title", "title": "Stub Title", "author": "Stub Author"}
|
||||||
|
|
||||||
|
|
||||||
|
class _BookIdentifierStub:
|
||||||
|
"""Stub book identifier that returns a high-confidence result."""
|
||||||
|
|
||||||
|
plugin_id = "bi_stub"
|
||||||
|
name = "Stub BI"
|
||||||
|
auto_queue = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def confidence_threshold(self) -> float:
|
||||||
|
return 0.8
|
||||||
|
|
||||||
|
def identify(self, raw_text: str) -> AIIdentifyResult:
|
||||||
|
return {
|
||||||
|
"title": "Found Book",
|
||||||
|
"author": "Found Author",
|
||||||
|
"year": "2000",
|
||||||
|
"isbn": "",
|
||||||
|
"publisher": "",
|
||||||
|
"confidence": 0.9,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _ArchiveSearcherStub:
|
||||||
|
"""Stub archive searcher that returns an empty result list."""
|
||||||
|
|
||||||
|
plugin_id = "as_stub"
|
||||||
|
name = "Stub AS"
|
||||||
|
auto_queue = False
|
||||||
|
|
||||||
|
def search(self, query: str) -> list[CandidateRecord]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ── bounds_for_index ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounds_empty_boundaries() -> None:
|
||||||
|
assert bounds_for_index(None, 0) == (0.0, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounds_empty_json() -> None:
|
||||||
|
assert bounds_for_index("[]", 0) == (0.0, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounds_single_boundary_first() -> None:
|
||||||
|
assert bounds_for_index("[0.5]", 0) == (0.0, 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounds_single_boundary_second() -> None:
|
||||||
|
assert bounds_for_index("[0.5]", 1) == (0.5, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounds_multiple_boundaries() -> None:
|
||||||
|
b = "[0.25, 0.5, 0.75]"
|
||||||
|
assert bounds_for_index(b, 0) == (0.0, 0.25)
|
||||||
|
assert bounds_for_index(b, 1) == (0.25, 0.5)
|
||||||
|
assert bounds_for_index(b, 2) == (0.5, 0.75)
|
||||||
|
assert bounds_for_index(b, 3) == (0.75, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounds_out_of_range_returns_last_segment() -> None:
|
||||||
|
_, end = bounds_for_index("[0.5]", 99)
|
||||||
|
assert end == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── compute_status ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_status_unidentified_no_ai_title() -> None:
|
||||||
|
assert compute_status(_book(ai_title="", title="", author="", year="")) == "unidentified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_status_unidentified_empty() -> None:
|
||||||
|
assert compute_status(_book()) == "unidentified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_status_ai_identified() -> None:
|
||||||
|
book = _book(ai_title="Some Book", ai_author="Author", ai_year="2000", ai_isbn="", ai_publisher="")
|
||||||
|
assert compute_status(book) == "ai_identified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_status_user_approved() -> None:
|
||||||
|
book = _book(
|
||||||
|
ai_title="Some Book",
|
||||||
|
ai_author="Author",
|
||||||
|
ai_year="2000",
|
||||||
|
ai_isbn="",
|
||||||
|
ai_publisher="",
|
||||||
|
title="Some Book",
|
||||||
|
author="Author",
|
||||||
|
year="2000",
|
||||||
|
isbn="",
|
||||||
|
publisher="",
|
||||||
|
)
|
||||||
|
assert compute_status(book) == "user_approved"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_status_ai_identified_when_fields_differ() -> None:
|
||||||
|
book = _book(
|
||||||
|
ai_title="Some Book",
|
||||||
|
ai_author="Original Author",
|
||||||
|
ai_year="2000",
|
||||||
|
title="Some Book",
|
||||||
|
author="Different Author",
|
||||||
|
year="2000",
|
||||||
|
)
|
||||||
|
assert compute_status(book) == "ai_identified"
|
||||||
|
|
||||||
|
|
||||||
|
# ── build_query ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_query_from_candidates() -> None:
|
||||||
|
book = _book(candidates='[{"source": "x", "author": "Tolkien", "title": "LOTR"}]')
|
||||||
|
assert build_query(book) == "Tolkien LOTR"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_query_from_ai_fields() -> None:
|
||||||
|
book = _book(candidates="[]", ai_author="Pushkin", ai_title="Evgeny Onegin", raw_text="")
|
||||||
|
assert build_query(book) == "Pushkin Evgeny Onegin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_query_from_raw_text() -> None:
|
||||||
|
book = _book(candidates="[]", ai_author="", ai_title="", raw_text="some spine text")
|
||||||
|
assert build_query(book) == "some spine text"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_query_empty() -> None:
|
||||||
|
book = _book(candidates="[]", ai_author="", ai_title="", raw_text="")
|
||||||
|
assert build_query(book) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_query_candidates_prefer_first_nonempty() -> None:
|
||||||
|
book = _book(
|
||||||
|
candidates='[{"source":"a","author":"","title":""}, {"source":"b","author":"Auth","title":"Title"}]',
|
||||||
|
ai_author="other",
|
||||||
|
ai_title="other",
|
||||||
|
)
|
||||||
|
assert build_query(book) == "Auth Title"
|
||||||
|
|
||||||
|
|
||||||
|
# ── apply_ai_result ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_ai_result_high_confidence(seeded_db: None) -> None:
|
||||||
|
result: AIIdentifyResult = {
|
||||||
|
"title": "My Book",
|
||||||
|
"author": "J. Doe",
|
||||||
|
"year": "1999",
|
||||||
|
"isbn": "123",
|
||||||
|
"publisher": "Pub",
|
||||||
|
"confidence": 0.9,
|
||||||
|
}
|
||||||
|
apply_ai_result("b1", result, confidence_threshold=0.8)
|
||||||
|
with db_module.connection() as c:
|
||||||
|
book = db_module.get_book(c, "b1")
|
||||||
|
assert book is not None
|
||||||
|
assert book.ai_title == "My Book"
|
||||||
|
assert book.ai_author == "J. Doe"
|
||||||
|
assert abs(book.title_confidence - 0.9) < 1e-9
|
||||||
|
assert book.identification_status == "ai_identified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_ai_result_low_confidence_skips_fields(seeded_db: None) -> None:
|
||||||
|
result: AIIdentifyResult = {
|
||||||
|
"title": "My Book",
|
||||||
|
"author": "J. Doe",
|
||||||
|
"year": "1999",
|
||||||
|
"isbn": "",
|
||||||
|
"publisher": "",
|
||||||
|
"confidence": 0.5,
|
||||||
|
}
|
||||||
|
apply_ai_result("b1", result, confidence_threshold=0.8)
|
||||||
|
with db_module.connection() as c:
|
||||||
|
book = db_module.get_book(c, "b1")
|
||||||
|
assert book is not None
|
||||||
|
assert book.ai_title == "" # not updated
|
||||||
|
assert abs(book.title_confidence - 0.5) < 1e-9 # confidence stored regardless
|
||||||
|
assert book.identification_status == "unidentified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_ai_result_exact_threshold(seeded_db: None) -> None:
|
||||||
|
result: AIIdentifyResult = {
|
||||||
|
"title": "Book",
|
||||||
|
"author": "",
|
||||||
|
"year": "",
|
||||||
|
"isbn": "",
|
||||||
|
"publisher": "",
|
||||||
|
"confidence": 0.8,
|
||||||
|
}
|
||||||
|
apply_ai_result("b1", result, confidence_threshold=0.8)
|
||||||
|
with db_module.connection() as c:
|
||||||
|
book = db_module.get_book(c, "b1")
|
||||||
|
assert book is not None
|
||||||
|
assert book.ai_title == "Book"
|
||||||
|
|
||||||
|
|
||||||
|
# ── shelf_source error conditions ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_shelf_source_not_found(seeded_db: None) -> None:
|
||||||
|
with db_module.connection() as c:
|
||||||
|
with pytest.raises(ShelfNotFoundError) as exc_info:
|
||||||
|
shelf_source(c, "nonexistent")
|
||||||
|
assert exc_info.value.shelf_id == "nonexistent"
|
||||||
|
assert "nonexistent" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_shelf_source_no_image(seeded_db: None) -> None:
|
||||||
|
# s1 has no photo_filename and c1 has no photo_filename → NoShelfImageError
|
||||||
|
with db_module.connection() as c:
|
||||||
|
with pytest.raises(NoShelfImageError) as exc_info:
|
||||||
|
shelf_source(c, "s1")
|
||||||
|
assert exc_info.value.shelf_id == "s1"
|
||||||
|
assert exc_info.value.cabinet_id == "c1"
|
||||||
|
assert "s1" in str(exc_info.value)
|
||||||
|
assert "c1" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
# ── book_spine_source error conditions ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_book_spine_source_book_not_found(seeded_db: None) -> None:
|
||||||
|
with db_module.connection() as c:
|
||||||
|
with pytest.raises(BookNotFoundError) as exc_info:
|
||||||
|
book_spine_source(c, "nonexistent")
|
||||||
|
assert exc_info.value.book_id == "nonexistent"
|
||||||
|
assert "nonexistent" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_book_spine_source_propagates_no_shelf_image(seeded_db: None) -> None:
|
||||||
|
# b1 exists but s1 has no image → NoShelfImageError propagates through book_spine_source
|
||||||
|
with db_module.connection() as c:
|
||||||
|
with pytest.raises(NoShelfImageError) as exc_info:
|
||||||
|
book_spine_source(c, "b1")
|
||||||
|
assert exc_info.value.shelf_id == "s1"
|
||||||
|
assert exc_info.value.cabinet_id == "c1"
|
||||||
|
|
||||||
|
|
||||||
|
# ── run_boundary_detector error conditions ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_boundary_detector_cabinet_not_found(seeded_db: None) -> None:
|
||||||
|
plugin = _BoundaryDetectorShelvesStub()
|
||||||
|
with pytest.raises(CabinetNotFoundError) as exc_info:
|
||||||
|
run_boundary_detector(plugin, "cabinets", "nonexistent")
|
||||||
|
assert exc_info.value.cabinet_id == "nonexistent"
|
||||||
|
assert "nonexistent" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_boundary_detector_no_cabinet_photo(seeded_db: None) -> None:
|
||||||
|
# c1 exists but has no photo_filename
|
||||||
|
plugin = _BoundaryDetectorShelvesStub()
|
||||||
|
with pytest.raises(NoCabinetPhotoError) as exc_info:
|
||||||
|
run_boundary_detector(plugin, "cabinets", "c1")
|
||||||
|
assert exc_info.value.cabinet_id == "c1"
|
||||||
|
assert "c1" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_boundary_detector_shelf_not_found(seeded_db: None) -> None:
|
||||||
|
plugin = _BoundaryDetectorStub()
|
||||||
|
with pytest.raises(ShelfNotFoundError) as exc_info:
|
||||||
|
run_boundary_detector(plugin, "shelves", "nonexistent")
|
||||||
|
assert exc_info.value.shelf_id == "nonexistent"
|
||||||
|
assert "nonexistent" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_boundary_detector_shelf_no_image(seeded_db: None) -> None:
|
||||||
|
# s1 exists but has no image (neither override nor cabinet photo)
|
||||||
|
plugin = _BoundaryDetectorStub()
|
||||||
|
with pytest.raises(NoShelfImageError) as exc_info:
|
||||||
|
run_boundary_detector(plugin, "shelves", "s1")
|
||||||
|
assert exc_info.value.shelf_id == "s1"
|
||||||
|
assert exc_info.value.cabinet_id == "c1"
|
||||||
|
|
||||||
|
|
||||||
|
# ── run_book_identifier error conditions ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_book_identifier_not_found(seeded_db: None) -> None:
|
||||||
|
plugin = _BookIdentifierStub()
|
||||||
|
with pytest.raises(BookNotFoundError) as exc_info:
|
||||||
|
run_book_identifier(plugin, "nonexistent")
|
||||||
|
assert exc_info.value.book_id == "nonexistent"
|
||||||
|
assert "nonexistent" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_book_identifier_no_raw_text(seeded_db: None) -> None:
|
||||||
|
# b1 has raw_text='' (default)
|
||||||
|
plugin = _BookIdentifierStub()
|
||||||
|
with pytest.raises(NoRawTextError) as exc_info:
|
||||||
|
run_book_identifier(plugin, "b1")
|
||||||
|
assert exc_info.value.book_id == "b1"
|
||||||
|
assert "b1" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
# ── run_archive_searcher error conditions ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_archive_searcher_not_found(seeded_db: None) -> None:
|
||||||
|
plugin = _ArchiveSearcherStub()
|
||||||
|
with pytest.raises(BookNotFoundError) as exc_info:
|
||||||
|
run_archive_searcher(plugin, "nonexistent")
|
||||||
|
assert exc_info.value.book_id == "nonexistent"
|
||||||
|
assert "nonexistent" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
# ── dismiss_field error conditions ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_dismiss_field_not_found(seeded_db: None) -> None:
|
||||||
|
with pytest.raises(BookNotFoundError) as exc_info:
|
||||||
|
dismiss_field("nonexistent", "title", "some value")
|
||||||
|
assert exc_info.value.book_id == "nonexistent"
|
||||||
|
assert "nonexistent" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
# ── dispatch_plugin error conditions ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _run_dispatch(plugin_id: str, lookup: PluginLookupResult, entity_type: str, entity_id: str) -> None:
|
||||||
|
"""Helper to synchronously drive the async dispatch_plugin."""
|
||||||
|
|
||||||
|
async def _inner() -> None:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await logic.dispatch_plugin(plugin_id, lookup, entity_type, entity_id, loop)
|
||||||
|
|
||||||
|
asyncio.run(_inner())
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_plugin_not_found() -> None:
|
||||||
|
with pytest.raises(PluginNotFoundError) as exc_info:
|
||||||
|
_run_dispatch("no_such_plugin", (None, None), "books", "b1")
|
||||||
|
assert exc_info.value.plugin_id == "no_such_plugin"
|
||||||
|
assert "no_such_plugin" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_plugin_boundary_wrong_entity_type() -> None:
|
||||||
|
lookup = ("boundary_detector", _BoundaryDetectorStub())
|
||||||
|
with pytest.raises(InvalidPluginEntityError) as exc_info:
|
||||||
|
_run_dispatch("bd_stub", lookup, "books", "b1")
|
||||||
|
assert exc_info.value.plugin_category == "boundary_detector"
|
||||||
|
assert exc_info.value.entity_type == "books"
|
||||||
|
assert "boundary_detector" in str(exc_info.value)
|
||||||
|
assert "books" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_plugin_target_mismatch_cabinets(seeded_db: None) -> None:
|
||||||
|
# Plugin targets "books" but entity_type is "cabinets" (expects target="shelves")
|
||||||
|
plugin = _BoundaryDetectorStub() # target = "books"
|
||||||
|
lookup = ("boundary_detector", plugin)
|
||||||
|
with pytest.raises(PluginTargetMismatchError) as exc_info:
|
||||||
|
_run_dispatch("bd_stub", lookup, "cabinets", "c1")
|
||||||
|
assert exc_info.value.plugin_id == "bd_stub"
|
||||||
|
assert exc_info.value.expected_target == "shelves"
|
||||||
|
assert exc_info.value.actual_target == "books"
|
||||||
|
assert "bd_stub" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_plugin_target_mismatch_shelves(seeded_db: None) -> None:
|
||||||
|
# Plugin targets "shelves" but entity_type is "shelves" (expects target="books")
|
||||||
|
plugin = _BoundaryDetectorShelvesStub() # target = "shelves"
|
||||||
|
lookup = ("boundary_detector", plugin)
|
||||||
|
with pytest.raises(PluginTargetMismatchError) as exc_info:
|
||||||
|
_run_dispatch("bd_shelves_stub", lookup, "shelves", "s1")
|
||||||
|
assert exc_info.value.plugin_id == "bd_shelves_stub"
|
||||||
|
assert exc_info.value.expected_target == "books"
|
||||||
|
assert exc_info.value.actual_target == "shelves"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_plugin_text_recognizer_wrong_entity_type() -> None:
|
||||||
|
lookup = ("text_recognizer", _TextRecognizerStub())
|
||||||
|
with pytest.raises(InvalidPluginEntityError) as exc_info:
|
||||||
|
_run_dispatch("tr_stub", lookup, "cabinets", "c1")
|
||||||
|
assert exc_info.value.plugin_category == "text_recognizer"
|
||||||
|
assert exc_info.value.entity_type == "cabinets"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_plugin_book_identifier_wrong_entity_type() -> None:
|
||||||
|
lookup = ("book_identifier", _BookIdentifierStub())
|
||||||
|
with pytest.raises(InvalidPluginEntityError) as exc_info:
|
||||||
|
_run_dispatch("bi_stub", lookup, "shelves", "s1")
|
||||||
|
assert exc_info.value.plugin_category == "book_identifier"
|
||||||
|
assert exc_info.value.entity_type == "shelves"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_plugin_archive_searcher_wrong_entity_type() -> None:
|
||||||
|
lookup = ("archive_searcher", _ArchiveSearcherStub())
|
||||||
|
with pytest.raises(InvalidPluginEntityError) as exc_info:
|
||||||
|
_run_dispatch("as_stub", lookup, "cabinets", "c1")
|
||||||
|
assert exc_info.value.plugin_category == "archive_searcher"
|
||||||
|
assert exc_info.value.entity_type == "cabinets"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Exception string representation ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_exception_str_cabinet_not_found() -> None:
|
||||||
|
exc = CabinetNotFoundError("cab-123")
|
||||||
|
assert exc.cabinet_id == "cab-123"
|
||||||
|
assert "cab-123" in str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exception_str_shelf_not_found() -> None:
|
||||||
|
exc = ShelfNotFoundError("shelf-456")
|
||||||
|
assert exc.shelf_id == "shelf-456"
|
||||||
|
assert "shelf-456" in str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exception_str_plugin_not_found() -> None:
|
||||||
|
exc = PluginNotFoundError("myplugin")
|
||||||
|
assert exc.plugin_id == "myplugin"
|
||||||
|
assert "myplugin" in str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exception_str_no_shelf_image() -> None:
|
||||||
|
exc = NoShelfImageError("s1", "c1")
|
||||||
|
assert exc.shelf_id == "s1"
|
||||||
|
assert exc.cabinet_id == "c1"
|
||||||
|
assert "s1" in str(exc)
|
||||||
|
assert "c1" in str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exception_str_no_cabinet_photo() -> None:
|
||||||
|
exc = NoCabinetPhotoError("c1")
|
||||||
|
assert exc.cabinet_id == "c1"
|
||||||
|
assert "c1" in str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exception_str_no_raw_text() -> None:
|
||||||
|
exc = NoRawTextError("b1")
|
||||||
|
assert exc.book_id == "b1"
|
||||||
|
assert "b1" in str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exception_str_invalid_plugin_entity() -> None:
|
||||||
|
exc = InvalidPluginEntityError("text_recognizer", "cabinets")
|
||||||
|
assert exc.plugin_category == "text_recognizer"
|
||||||
|
assert exc.entity_type == "cabinets"
|
||||||
|
assert "text_recognizer" in str(exc)
|
||||||
|
assert "cabinets" in str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exception_str_plugin_target_mismatch() -> None:
|
||||||
|
exc = PluginTargetMismatchError("my_bd", "shelves", "books")
|
||||||
|
assert exc.plugin_id == "my_bd"
|
||||||
|
assert exc.expected_target == "shelves"
|
||||||
|
assert exc.actual_target == "books"
|
||||||
|
assert "my_bd" in str(exc)
|
||||||
|
assert "shelves" in str(exc)
|
||||||
|
assert "books" in str(exc)
|
||||||
149
tests/test_storage.py
Normal file
149
tests/test_storage.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""Unit tests for db.py, files.py, and config.py: DB helpers, name/position counters, settings merge."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import db
|
||||||
|
import files
|
||||||
|
from config import deep_merge
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_counters() -> Iterator[None]:
|
||||||
|
db.COUNTERS.clear()
|
||||||
|
yield
|
||||||
|
db.COUNTERS.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[sqlite3.Connection]:
|
||||||
|
"""Temporary SQLite database with full schema applied."""
|
||||||
|
monkeypatch.setattr(db, "DB_PATH", tmp_path / "test.db")
|
||||||
|
monkeypatch.setattr(files, "DATA_DIR", tmp_path)
|
||||||
|
monkeypatch.setattr(files, "IMAGES_DIR", tmp_path / "images")
|
||||||
|
files.init_dirs()
|
||||||
|
db.init_db()
|
||||||
|
connection = db.conn()
|
||||||
|
yield connection
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── deep_merge ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_deep_merge_basic() -> None:
|
||||||
|
result = deep_merge({"a": 1, "b": 2}, {"b": 3, "c": 4})
|
||||||
|
assert result == {"a": 1, "b": 3, "c": 4}
|
||||||
|
|
||||||
|
|
||||||
|
def test_deep_merge_nested() -> None:
|
||||||
|
base = {"x": {"a": 1, "b": 2}}
|
||||||
|
override = {"x": {"b": 99, "c": 3}}
|
||||||
|
result = deep_merge(base, override)
|
||||||
|
assert result == {"x": {"a": 1, "b": 99, "c": 3}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_deep_merge_list_replacement() -> None:
|
||||||
|
base = {"items": [1, 2, 3]}
|
||||||
|
override = {"items": [4, 5]}
|
||||||
|
result = deep_merge(base, override)
|
||||||
|
assert result["items"] == [4, 5]
|
||||||
|
|
||||||
|
|
||||||
|
def test_deep_merge_does_not_mutate_base() -> None:
|
||||||
|
base = {"a": {"x": 1}}
|
||||||
|
deep_merge(base, {"a": {"x": 2}})
|
||||||
|
assert base["a"]["x"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── uid / now ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_uid_unique() -> None:
|
||||||
|
assert db.uid() != db.uid()
|
||||||
|
|
||||||
|
|
||||||
|
def test_uid_is_string() -> None:
|
||||||
|
result = db.uid()
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert len(result) == 36 # UUID4 format
|
||||||
|
|
||||||
|
|
||||||
|
def test_now_is_string() -> None:
|
||||||
|
result = db.now()
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert "T" in result # ISO format
|
||||||
|
|
||||||
|
|
||||||
|
# ── next_name ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_name_increments() -> None:
|
||||||
|
assert db.next_name("Room") == "Room 1"
|
||||||
|
assert db.next_name("Room") == "Room 2"
|
||||||
|
assert db.next_name("Room") == "Room 3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_name_independent_prefixes() -> None:
|
||||||
|
assert db.next_name("Room") == "Room 1"
|
||||||
|
assert db.next_name("Shelf") == "Shelf 1"
|
||||||
|
assert db.next_name("Room") == "Room 2"
|
||||||
|
|
||||||
|
|
||||||
|
# ── next_pos / next_root_pos ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_root_pos_empty(test_db: sqlite3.Connection) -> None:
|
||||||
|
pos = db.next_root_pos(test_db, "rooms")
|
||||||
|
assert pos == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_root_pos_with_rows(test_db: sqlite3.Connection) -> None:
|
||||||
|
ts = db.now()
|
||||||
|
test_db.execute("INSERT INTO rooms VALUES (?,?,?,?)", ["r1", "Room 1", 1, ts])
|
||||||
|
test_db.execute("INSERT INTO rooms VALUES (?,?,?,?)", ["r2", "Room 2", 2, ts])
|
||||||
|
test_db.commit()
|
||||||
|
assert db.next_root_pos(test_db, "rooms") == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_pos_empty(test_db: sqlite3.Connection) -> None:
|
||||||
|
ts = db.now()
|
||||||
|
test_db.execute("INSERT INTO rooms VALUES (?,?,?,?)", ["r1", "Room", 1, ts])
|
||||||
|
test_db.commit()
|
||||||
|
pos = db.next_pos(test_db, "cabinets", "room_id", "r1")
|
||||||
|
assert pos == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_pos_with_children(test_db: sqlite3.Connection) -> None:
|
||||||
|
ts = db.now()
|
||||||
|
test_db.execute("INSERT INTO rooms VALUES (?,?,?,?)", ["r1", "Room", 1, ts])
|
||||||
|
test_db.execute("INSERT INTO cabinets VALUES (?,?,?,?,?,?,?,?)", ["c1", "r1", "C1", None, None, None, 1, ts])
|
||||||
|
test_db.execute("INSERT INTO cabinets VALUES (?,?,?,?,?,?,?,?)", ["c2", "r1", "C2", None, None, None, 2, ts])
|
||||||
|
test_db.commit()
|
||||||
|
pos = db.next_pos(test_db, "cabinets", "room_id", "r1")
|
||||||
|
assert pos == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ── init_db ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_db_creates_tables(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(db, "DB_PATH", tmp_path / "test.db")
|
||||||
|
db.init_db()
|
||||||
|
connection = sqlite3.connect(tmp_path / "test.db")
|
||||||
|
tables = {row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
|
||||||
|
connection.close()
|
||||||
|
assert {"rooms", "cabinets", "shelves", "books"}.issubset(tables)
|
||||||
|
|
||||||
|
|
||||||
|
# ── init_dirs ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_dirs_creates_images_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(files, "DATA_DIR", tmp_path)
|
||||||
|
monkeypatch.setattr(files, "IMAGES_DIR", tmp_path / "images")
|
||||||
|
files.init_dirs()
|
||||||
|
assert (tmp_path / "images").is_dir()
|
||||||
Reference in New Issue
Block a user