From f29678ebf12df939e557057123e86b6f41d0e28e Mon Sep 17 00:00:00 2001 From: night Date: Mon, 9 Mar 2026 14:11:11 +0300 Subject: [PATCH] Initial commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 16 + .prettierrc | 4 + .vscode/settings.json | 7 + AGENTS.md | 10 + CLAUDE.md | 1 + README.md | 73 + config/credentials.default.yaml | 9 + config/functions.default.yaml | 103 ++ config/models.default.yaml | 50 + config/ui.default.yaml | 3 + docs/overview.md | 131 ++ eslint.config.js | 127 ++ package.json | 16 + poetry.lock | 1574 +++++++++++++++++ pyproject.toml | 62 + scripts/presubmit.py | 55 + src/api.py | 407 +++++ src/app.py | 76 + src/config.py | 176 ++ src/db.py | 515 ++++++ src/errors.py | 280 +++ src/files.py | 40 + src/logic/__init__.py | 108 ++ src/logic/archive.py | 52 + src/logic/batch.py | 66 + src/logic/boundaries.py | 147 ++ src/logic/identification.py | 245 +++ src/logic/images.py | 107 ++ src/models.py | 241 +++ src/plugins/__init__.py | 241 +++ src/plugins/ai_compat/__init__.py | 21 + src/plugins/ai_compat/_client.py | 94 + src/plugins/ai_compat/book_identifier.py | 56 + .../ai_compat/boundary_detector_books.py | 46 + .../ai_compat/boundary_detector_shelves.py | 51 + src/plugins/ai_compat/text_recognizer.py | 56 + src/plugins/archives/__init__.py | 0 src/plugins/archives/html_scraper.py | 121 ++ src/plugins/archives/openlibrary.py | 54 + src/plugins/archives/rsl.py | 59 + src/plugins/archives/sru_catalog.py | 71 + src/plugins/rate_limiter.py | 23 + static/css/base.css | 9 + static/css/forms.css | 44 + static/css/layout.css | 36 + static/css/overlays.css | 31 + static/css/tree.css | 70 + static/index.html | 81 + static/js/api.js | 23 + static/js/canvas-boundary.js | 271 +++ static/js/canvas-crop.js | 166 ++ static/js/detail-render.js | 166 ++ static/js/editing.js | 65 + static/js/events.js | 283 +++ static/js/helpers.js | 21 + static/js/init.js | 82 + static/js/photo.js | 138 ++ static/js/state.js | 41 + static/js/tree-render.js | 321 ++++ tests/__init__.py | 0 tests/js/pure-functions.test.js | 239 +++ tests/test_errors.py | 190 ++ tests/test_logic.py | 585 ++++++ tests/test_storage.py | 149 ++ 64 files changed, 8605 insertions(+) create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 .vscode/settings.json create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 README.md create mode 100644 config/credentials.default.yaml create mode 100644 config/functions.default.yaml create mode 100644 config/models.default.yaml create mode 100644 config/ui.default.yaml create mode 100644 docs/overview.md create mode 100644 eslint.config.js create mode 100644 package.json create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 scripts/presubmit.py create mode 100644 src/api.py create mode 100644 src/app.py create mode 100644 src/config.py create mode 100644 src/db.py create mode 100644 src/errors.py create mode 100644 src/files.py create mode 100644 src/logic/__init__.py create mode 100644 src/logic/archive.py create mode 100644 src/logic/batch.py create mode 100644 src/logic/boundaries.py create mode 100644 src/logic/identification.py create mode 100644 src/logic/images.py create mode 100644 src/models.py create mode 100644 src/plugins/__init__.py create mode 100644 src/plugins/ai_compat/__init__.py create mode 100644 src/plugins/ai_compat/_client.py create mode 100644 src/plugins/ai_compat/book_identifier.py create mode 100644 src/plugins/ai_compat/boundary_detector_books.py create mode 100644 src/plugins/ai_compat/boundary_detector_shelves.py create mode 100644 src/plugins/ai_compat/text_recognizer.py create mode 100644 src/plugins/archives/__init__.py create mode 100644 src/plugins/archives/html_scraper.py create mode 100644 src/plugins/archives/openlibrary.py create mode 100644 src/plugins/archives/rsl.py create mode 100644 src/plugins/archives/sru_catalog.py create mode 100644 src/plugins/rate_limiter.py create mode 100644 static/css/base.css create mode 100644 static/css/forms.css create mode 100644 static/css/layout.css create mode 100644 static/css/overlays.css create mode 100644 static/css/tree.css create mode 100644 static/index.html create mode 100644 static/js/api.js create mode 100644 static/js/canvas-boundary.js create mode 100644 static/js/canvas-crop.js create mode 100644 static/js/detail-render.js create mode 100644 static/js/editing.js create mode 100644 static/js/events.js create mode 100644 static/js/helpers.js create mode 100644 static/js/init.js create mode 100644 static/js/photo.js create mode 100644 static/js/state.js create mode 100644 static/js/tree-render.js create mode 100644 tests/__init__.py create mode 100644 tests/js/pure-functions.test.js create mode 100644 tests/test_errors.py create mode 100644 tests/test_logic.py create mode 100644 tests/test_storage.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7eb0636 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..3f584f6 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "printWidth": 120, + "singleQuote": true +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..18d2559 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "cSpell.words": [ + "gphoto", + "LANCZOS" + ], + "claudeCode.allowDangerouslySkipPermissions": true +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..596f94b --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b7cc3ec --- /dev/null +++ b/README.md @@ -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). diff --git a/config/credentials.default.yaml b/config/credentials.default.yaml new file mode 100644 index 0000000..202f228 --- /dev/null +++ b/config/credentials.default.yaml @@ -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-..." diff --git a/config/functions.default.yaml b/config/functions.default.yaml new file mode 100644 index 0000000..f2df473 --- /dev/null +++ b/config/functions.default.yaml @@ -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" diff --git a/config/models.default.yaml b/config/models.default.yaml new file mode 100644 index 0000000..7c8e235 --- /dev/null +++ b/config/models.default.yaml @@ -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} diff --git a/config/ui.default.yaml b/config/ui.default.yaml new file mode 100644 index 0000000..621486f --- /dev/null +++ b/config/ui.default.yaml @@ -0,0 +1,3 @@ +# UI settings. Override in ui.user.yaml. +ui: + boundary_grab_px: 14 # pixel grab threshold for dragging boundary lines diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000..244f79d --- /dev/null +++ b/docs/overview.md @@ -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 +``` diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..ca32223 --- /dev/null +++ b/eslint.config.js @@ -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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/js/api.js b/static/js/api.js new file mode 100644 index 0000000..5db988a --- /dev/null +++ b/static/js/api.js @@ -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(); +} diff --git a/static/js/canvas-boundary.js b/static/js/canvas-boundary.js new file mode 100644 index 0000000..4181a87 --- /dev/null +++ b/static/js/canvas-boundary.js @@ -0,0 +1,271 @@ +/* + * canvas-boundary.js + * Boundary-line editor rendered on a 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 { 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=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')); +} diff --git a/static/js/canvas-crop.js b/static/js/canvas-crop.js new file mode 100644 index 0000000..472b7b8 --- /dev/null +++ b/static/js/canvas-crop.js @@ -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 = ''; + 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) + ${vAiProgressBar(stats)} +

${r.cabinets.length} cabinet${r.cabinets.length!==1?'s':''} · ${totalBooks} book${totalBooks!==1?'s':''}

+ `; +} + +// ── Detail body (right panel) ──────────────────────────────────────────────── +function vDetailBody() { + if (!S.selected) return '
← Select a room, cabinet or shelf from the tree
'; + const {type, id} = S.selected; + const node = findNode(id); + if (!node) return '
Not found
'; + 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 = [ + ``, + ...pluginIds.map(pid => ``), + ...(pluginIds.length > 1 ? [``] : []), + ].join(''); + return `
+ ${vAiProgressBar(stats)} + ${hasPhoto + ? `
+ + +
` + : `
📷
Upload a cabinet photo (📷 in header) to get started
`} + ${hasPhoto ? `

Drag lines · Ctrl+Alt+Click to add · Snap to AI guides

` : ''} + ${hasPhoto ? `
+ ${bounds.length ? `${cab.shelves.length} shelf${cab.shelves.length!==1?'s':''} · ${bounds.length} boundar${bounds.length!==1?'ies':'y'}` : ''} +
+ ${bndPlugins.map(p => vPluginBtn(p, cab.id, 'cabinets')).join('')} + +
+
` : ''} +
`; +} + +// ── 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 = [ + ``, + ...pluginIds.map(pid => ``), + ...(pluginIds.length > 1 ? [``] : []), + ].join(''); + return `
+ ${vAiProgressBar(stats)} +
+ + +
+

Drag lines · Ctrl+Alt+Click to add · Snap to AI guides

+
+ ${bounds.length ? `${shelf.books.length} book${shelf.books.length!==1?'s':''} · ${bounds.length} boundary${bounds.length!==1?'ies':''}` : ''} +
+ ${bndPlugins.map(p => vPluginBtn(p, shelf.id, 'shelves')).join('')} + +
+
+
`; +} + +// ── 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 `
+
+
Spine
+
+ ${b.image_filename + ? `
Title page
+
` + : ''} +
+
+
+
+ ${sl} + ${b.identification_status ?? 'unidentified'} + ${b.analyzed_at ? `Identified ${b.analyzed_at.slice(0,10)}` : ''} +
+
+ + +
+ ${searchers.length ? `
+ +
` : ''} +
+ ${candidateSugRows(b, 'title', 'd-title')} + +
+
+ ${candidateSugRows(b, 'author', 'd-author')} + +
+
+ ${candidateSugRows(b, 'year', 'd-year')} + +
+
+ ${candidateSugRows(b, 'isbn', 'd-isbn')} + +
+
+ ${candidateSugRows(b, 'publisher', 'd-pub')} + +
+
+
+
+
+
`; +} diff --git a/static/js/editing.js b/static/js/editing.js new file mode 100644 index 0000000..43b53dc --- /dev/null +++ b/static/js/editing.js @@ -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(); } + }, + })); + }); +} diff --git a/static/js/events.js b/static/js/events.js new file mode 100644 index 0000000..811d96e --- /dev/null +++ b/static/js/events.js @@ -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; + } + + } +} diff --git a/static/js/helpers.js b/static/js/helpers.js new file mode 100644 index 0000000..f816ba9 --- /dev/null +++ b/static/js/helpers.js @@ -0,0 +1,21 @@ +/* + * helpers.js + * Pure utility functions with no dependencies on other application modules. + * Safe to call from any JS file. + * + * Provides: esc(), toast(), isDesktop() + */ + +// ── Helpers ───────────────────────────────────────────────────────────────── +function esc(s) { + return String(s ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +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; } diff --git a/static/js/init.js b/static/js/init.js new file mode 100644 index 0000000..fa0f1a0 --- /dev/null +++ b/static/js/init.js @@ -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()]); diff --git a/static/js/photo.js b/static/js/photo.js new file mode 100644 index 0000000..d03a930 --- /dev/null +++ b/static/js/photo.js @@ -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 = `
+ + Photo Queue + +
+
+
+
All done!
+
All ${books.length} book${books.length !== 1 ? 's' : ''} photographed
+ +
`; + return; + } + const book = books[index]; + el.innerHTML = `
+ + ${index + 1} / ${books.length} + +
+
+ Spine +
${esc(book.title || '—')}
+
+
+ + +
+ ${processing ? '
Processing…
' : ''}`; +} + +// ── 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); } +}); diff --git a/static/js/state.js b/static/js/state.js new file mode 100644 index 0000000..6ad1010 --- /dev/null +++ b/static/js/state.js @@ -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} diff --git a/static/js/tree-render.js b/static/js/tree-render.js new file mode 100644 index 0000000..c9ef64c --- /dev/null +++ b/static/js/tree-render.js @@ -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 ``; +} + +// ── Batch button ───────────────────────────────────────────────────────────── +function vBatchBtn() { + if (_batchState.running) + return `${_batchState.done}/${_batchState.total} ⏳`; + return ``; +} + +// ── 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 => + `${esc(getSourceLabel(s))}` + ).join(' '); + const val = esc(display); + return `
+ ${badges} ${val} + + +
`; + }).join(''); +} + +// ── App shell ──────────────────────────────────────────────────────────────── +function vApp() { + return `
+ +
+
+

${mainTitle()}

+
${vBatchBtn()}
+
${mainHeaderBtns()}
+
+
${vDetailBody()}
+
+
`; +} + +function mainTitle() { + if (!S.selected) return 'Select a room, cabinet or shelf'; + const n = findNode(S.selected.id); + const {type, id} = S.selected; + if (type === 'book') { + return `${esc(n?.title || 'Untitled book')}`; + } + const name = esc(n?.name || ''); + return `${name}`; +} + +function mainHeaderBtns() { + if (!S.selected) return ''; + const {type, id} = S.selected; + if (type === 'room') { + return `
+ + +
`; + } + if (type === 'cabinet') { + const cab = findNode(id); + return `
+ + ${cab?.photo_filename ? `` : ''} + +
`; + } + if (type === 'shelf') { + const shelf = findNode(id); + return `
+ + ${shelf?.photo_filename ? `` : ''} + +
`; + } + if (type === 'book') { + return `
+ + + +
`; + } + return ''; +} + +// ── Tree body ──────────────────────────────────────────────────────────────── +function vTreeBody() { + if (!S.tree) return '
Loading…
'; + if (!S.tree.length) return '
📚
No rooms yet
'; + return `
${S.tree.map(vRoom).join('')}
`; +} + +function vRoom(r) { + const exp = S.expanded.has(r.id); + const sel = S.selected?.id === r.id; + return `
+
+ + + 🏠 ${esc(r.name)} +
+ + +
+
+ ${exp ? `
+ ${r.cabinets.map(vCabinet).join('')} +
` : ''} +
`; +} + +function vCabinet(c) { + const exp = S.expanded.has(c.id); + const sel = S.selected?.id === c.id; + return `
+
+ + + ${c.photo_filename ? `` : ''} + 📚 ${esc(c.name)} +
+ ${!isDesktop() ? `` : ''} + ${!isDesktop() ? `` : ''} + + ${!isDesktop() ? `` : ''} +
+
+ ${exp ? `
+ ${c.shelves.map(vShelf).join('')} +
` : ''} +
`; +} + +function vShelf(s) { + const exp = S.expanded.has(s.id); + const sel = S.selected?.id === s.id; + return `
+
+ + + ${esc(s.name)} +
+ ${!isDesktop() ? `` : ''} + ${!isDesktop() ? `` : ''} + + ${!isDesktop() ? `` : ''} +
+
+ ${exp ? `
+ ${s.books.map(vBook).join('')} +
` : ''} +
`; +} + +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 `
+
+ + ${sl} + ${b.image_filename ? `` : `
📖
`} +
+
${esc(b.title || '—')}
+ ${sub ? `
${esc(sub)}
` : ''} +
+ ${!isDesktop() ? `
+ + +
` : ''} +
+
`; +} + +// ── 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 `
+
+ ✓ ${approved} approved· + AI ${ai}· + ? ${unidentified} unidentified +
+
+
+
+
+
+
`; +} + +// ── 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; +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/js/pure-functions.test.js b/tests/js/pure-functions.test.js new file mode 100644 index 0000000..2c17539 --- /dev/null +++ b/tests/js/pure-functions.test.js @@ -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('text'), '<b>text</b>'); + assert.equal(esc('"quoted"'), '"quoted"'); + assert.equal(esc('a & b'), 'a & b'); + assert.equal(esc(''), '<script>alert("xss")</script>'); +}); + +test('esc: coerces null/undefined/number to string', () => { + const { esc } = load('static/js/helpers.js'); + assert.equal(esc(null), ''); + assert.equal(esc(undefined), ''); + assert.equal(esc(42), '42'); +}); + +// ── parseBounds (canvas-boundary.js) ───────────────────────────────────────── + +test('parseBounds: parses valid JSON array of fractions', () => { + const { parseBounds } = load('static/js/canvas-boundary.js'); + assert.deepEqual(j(parseBounds('[0.25, 0.5, 0.75]')), [0.25, 0.5, 0.75]); + assert.deepEqual(j(parseBounds('[]')), []); +}); + +test('parseBounds: returns [] for falsy / invalid / null-JSON input', () => { + const { parseBounds } = load('static/js/canvas-boundary.js'); + assert.deepEqual(j(parseBounds(null)), []); + assert.deepEqual(j(parseBounds('')), []); + assert.deepEqual(j(parseBounds('not-json')), []); + assert.deepEqual(j(parseBounds('null')), []); +}); + +// ── parseBndPluginResults (canvas-boundary.js) ──────────────────────────────── + +test('parseBndPluginResults: parses a valid JSON object', () => { + const { parseBndPluginResults } = load('static/js/canvas-boundary.js'); + assert.deepEqual( + j(parseBndPluginResults('{"p1":[0.3,0.6],"p2":[0.4]}')), + { p1: [0.3, 0.6], p2: [0.4] } + ); +}); + +test('parseBndPluginResults: returns {} for null / array / invalid input', () => { + const { parseBndPluginResults } = load('static/js/canvas-boundary.js'); + assert.deepEqual(j(parseBndPluginResults(null)), {}); + assert.deepEqual(j(parseBndPluginResults('')), {}); + assert.deepEqual(j(parseBndPluginResults('[1,2,3]')), {}); // arrays are rejected + assert.deepEqual(j(parseBndPluginResults('{bad}')), {}); +}); + +// ── parseCandidates (tree-render.js) ────────────────────────────────────────── + +/** Load tree-render.js with stubs for all globals it references in function bodies. */ +function loadTreeRender() { + return load('static/js/tree-render.js', { + S: { selected: null, expanded: new Set(), _loading: {} }, + _plugins: [], + _batchState: { running: false, done: 0, total: 0 }, + _bnd: null, + esc: (s) => String(s ?? ''), + isDesktop: () => true, + findNode: () => null, + vDetailBody: () => '', + }); +} + +test('parseCandidates: parses a valid JSON array', () => { + const { parseCandidates } = loadTreeRender(); + const input = [{ title: 'Foo', author: 'Bar', source: 'vlm' }]; + assert.deepEqual(j(parseCandidates(JSON.stringify(input))), input); +}); + +test('parseCandidates: returns [] for null / empty / invalid input', () => { + const { parseCandidates } = loadTreeRender(); + assert.deepEqual(j(parseCandidates(null)), []); + assert.deepEqual(j(parseCandidates('')), []); + assert.deepEqual(j(parseCandidates('bad json')), []); +}); + +// ── getBookStats (tree-render.js) ───────────────────────────────────────────── + +function makeBook(status) { + return { id: Math.random(), identification_status: status, title: 'T' }; +} + +test('getBookStats: counts books by status on a shelf', () => { + const { getBookStats } = loadTreeRender(); + const shelf = { + id: 1, + books: [ + makeBook('user_approved'), + makeBook('ai_identified'), + makeBook('unidentified'), + makeBook('unidentified'), + ], + }; + const s = getBookStats(shelf, 'shelf'); + assert.equal(s.total, 4); + assert.equal(s.approved, 1); + assert.equal(s.ai, 1); + assert.equal(s.unidentified, 2); +}); + +test('getBookStats: aggregates across a full room → cabinet → shelf hierarchy', () => { + const { getBookStats } = loadTreeRender(); + const room = { + id: 1, + cabinets: [{ + id: 2, + shelves: [{ + id: 3, + books: [makeBook('user_approved'), makeBook('unidentified'), makeBook('ai_identified')], + }], + }], + }; + const s = getBookStats(room, 'room'); + assert.equal(s.total, 3); + assert.equal(s.approved, 1); + assert.equal(s.ai, 1); + assert.equal(s.unidentified, 1); +}); + +test('getBookStats: returns zeros for a book node itself', () => { + const { getBookStats } = loadTreeRender(); + const book = makeBook('user_approved'); + const s = getBookStats(book, 'book'); + assert.equal(s.total, 1); + assert.equal(s.approved, 1); +}); + +// ── collectQueueBooks (photo.js) ────────────────────────────────────────────── + +function loadPhoto() { + return load('static/js/photo.js', { + S: { _photoTarget: null }, + _photoQueue: null, + req: async () => ({}), + toast: () => {}, + walkTree: () => {}, + findNode: () => null, + isDesktop: () => true, + render: () => {}, + }); +} + +test('collectQueueBooks: excludes user_approved books from a shelf', () => { + const { collectQueueBooks } = loadPhoto(); + const shelf = { + id: 1, + books: [ + { id: 2, identification_status: 'user_approved', title: 'A' }, + { id: 3, identification_status: 'unidentified', title: 'B' }, + { id: 4, identification_status: 'ai_identified', title: 'C' }, + ], + }; + const result = collectQueueBooks(shelf, 'shelf'); + assert.equal(result.length, 2); + assert.deepEqual(j(result.map((b) => b.id)), [3, 4]); +}); + +test('collectQueueBooks: collects across room → cabinet → shelf hierarchy', () => { + const { collectQueueBooks } = loadPhoto(); + const room = { + id: 1, + cabinets: [{ + id: 2, + shelves: [{ + id: 3, + books: [ + { id: 4, identification_status: 'user_approved' }, + { id: 5, identification_status: 'unidentified' }, + { id: 6, identification_status: 'ai_identified' }, + ], + }], + }], + }; + const result = collectQueueBooks(room, 'room'); + assert.equal(result.length, 2); + assert.deepEqual(j(result.map((b) => b.id)), [5, 6]); +}); + +test('collectQueueBooks: returns empty array when all books are approved', () => { + const { collectQueueBooks } = loadPhoto(); + const shelf = { + id: 1, + books: [ + { id: 2, identification_status: 'user_approved' }, + { id: 3, identification_status: 'user_approved' }, + ], + }; + assert.deepEqual(j(collectQueueBooks(shelf, 'shelf')), []); +}); diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..2cab6a3 --- /dev/null +++ b/tests/test_errors.py @@ -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) diff --git a/tests/test_logic.py b/tests/test_logic.py new file mode 100644 index 0000000..4528647 --- /dev/null +++ b/tests/test_logic.py @@ -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) diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..21def3f --- /dev/null +++ b/tests/test_storage.py @@ -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()