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:
2026-03-09 14:17:13 +03:00
commit 084d1aebd5
64 changed files with 8605 additions and 0 deletions

16
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,4 @@
{
"printWidth": 120,
"singleQuote": true
}

7
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"cSpell.words": [
"gphoto",
"LANCZOS"
],
"claudeCode.allowDangerouslySkipPermissions": true
}

10
AGENTS.md Normal file
View 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.

1
CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

73
README.md Normal file
View 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).

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

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

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

File diff suppressed because it is too large Load Diff

62
pyproject.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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",
]

View 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)

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

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

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

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

View File

View 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)
]

View 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

View 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

View 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)
]

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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();
}

View 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
View 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
View 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
View 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
View 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
View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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
View 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
View 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
View 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
View 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
View File

View 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>'), '&lt;b&gt;text&lt;/b&gt;');
assert.equal(esc('"quoted"'), '&quot;quoted&quot;');
assert.equal(esc('a & b'), 'a &amp; b');
assert.equal(esc('<script>alert("xss")</script>'), '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
});
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
View 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
View 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
View 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()