Compare commits

...

6 Commits

Author SHA1 Message Date
9c003a43ea Add package-lock.json for reproducible npm installs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 12:12:21 +03:00
b94f222c96 Add per-request AI logging, DB batch queue, WS entity updates, and UI polish
- log_thread.py: thread-safe ContextVar bridge so executor threads can log
  individual LLM calls and archive searches back to the event loop
- ai_log.py: init_thread_logging(), notify_entity_update(); WS now pushes
  entity_update messages when book data changes after any plugin or batch run
- batch.py: replace batch_pending.json with batch_queue SQLite table;
  run_batch_consumer() reads queue dynamically so new books can be added
  while batch is running; add_to_queue() deduplicates
- migrate.py: fix _migrate_v1 (clear-on-startup bug); add _migrate_v2 for
  batch_queue table
- _client.py / archive.py / identification.py: wrap each LLM API call and
  archive search with log_thread start/finish entries
- api.py: POST /api/batch returns {already_running, added}; notify_entity_update
  after identify pipeline
- models.default.yaml: strengthen ai_identify confidence-scoring instructions;
  warn against placeholder data
- detail-render.js: book log entries show clickable ID + spine thumbnail;
  book spine/title images open full-screen popup
- events.js: batch-start handles already_running+added; open-img-popup action
- init.js: entity_update WS handler; image popup close listeners
- overlays.css / index.html: full-screen image popup overlay
- eslint.config.js: add new globals; fix no-redeclare/no-unused-vars for
  multi-file global architecture; all lint errors resolved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 12:10:54 +03:00
fd32be729f Replace config-driven HtmlScraperPlugin with specific archive classes
Each archive scraper now has its own class with hardcoded URL and parsing
logic; config only carries auto_queue, timeout, and rate_limit_seconds.

- html_scraper: refactor to base class with public shared utilities
  (YEAR_RE, AUTHOR_PREFIX_PAT, cls_inner_texts, img_alts)
- rusneb.py (new): RusnebPlugin extracts year per list item rather than
  globally, eliminating wrong page-level dates
- alib.py (new): AlibPlugin extracts year from within each <p><b> entry
  rather than globally, fixing nonsensical year values
- shpl.py (new): ShplPlugin retains the dead ШПИЛ endpoint with hardcoded
  params; config type updated from html_scraper to shpl
- config: remove config: subsections from rusneb, alib_web, shpl entries;
  update type fields to rusneb, alib_web, shpl respectively
- plugins/__init__.py: register new specific types, remove html_scraper
- tests: use specific plugin classes; assert all CandidateRecord fields
  (source, title, author, year, isbn, publisher) with appropriate constraints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 00:03:17 +03:00
b8f82607f9 Fix archive plugins for НЭБ and Alib; add network integration tests
- html_scraper: add img_alt strategy (НЭБ titles from <img alt>), bold_text
  strategy (Alib entries from <p><b>), Windows-1251 encoding support,
  _cls_inner_texts() helper that strips inner HTML tags
- rsl: rewrite to POST SearchFilterForm[search] with CSRF token and CQL
  title:(words) AND author:(word) query format
- config: update rusneb (img_alt + correct author_class) and alib_web
  (encoding + bold_text) to match fixed plugin strategies
- tests: add tests/test_archives.py with network-marked tests for all six
  archive plugins; НЛР and ШПИЛ marked xfail (endpoints return HTTP 404)
- presubmit: exclude network tests from default run (-m "not network")

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 22:59:19 +03:00
ce03046e51 Fix boundary/child count invariant on shelf and book deletion
When deleting a shelf or book, remove the corresponding boundary from
the parent's boundary list so len(boundaries) == len(children) - 1
is maintained. Add API-level tests covering first, middle, and last
child deletion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 19:45:55 +03:00
7095cbaa60 Add git/docs rules to AGENTS.md; add docs reminder to presubmit 2026-03-09 14:30:16 +03:00
51 changed files with 4424 additions and 706 deletions

View File

@@ -8,6 +8,12 @@
## Implementation rules ## Implementation rules
- No backward-compatibility shims or legacy endpoint aliases. - No backward-compatibility shims or legacy endpoint aliases.
- Run `poetry run presubmit` before finishing any task. Fix all failures before marking work done. - Run `poetry run presubmit` before finishing any task. Fix all failures before marking work done.
- Before marking a task done: update `docs/overview.md` if the change affects architecture, layer boundaries, config schema, directory layout, API endpoints, or the plugin system. Do not update it for implementation details.
## Git rules
- Never set or modify git config (`git config` or `git -c user.*`) without an explicit request from the user.
- Respect whatever author/email is already configured in the repository or global git config.
- If a commit requires author information that is missing, ask the user rather than inventing values.
## Documentation rules ## Documentation rules
Follow `docs/contributing.md`. Key points: Follow `docs/contributing.md`. Key points:

View File

@@ -30,14 +30,16 @@ functions:
rate_limit_seconds: 0 rate_limit_seconds: 0
timeout: 30 timeout: 30
# ── Book identification: raw_text → {title, author, year, isbn, publisher, confidence} # ── Book identification: VLM result + archive results → ranked identification blocks
# is_vlm: true means the model also receives the book's spine and title-page images.
book_identifiers: book_identifiers:
identify: identify:
model: ai_identify model: ai_identify
confidence_threshold: 0.8 confidence_threshold: 0.8
auto_queue: false auto_queue: false
rate_limit_seconds: 0 rate_limit_seconds: 0
timeout: 30 timeout: 60
is_vlm: true
# ── Archive searchers: query → [{source, title, author, year, isbn, publisher}, ...] # ── Archive searchers: query → [{source, title, author, year, isbn, publisher}, ...]
archive_searchers: archive_searchers:
@@ -57,28 +59,17 @@ functions:
rusneb: rusneb:
name: "НЭБ" name: "НЭБ"
type: html_scraper type: rusneb
auto_queue: true auto_queue: true
rate_limit_seconds: 5 rate_limit_seconds: 5
timeout: 8 timeout: 8
config:
url: "https://rusneb.ru/search/"
search_param: q
title_class: "title"
author_class: "author"
alib_web: alib_web:
name: "Alib (web)" name: "Alib (web)"
type: html_scraper type: alib_web
auto_queue: false auto_queue: false
rate_limit_seconds: 5 rate_limit_seconds: 5
timeout: 8 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: nlr:
name: "НЛР" name: "НЛР"
@@ -91,13 +82,9 @@ functions:
query_prefix: "title=" query_prefix: "title="
shpl: shpl:
# Endpoint currently returns HTTP 404; retained for future re-enablement.
name: "ШПИЛ" name: "ШПИЛ"
type: html_scraper type: shpl
auto_queue: false auto_queue: false
rate_limit_seconds: 5 rate_limit_seconds: 5
timeout: 8 timeout: 8
config:
url: "https://www.shpl.ru/cgi-bin/irbis64/cgiirbis_64.exe"
search_param: S21ALL
extra_params: {C21COM: S, I21DBN: BIBL, P21DBN: BIBL, S21FMT: briefWebRus, Z21ID: ""}
brief_class: "brief"

View File

@@ -43,8 +43,32 @@ models:
model: "google/gemini-flash-1.5" model: "google/gemini-flash-1.5"
prompt: | prompt: |
# ${RAW_TEXT} — text read from the book spine (multi-line) # ${RAW_TEXT} — text read from the book spine (multi-line)
# ${ARCHIVE_RESULTS} — JSON array of candidate records from library archives
# ${OUTPUT_FORMAT} — JSON schema injected by BookIdentifierPlugin # ${OUTPUT_FORMAT} — JSON schema injected by BookIdentifierPlugin
The following text was read from a book spine: Text read from the book spine:
${RAW_TEXT} ${RAW_TEXT}
Identify this book. Search for it if needed. Return ONLY valid JSON, no explanation:
Archive search results (may be empty):
${ARCHIVE_RESULTS}
Your task:
1. Search the web for this book if needed to find additional information.
2. Combine the spine text, archive results, and your web search into identification candidates.
3. Collapse candidates that are clearly the same book (same title + author + year + publisher) into one entry, listing all contributing sources.
4. Rank candidates by confidence (highest first). Assign a score 0.0-1.0.
5. Remove any candidates you believe are irrelevant or clearly wrong.
IMPORTANT — confidence scoring rules:
- The score must reflect how well the found information matches the spine text and recognized data.
- If the only available evidence is a title with no author, year, publisher, or corroborating archive results, the score must not exceed 0.5.
- Base confidence on: quality of spine text match, number of matching fields, archive result corroboration, and completeness of the identified record.
- A record with title + author + year that appears in multiple archive sources warrants a high score; a record with only a guessed title warrants a low score.
IMPORTANT — output format rules:
- The JSON schema below is a format specification only. Do NOT use it as a source of example data.
- Do NOT return placeholder values such as "The Great Gatsby", "Unknown Author", "Example Publisher", or any other generic example text unless that exact text literally appears on the spine.
- Return only real books that could plausibly match what is shown on this spine.
- If you cannot identify the book with reasonable confidence, return an empty array [].
Return ONLY valid JSON matching the schema below, no explanation:
${OUTPUT_FORMAT} ${OUTPUT_FORMAT}

View File

@@ -1,3 +1,5 @@
# UI settings. Override in ui.user.yaml. # UI settings. Override in ui.user.yaml.
ui: ui:
boundary_grab_px: 14 # pixel grab threshold for dragging boundary lines boundary_grab_px: 14 # pixel grab threshold for dragging boundary lines
spine_padding_pct: 0.30 # extra fraction of book width added on each side of spine crop
ai_log_max_entries: 100 # max AI request log entries kept in memory

View File

@@ -20,15 +20,18 @@ src/
config.py # Config loading and typed AppConfig config.py # Config loading and typed AppConfig
models.py # Typed dataclasses / mashumaro decoders models.py # Typed dataclasses / mashumaro decoders
errors.py # Domain exceptions (NotFoundError, BadRequestError subtypes) errors.py # Domain exceptions (NotFoundError, BadRequestError subtypes)
log_thread.py # Thread-safe logging context (ContextVar + event-loop bridge for executor threads)
logic/ logic/
__init__.py # dispatch_plugin() orchestrator + re-exports __init__.py # dispatch_plugin() orchestrator + re-exports
boundaries.py # Boundary math, shelf/spine crop sources, boundary detector runner boundaries.py # Boundary math, shelf/spine crop sources, boundary detector runner
identification.py # Status computation, text recognizer, book identifier runners identification.py # Status computation, text recognizer, book identifier runners
archive.py # Archive searcher runner (sync + background) archive.py # Archive searcher runner (sync + background)
batch.py # Batch pipeline, process_book_sync batch.py # Batch queue consumer (run_batch_consumer); queue persisted in batch_queue DB table
ai_log.py # AI request ring buffer + WebSocket pub-sub (log_start/log_finish/notify_entity_update); persisted to ai_log table
images.py # crop_save, prep_img_b64, serve_crop images.py # crop_save, prep_img_b64, serve_crop
migrate.py # DB migration; run_migration() called at startup
plugins/ plugins/
__init__.py # Registry: load_plugins(), get_plugin(), get_manifest() __init__.py # Registry: load_plugins(), get_plugin(), get_manifest(), get_all_text_recognizers(), get_all_book_identifiers(), get_all_archive_searchers()
rate_limiter.py # Thread-safe per-domain rate limiter rate_limiter.py # Thread-safe per-domain rate limiter
ai_compat/ # AI plugin implementations ai_compat/ # AI plugin implementations
archives/ # Archive plugin implementations archives/ # Archive plugin implementations
@@ -71,7 +74,7 @@ Categories:
| `credentials` | `base_url` + `api_key` per endpoint; no model or prompt | | `credentials` | `base_url` + `api_key` per endpoint; no model or prompt |
| `models` | `credentials` ref + `model` string + optional `extra_body` + `prompt` | | `models` | `credentials` ref + `model` string + optional `extra_body` + `prompt` |
| `functions` | Plugin definitions; dict key = plugin_id (unique across all categories) | | `functions` | Plugin definitions; dict key = plugin_id (unique across all categories) |
| `ui` | Frontend display settings | | `ui` | Frontend display settings (`boundary_grab_px`, `spine_padding_pct`, `ai_log_max_entries`) |
Minimal setup — create `config/credentials.user.yaml`: Minimal setup — create `config/credentials.user.yaml`:
```yaml ```yaml
@@ -88,9 +91,19 @@ credentials:
| `boundary_detectors` (`target=shelves`) | cabinet image | `{boundaries:[…], confidence:N}` | `cabinets.ai_shelf_boundaries` | | `boundary_detectors` (`target=shelves`) | cabinet image | `{boundaries:[…], confidence:N}` | `cabinets.ai_shelf_boundaries` |
| `boundary_detectors` (`target=books`) | shelf image | `{boundaries:[…]}` | `shelves.ai_book_boundaries` | | `boundary_detectors` (`target=books`) | shelf image | `{boundaries:[…]}` | `shelves.ai_book_boundaries` |
| `text_recognizers` | spine image | `{raw_text, title, author, …}` | `books.raw_text` + `candidates` | | `text_recognizers` | spine image | `{raw_text, title, author, …}` | `books.raw_text` + `candidates` |
| `book_identifiers` | raw_text | `{title, author, …, confidence}` | `books.ai_*` + `candidates` | | `book_identifiers` | raw_text + archive results + optional images | `[{title, author, …, score, sources}, …]` | `books.ai_blocks` + `books.ai_*` |
| `archive_searchers` | query string | `[{source, title, author, …}, …]` | `books.candidates` | | `archive_searchers` | query string | `[{source, title, author, …}, …]` | `books.candidates` |
### Identification pipeline (`POST /api/books/{id}/identify`)
Single endpoint runs the full pipeline in sequence:
1. **VLM text recognizer** reads the spine image → `raw_text` and structured fields.
2. **All archive searchers** run in parallel with title+author and title-only queries.
3. Archive results are **deduplicated** by normalized full-field match (case-insensitive, punctuation removed, spaces collapsed).
4. **Main identifier model** receives `raw_text`, deduplicated archive results, and (if `is_vlm: true`) spine + title-page images. Returns ranked `IdentifyBlock` list.
5. `ai_blocks` stored persistently in the DB (never cleared; overwritten each pipeline run). Top block updates `ai_*` fields if score ≥ `confidence_threshold`.
`functions.*.yaml` key for `book_identifiers`: add `is_vlm: true` for models that accept images.
### Universal plugin endpoint ### Universal plugin endpoint
``` ```
POST /api/{entity_type}/{entity_id}/plugin/{plugin_id} POST /api/{entity_type}/{entity_id}/plugin/{plugin_id}
@@ -108,14 +121,22 @@ All implement `search(query: str) -> list[CandidateRecord]`. Use shared `RATE_LI
### Auto-queue ### Auto-queue
- After `text_recognizer` completes → fires all `archive_searchers` with `auto_queue: true` in background thread pool. - 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. - `POST /api/batch`adds all unidentified books to the `batch_queue` DB table; starts `run_batch_consumer()` if not already running. Calling again while running adds newly-unidentified books to the live queue.
## Database Schema (key fields) ## Database Schema (key fields)
| Table | Notable columns | | Table | Notable columns |
|-------|-----------------| |-------|-----------------|
| `cabinets` | `shelf_boundaries` (JSON `[…]`), `ai_shelf_boundaries` (JSON `{pluginId:[…]}`) | | `cabinets` | `shelf_boundaries` (JSON `[…]`), `ai_shelf_boundaries` (JSON `{pluginId:[…]}`) |
| `shelves` | `book_boundaries`, `ai_book_boundaries` (same format), `photo_filename` (optional override) | | `shelves` | `book_boundaries`, `ai_book_boundaries` (same format), `photo_filename` (optional override) |
| `books` | `raw_text`, `ai_title/author/year/isbn/publisher`, `candidates` (JSON `[{source,…}]`), `identification_status` | | `books` | `raw_text`, `ai_title/author/year/isbn/publisher`, `candidates` (JSON `[{source,…}]`), `ai_blocks` (JSON `[{title,author,year,isbn,publisher,score,sources}]`), `identification_status` |
| `batch_queue` | `book_id` (PK), `added_at` — persistent batch processing queue; consumed in FIFO order by `run_batch_consumer()` |
`ai_blocks` are persistent: set by the identification pipeline, shown in the book detail panel as clickable cards. Hidden by default for `user_approved` books.
### DB Migration (`src/migrate.py`)
`run_migration()` is called at startup (after `init_db()`). Migrations:
- `_migrate_v1`: adds the `ai_blocks` column if absent; clears stale AI fields (runs once only, not on every startup).
- `_migrate_v2`: creates the `batch_queue` table if absent.
`identification_status`: `unidentified``ai_identified``user_approved`. `identification_status`: `unidentified``ai_identified``user_approved`.
@@ -127,7 +148,12 @@ N interior boundaries → N+1 segments. `full = [0] + boundaries + [1]`. Segment
- Book K spine = shelf image cropped to `(x_start, *, x_end, *)` with composed crop if cabinet-based - Book K spine = shelf image cropped to `(x_start, *, x_end, *)` with composed crop if cabinet-based
## Frontend JS ## Frontend JS
No ES modules, no bundler. All files use global scope; load order in `index.html` is the dependency order. State lives in `state.js` (`S`, `_plugins`, `_bnd`, `_photoQueue`, etc.). Events delegated via `#app` in `events.js`. No ES modules, no bundler. All files use global scope; load order in `index.html` is the dependency order. State lives in `state.js` (`S`, `_plugins`, `_bnd`, `_photoQueue`, `_aiLog`, `_aiLogWs`, etc.). Events delegated via `#app` in `events.js`.
`connectAiLogWs()` subscribes to `/ws/ai-log` on startup. Message types:
- `snapshot` — full log on connect (`_aiLog` initialized)
- `update` — single log entry added or updated (spinner count in header updated)
- `entity_update` — entity data changed (tree node updated via `walkTree`; detail panel or full render depending on selection)
## Tooling ## Tooling
``` ```
@@ -150,8 +176,11 @@ PATCH /api/cabinets/{id}/boundaries # update shelf boundary
PATCH /api/shelves/{id}/boundaries # update book boundary list PATCH /api/shelves/{id}/boundaries # update book boundary list
GET /api/shelves/{id}/image # shelf image (override or cabinet crop) GET /api/shelves/{id}/image # shelf image (override or cabinet crop)
GET /api/books/{id}/spine # book spine crop GET /api/books/{id}/spine # book spine crop
POST /api/books/{id}/identify # full identification pipeline (VLM → archives → main model)
POST /api/books/{id}/process # full auto-queue pipeline (single book) POST /api/books/{id}/process # full auto-queue pipeline (single book)
POST /api/batch / GET /api/batch/status # batch processing POST /api/batch / GET /api/batch/status # batch processing
WS /ws/batch # batch progress push (replaces polling)
WS /ws/ai-log # AI request log: snapshot + update per request + entity_update on book changes
POST /api/books/{id}/dismiss-field # dismiss a candidate suggestion POST /api/books/{id}/dismiss-field # dismiss a candidate suggestion
PATCH /api/{kind}/reorder # drag-to-reorder PATCH /api/{kind}/reorder # drag-to-reorder
POST /api/cabinets/{id}/crop / POST /api/shelves/{id}/crop # permanent crop POST /api/cabinets/{id}/crop / POST /api/shelves/{id}/crop # permanent crop

View File

@@ -19,9 +19,12 @@ const appGlobals = {
S: 'writable', S: 'writable',
_plugins: 'writable', _plugins: 'writable',
_batchState: 'writable', _batchState: 'writable',
_batchPollTimer: 'writable', _batchWs: 'writable',
_bnd: 'writable', _bnd: 'writable',
_photoQueue: 'writable', _photoQueue: 'writable',
_aiBlocksVisible: 'writable',
_aiLog: 'writable',
_aiLogWs: 'writable',
// helpers.js // helpers.js
esc: 'readonly', esc: 'readonly',
@@ -46,6 +49,7 @@ const appGlobals = {
isLoading: 'readonly', isLoading: 'readonly',
vPluginBtn: 'readonly', vPluginBtn: 'readonly',
vBatchBtn: 'readonly', vBatchBtn: 'readonly',
vAiIndicator: 'readonly',
candidateSugRows: 'readonly', candidateSugRows: 'readonly',
_STATUS_BADGE: 'readonly', _STATUS_BADGE: 'readonly',
getBookStats: 'readonly', getBookStats: 'readonly',
@@ -56,6 +60,7 @@ const appGlobals = {
// detail-render.js // detail-render.js
vDetailBody: 'readonly', vDetailBody: 'readonly',
aiBlocksShown: 'readonly',
// canvas-crop.js // canvas-crop.js
startCropMode: 'readonly', startCropMode: 'readonly',
@@ -72,7 +77,8 @@ const appGlobals = {
// init.js // init.js
render: 'readonly', render: 'readonly',
renderDetail: 'readonly', renderDetail: 'readonly',
startBatchPolling: 'readonly', connectBatchWs: 'readonly',
connectAiLogWs: 'readonly',
loadTree: 'readonly', loadTree: 'readonly',
// CDN (SortableJS loaded via <script> in index.html) // CDN (SortableJS loaded via <script> in index.html)
@@ -96,8 +102,15 @@ export default [
// Catch typos and missing globals // Catch typos and missing globals
'no-undef': 'error', 'no-undef': 'error',
// builtinGlobals:false — only catch intra-file re-declarations, not globals
// from appGlobals which are intentionally re-defined in their owning file.
'no-redeclare': ['error', { builtinGlobals: false }],
// Unused variables: allow leading-underscore convention for intentional ignores // Unused variables: allow leading-underscore convention for intentional ignores
'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], 'no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
],
// Require strict equality // Require strict equality
eqeqeq: ['error', 'always', { null: 'ignore' }], eqeqeq: ['error', 'always', { null: 'ignore' }],

1105
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -48,6 +48,7 @@ line-length = 120
[tool.flake8] [tool.flake8]
max-line-length = 120 max-line-length = 120
extend-ignore = ["E203"] extend-ignore = ["E203"]
exclude = "node_modules/*"
[tool.pyright] [tool.pyright]
pythonVersion = "3.14" pythonVersion = "3.14"
@@ -56,6 +57,7 @@ include = ["src", "tests", "scripts"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["src"] pythonpath = ["src"]
markers = ["network: live HTTP requests to external services (deselect with -m 'not network')"]
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]

View File

@@ -24,7 +24,7 @@ def presubmit():
["black", "--check", "."], ["black", "--check", "."],
["flake8", "."], ["flake8", "."],
["pyright"], ["pyright"],
["pytest", "tests/"], ["pytest", "tests/", "-m", "not network"],
# JS: tests run via Node built-in runner (no npm packages needed) # JS: tests run via Node built-in runner (no npm packages needed)
["node", "--test", "tests/js/pure-functions.test.js"], ["node", "--test", "tests/js/pure-functions.test.js"],
] ]
@@ -53,3 +53,7 @@ def presubmit():
print(f"\nFailed: {', '.join(failed)}", file=sys.stderr) print(f"\nFailed: {', '.join(failed)}", file=sys.stderr)
sys.exit(1) sys.exit(1)
print("\nAll presubmit checks passed.") print("\nAll presubmit checks passed.")
print(
"\nReminder: if this task changed architecture, layer boundaries, config schema,\n"
"directory layout, API endpoints, or the plugin system — update docs/overview.md."
)

View File

@@ -8,9 +8,10 @@ No SQL here; no business logic here.
import asyncio import asyncio
import dataclasses import dataclasses
import json import json
import time
from typing import Any, TypeVar from typing import Any, TypeVar
from fastapi import APIRouter, File, HTTPException, Request, UploadFile from fastapi import APIRouter, File, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
from mashumaro.codecs import BasicDecoder from mashumaro.codecs import BasicDecoder
import db import db
@@ -55,8 +56,12 @@ async def _parse(decoder: BasicDecoder[_T], request: Request) -> _T:
@router.get("/api/config") @router.get("/api/config")
def api_config() -> dict[str, Any]: def api_config() -> dict[str, Any]:
cfg = get_config()
logic.set_max_entries(cfg.ui.ai_log_max_entries)
return { return {
"boundary_grab_px": get_config().ui.boundary_grab_px, "boundary_grab_px": cfg.ui.boundary_grab_px,
"spine_padding_pct": cfg.ui.spine_padding_pct,
"ai_log_max_entries": cfg.ui.ai_log_max_entries,
"plugins": plugin_registry.get_manifest(), "plugins": plugin_registry.get_manifest(),
} }
@@ -205,11 +210,19 @@ async def update_shelf(shelf_id: str, request: Request) -> dict[str, Any]:
@router.delete("/api/shelves/{shelf_id}") @router.delete("/api/shelves/{shelf_id}")
async def delete_shelf(shelf_id: str) -> dict[str, Any]: async def delete_shelf(shelf_id: str) -> dict[str, Any]:
with db.connection() as c: with db.connection() as c:
if not db.get_shelf(c, shelf_id): shelf = db.get_shelf(c, shelf_id)
if not shelf:
raise HTTPException(404, "Shelf not found") raise HTTPException(404, "Shelf not found")
photos = db.collect_shelf_photos(c, shelf_id) photos = db.collect_shelf_photos(c, shelf_id)
rank = db.get_shelf_rank(c, shelf_id)
cab = db.get_cabinet(c, shelf.cabinet_id)
with db.transaction() as c: with db.transaction() as c:
db.delete_shelf(c, shelf_id) db.delete_shelf(c, shelf_id)
if cab:
bounds: list[float] = json.loads(cab.shelf_boundaries) if cab.shelf_boundaries else []
if bounds:
del bounds[min(rank, len(bounds) - 1)]
db.set_cabinet_boundaries(c, cab.id, json.dumps(bounds))
for fn in photos: for fn in photos:
del_photo(fn) del_photo(fn)
return {"ok": True} return {"ok": True}
@@ -293,11 +306,19 @@ async def update_book(book_id: str, request: Request) -> dict[str, Any]:
@router.delete("/api/books/{book_id}") @router.delete("/api/books/{book_id}")
async def delete_book(book_id: str) -> dict[str, Any]: async def delete_book(book_id: str) -> dict[str, Any]:
with db.connection() as c: with db.connection() as c:
if not db.get_book(c, book_id): book = db.get_book(c, book_id)
if not book:
raise HTTPException(404, "Book not found") raise HTTPException(404, "Book not found")
fn = db.get_book_photo(c, book_id) fn = db.get_book_photo(c, book_id)
rank = db.get_book_rank(c, book_id)
shelf = db.get_shelf(c, book.shelf_id)
with db.transaction() as c: with db.transaction() as c:
db.delete_book(c, book_id) db.delete_book(c, book_id)
if shelf:
bounds: list[float] = json.loads(shelf.book_boundaries) if shelf.book_boundaries else []
if bounds:
del bounds[min(rank, len(bounds) - 1)]
db.set_shelf_boundaries(c, shelf.id, json.dumps(bounds))
del_photo(fn) del_photo(fn)
return {"ok": True} return {"ok": True}
@@ -317,8 +338,9 @@ async def book_photo(book_id: str, image: UploadFile = File(...)) -> dict[str, A
@router.get("/api/books/{book_id}/spine") @router.get("/api/books/{book_id}/spine")
def book_spine(book_id: str) -> Any: def book_spine(book_id: str) -> Any:
padding = get_config().ui.spine_padding_pct
with db.connection() as c: with db.connection() as c:
path, crop = book_spine_source(c, book_id) path, crop = book_spine_source(c, book_id, padding)
return serve_crop(path, crop) return serve_crop(path, crop)
@@ -349,6 +371,26 @@ async def process_book(book_id: str) -> dict[str, Any]:
return dataclasses.asdict(book) return dataclasses.asdict(book)
@router.post("/api/books/{book_id}/identify")
async def identify_book(book_id: str) -> dict[str, Any]:
"""Run the full identification pipeline (VLM -> archives -> main model) for a single book."""
with db.connection() as c:
if not db.get_book(c, book_id):
raise HTTPException(404, "Book not found")
loop = asyncio.get_event_loop()
started = time.time()
entry_id = logic.log_start("identify_pipeline", "books", book_id, "pipeline", book_id)
try:
result = await loop.run_in_executor(logic.batch_executor, logic.run_identify_pipeline, book_id)
logic.log_finish(entry_id, "ok", result.ai_title or "", started)
except Exception as exc:
logic.log_finish(entry_id, "error", str(exc), started)
raise
result_dict = dataclasses.asdict(result)
logic.notify_entity_update("books", book_id, result_dict)
return result_dict
# ── Universal plugin endpoint ───────────────────────────────────────────────── # ── Universal plugin endpoint ─────────────────────────────────────────────────
@@ -377,14 +419,15 @@ async def run_plugin(entity_type: str, entity_id: str, plugin_id: str) -> dict[s
@router.post("/api/batch") @router.post("/api/batch")
async def start_batch() -> dict[str, Any]: async def start_batch() -> dict[str, Any]:
if logic.batch_state["running"]:
return {"already_running": True}
with db.connection() as c: with db.connection() as c:
ids = db.get_unidentified_book_ids(c) ids = db.get_unidentified_book_ids(c)
if not ids: if not ids:
return {"started": False, "reason": "no_unidentified_books"} return {"started": False, "reason": "no_unidentified_books"}
asyncio.create_task(logic.run_batch(ids)) added = logic.add_to_queue(ids)
return {"started": True, "total": len(ids)} if logic.batch_state["running"]:
return {"already_running": True, "added": added}
asyncio.create_task(logic.run_batch_consumer())
return {"started": True, "added": added}
@router.get("/api/batch/status") @router.get("/api/batch/status")
@@ -392,6 +435,48 @@ def batch_status() -> dict[str, Any]:
return dict(logic.batch_state) return dict(logic.batch_state)
@router.websocket("/ws/batch")
async def ws_batch(websocket: WebSocket) -> None:
"""Stream batch_state snapshots as JSON until the batch finishes or the client disconnects.
Sends the current state immediately on connect, then pushes each subsequent
update until running transitions to false.
"""
await websocket.accept()
q = logic.subscribe_batch()
try:
await websocket.send_json(dict(logic.batch_state))
while logic.batch_state["running"]:
state = await q.get()
await websocket.send_json(state)
if not state["running"]:
break
except WebSocketDisconnect:
pass
finally:
logic.unsubscribe_batch(q)
@router.websocket("/ws/ai-log")
async def ws_ai_log(websocket: WebSocket) -> None:
"""Stream AI request log entries as JSON.
Sends a snapshot of all current entries on connect, then pushes each new
update message until the client disconnects.
"""
await websocket.accept()
q = logic.subscribe_log()
try:
await websocket.send_json({"type": "snapshot", "entries": logic.get_snapshot()})
while True:
msg = await q.get()
await websocket.send_json(msg)
except WebSocketDisconnect:
pass
finally:
logic.unsubscribe_log(q)
# ── Reorder ─────────────────────────────────────────────────────────────────── # ── Reorder ───────────────────────────────────────────────────────────────────
_REORDER_TABLES = {"rooms", "cabinets", "shelves", "books"} _REORDER_TABLES = {"rooms", "cabinets", "shelves", "books"}

View File

@@ -8,18 +8,21 @@ Usage:
poetry run serve poetry run serve
""" """
import asyncio
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import logic
import plugins as plugin_registry import plugins as plugin_registry
from api import router from api import router
from config import get_config, load_config from config import get_config, load_config
from db import init_db from db import init_db
from files import IMAGES_DIR, init_dirs from files import IMAGES_DIR, init_dirs
from errors import BadRequestError, ConfigError, ImageReadError, NotFoundError from errors import BadRequestError, ConfigError, ImageReadError, NotFoundError
from migrate import run_migration
@asynccontextmanager @asynccontextmanager
@@ -27,8 +30,22 @@ async def lifespan(app: FastAPI):
load_config() load_config()
init_dirs() init_dirs()
init_db() init_db()
run_migration()
plugin_registry.load_plugins(get_config()) plugin_registry.load_plugins(get_config())
cfg = get_config()
logic.load_from_db(cfg.ui.ai_log_max_entries)
logic.init_thread_logging(asyncio.get_running_loop())
pending = logic.get_pending_batch()
if pending:
asyncio.create_task(logic.run_batch_consumer())
yield yield
# Graceful shutdown: cancel the running batch task so uvicorn isn't blocked,
# then release executor threads (running threads finish naturally in the background).
task = logic.get_batch_task()
if task is not None and not task.done():
task.cancel()
logic.batch_executor.shutdown(wait=False, cancel_futures=True)
logic.archive_executor.shutdown(wait=False, cancel_futures=True)
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)

View File

@@ -53,6 +53,7 @@ class AIFunctionConfig:
max_image_px: int = 1600 max_image_px: int = 1600
confidence_threshold: float = 0.8 confidence_threshold: float = 0.8
name: str = "" name: str = ""
is_vlm: bool = False
@dataclass @dataclass
@@ -76,6 +77,8 @@ class FunctionsConfig:
@dataclass @dataclass
class UIConfig: class UIConfig:
boundary_grab_px: int = 14 boundary_grab_px: int = 14
spine_padding_pct: float = 0.10
ai_log_max_entries: int = 100
@dataclass @dataclass

115
src/db.py
View File

@@ -5,11 +5,13 @@ No file I/O, no config, no business logic. All SQL lives here.
import json import json
import sqlite3 import sqlite3
import time
import uuid import uuid
from collections.abc import Iterator from collections.abc import Iterator
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any
from mashumaro.codecs import BasicDecoder from mashumaro.codecs import BasicDecoder
@@ -67,7 +69,24 @@ CREATE TABLE IF NOT EXISTS books (
title_confidence REAL DEFAULT 0, title_confidence REAL DEFAULT 0,
analyzed_at TEXT, analyzed_at TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
candidates TEXT DEFAULT NULL candidates TEXT DEFAULT NULL,
ai_blocks TEXT DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS ai_log (
id TEXT PRIMARY KEY,
ts REAL NOT NULL,
plugin_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
model TEXT NOT NULL,
request TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'running',
response TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS batch_queue (
book_id TEXT PRIMARY KEY,
added_at REAL NOT NULL
); );
""" """
@@ -413,11 +432,12 @@ def create_book(db: sqlite3.Connection, shelf_id: str) -> BookRow:
"analyzed_at": None, "analyzed_at": None,
"created_at": now(), "created_at": now(),
"candidates": None, "candidates": None,
"ai_blocks": None,
} }
db.execute( db.execute(
"INSERT INTO books VALUES(:id,:shelf_id,:position,:image_filename,:title,:author,:year,:isbn,:publisher," "INSERT INTO books VALUES(:id,:shelf_id,:position,:image_filename,:title,:author,:year,:isbn,:publisher,"
":notes,:raw_text,:ai_title,:ai_author,:ai_year,:ai_isbn,:ai_publisher,:identification_status," ":notes,:raw_text,:ai_title,:ai_author,:ai_year,:ai_isbn,:ai_publisher,:identification_status,"
":title_confidence,:analyzed_at,:created_at,:candidates)", ":title_confidence,:analyzed_at,:created_at,:candidates,:ai_blocks)",
data, data,
) )
return _book_dec.decode(data) return _book_dec.decode(data)
@@ -494,6 +514,10 @@ def set_book_candidates(db: sqlite3.Connection, book_id: str, candidates_json: s
db.execute("UPDATE books SET candidates=? WHERE id=?", [candidates_json, book_id]) db.execute("UPDATE books SET candidates=? WHERE id=?", [candidates_json, book_id])
def set_book_ai_blocks(db: sqlite3.Connection, book_id: str, ai_blocks_json: str) -> None:
db.execute("UPDATE books SET ai_blocks=? WHERE id=?", [ai_blocks_json, book_id])
def get_book_rank(db: sqlite3.Connection, book_id: str) -> int: def get_book_rank(db: sqlite3.Connection, book_id: str) -> int:
"""0-based rank of book among its siblings sorted by position.""" """0-based rank of book among its siblings sorted by position."""
row = db.execute("SELECT shelf_id FROM books WHERE id=?", [book_id]).fetchone() row = db.execute("SELECT shelf_id FROM books WHERE id=?", [book_id]).fetchone()
@@ -513,3 +537,90 @@ def get_unidentified_book_ids(db: sqlite3.Connection) -> list[str]:
def reorder_entities(db: sqlite3.Connection, table: str, ids: list[str]) -> None: def reorder_entities(db: sqlite3.Connection, table: str, ids: list[str]) -> None:
for i, entity_id in enumerate(ids, 1): for i, entity_id in enumerate(ids, 1):
db.execute(f"UPDATE {table} SET position=? WHERE id=?", [i, entity_id]) db.execute(f"UPDATE {table} SET position=? WHERE id=?", [i, entity_id])
# ── AI log ────────────────────────────────────────────────────────────────────
def insert_ai_log_entry(
db: sqlite3.Connection,
entry_id: str,
ts: float,
plugin_id: str,
entity_type: str,
entity_id: str,
model: str,
request: str,
) -> None:
"""Insert a new AI log entry with status='running'."""
db.execute(
"INSERT OR IGNORE INTO ai_log"
" (id, ts, plugin_id, entity_type, entity_id, model, request) VALUES (?,?,?,?,?,?,?)",
[entry_id, ts, plugin_id, entity_type, entity_id, model, request],
)
def update_ai_log_entry(db: sqlite3.Connection, entry_id: str, status: str, response: str, duration_ms: int) -> None:
"""Update an AI log entry with the final status and response."""
db.execute(
"UPDATE ai_log SET status=?, response=?, duration_ms=? WHERE id=?",
[status, response, duration_ms, entry_id],
)
def get_ai_log_entries(db: sqlite3.Connection, limit: int) -> list[dict[str, Any]]:
"""Return the most recent AI log entries, oldest first."""
rows = db.execute(
"SELECT id, ts, plugin_id, entity_type, entity_id, model, request, status, response, duration_ms"
" FROM ai_log ORDER BY ts DESC LIMIT ?",
[limit],
).fetchall()
return [dict(r) for r in reversed(rows)]
# ── Batch queue ────────────────────────────────────────────────────────────────
def add_to_batch_queue(db: sqlite3.Connection, book_ids: list[str]) -> None:
"""Insert book IDs into the batch queue, ignoring duplicates.
Args:
db: Open database connection (must be writable).
book_ids: Book IDs to enqueue.
"""
ts = time.time()
db.executemany(
"INSERT OR IGNORE INTO batch_queue (book_id, added_at) VALUES (?,?)", [(bid, ts) for bid in book_ids]
)
def remove_from_batch_queue(db: sqlite3.Connection, book_id: str) -> None:
"""Remove a single book ID from the batch queue.
Args:
db: Open database connection (must be writable).
book_id: Book ID to dequeue.
"""
db.execute("DELETE FROM batch_queue WHERE book_id=?", [book_id])
def get_batch_queue(db: sqlite3.Connection) -> list[str]:
"""Return all queued book IDs ordered by insertion time (oldest first).
Args:
db: Open database connection.
Returns:
List of book ID strings.
"""
rows = db.execute("SELECT book_id FROM batch_queue ORDER BY added_at").fetchall()
return [str(r[0]) for r in rows]
def clear_batch_queue(db: sqlite3.Connection) -> None:
"""Remove all entries from the batch queue.
Args:
db: Open database connection (must be writable).
"""
db.execute("DELETE FROM batch_queue")

View File

@@ -154,6 +154,21 @@ class NoRawTextError(BadRequestError):
return f"Book {self.book_id!r} has no raw text; run text recognizer first" return f"Book {self.book_id!r} has no raw text; run text recognizer first"
class NoPipelinePluginError(BadRequestError):
"""Raised when the identification pipeline requires a plugin category with no registered plugins.
Attributes:
plugin_category: The plugin category (e.g. 'text_recognizer') that has no registered plugins.
"""
def __init__(self, plugin_category: str) -> None:
super().__init__()
self.plugin_category = plugin_category
def __str__(self) -> str:
return f"No {self.plugin_category!r} plugin configured; add one to functions.*.yaml"
class InvalidPluginEntityError(BadRequestError): class InvalidPluginEntityError(BadRequestError):
"""Raised when a plugin category does not support the requested entity type. """Raised when a plugin category does not support the requested entity type.

141
src/log_thread.py Normal file
View File

@@ -0,0 +1,141 @@
"""Thread-safe AI logging helpers for use from thread pool workers.
Provides start_entry() / finish_entry() that schedule log operations on the
event loop via call_soon_threadsafe, making them safe to call from executor
threads. Also provides a ContextVar so plugin/entity context flows through
asyncio.run_in_executor() calls automatically.
Initialized by logic/ai_log.py at app startup via set_app_loop().
Importable by both logic/ and plugins/ without circular dependencies.
"""
import concurrent.futures
import time
from collections.abc import Callable
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Any
try:
import asyncio as _asyncio
_AbstractEventLoop = _asyncio.AbstractEventLoop
except ImportError: # pragma: no cover
_AbstractEventLoop = Any # type: ignore[assignment,misc]
import asyncio
@dataclass
class _LogCtx:
plugin_id: str
entity_type: str
entity_id: str
# ContextVar propagated automatically into asyncio executor threads.
_ctx: ContextVar[_LogCtx | None] = ContextVar("_log_ctx", default=None)
# Initialized at startup by set_app_loop().
_loop: asyncio.AbstractEventLoop | None = None
_log_start_fn: Callable[..., str] | None = None
_log_finish_fn: Callable[..., None] | None = None
def set_app_loop(
loop: asyncio.AbstractEventLoop,
log_start: Callable[..., str],
log_finish: Callable[..., None],
) -> None:
"""Store the running event loop and logging callables.
Must be called once at app startup from the async context.
Args:
loop: The running asyncio event loop.
log_start: Synchronous log_start function from logic.ai_log.
log_finish: Synchronous log_finish function from logic.ai_log.
"""
global _loop, _log_start_fn, _log_finish_fn
_loop = loop
_log_start_fn = log_start
_log_finish_fn = log_finish
def set_log_ctx(plugin_id: str, entity_type: str, entity_id: str) -> None:
"""Set the current log context for this thread/task.
Call before run_in_executor() to propagate context into executor threads.
Or call directly inside a thread to set context for subsequent calls in
the same thread.
Args:
plugin_id: Plugin ID to attribute log entries to.
entity_type: Entity type (e.g. ``"books"``).
entity_id: Entity ID.
"""
_ctx.set(_LogCtx(plugin_id=plugin_id, entity_type=entity_type, entity_id=entity_id))
def start_entry(model: str, request_summary: str) -> str:
"""Start a log entry from a thread pool worker.
Reads context from the ContextVar set by set_log_ctx(). Schedules
log_start on the event loop and blocks briefly to obtain the entry ID.
Returns empty string if context or loop is unavailable.
Args:
model: Model name used for the request.
request_summary: Short human-readable description.
Returns:
Log entry ID string, or ``""`` if logging is unavailable.
"""
ctx = _ctx.get()
if ctx is None or _loop is None or _log_start_fn is None:
return ""
fut: concurrent.futures.Future[str] = concurrent.futures.Future()
fn = _log_start_fn
pid, et, eid = ctx.plugin_id, ctx.entity_type, ctx.entity_id
def _call() -> None:
try:
entry_id = fn(pid, et, eid, model, request_summary)
fut.set_result(entry_id)
except Exception as exc: # noqa: BLE001
fut.set_exception(exc)
_loop.call_soon_threadsafe(_call)
try:
return fut.result(timeout=5)
except Exception:
return ""
def finish_entry(entry_id: str, status: str, response: str, started_at: float) -> None:
"""Finish a log entry from a thread pool worker (fire-and-forget).
Schedules log_finish on the event loop. Does nothing if entry_id is empty
or the loop is unavailable.
Args:
entry_id: ID returned by start_entry().
status: ``"ok"`` or ``"error"``.
response: Short summary of response or error message.
started_at: ``time.time()`` value recorded before the request.
"""
if not entry_id or _loop is None or _log_finish_fn is None:
return
fn = _log_finish_fn
_loop.call_soon_threadsafe(fn, entry_id, status, response, started_at)
def timed_start(model: str, request_summary: str) -> tuple[str, float]:
"""Convenience wrapper: start an entry and record the start time.
Returns:
Tuple of (entry_id, started_at) for passing to finish_entry().
"""
started_at = time.time()
entry_id = start_entry(model, request_summary)
return entry_id, started_at

View File

@@ -2,13 +2,37 @@
import asyncio import asyncio
import dataclasses import dataclasses
import time
from typing import Any from typing import Any
import log_thread
import plugins as plugin_registry import plugins as plugin_registry
from errors import InvalidPluginEntityError, PluginNotFoundError, PluginTargetMismatchError from errors import InvalidPluginEntityError, PluginNotFoundError, PluginTargetMismatchError
from models import PluginLookupResult from models import PluginLookupResult
from logic.ai_log import (
get_snapshot,
init_thread_logging,
load_from_db,
log_finish,
log_start,
notify_entity_update,
set_max_entries,
subscribe_log,
unsubscribe_log,
)
from logic.archive import run_archive_searcher, run_archive_searcher_bg from logic.archive import run_archive_searcher, run_archive_searcher_bg
from logic.batch import archive_executor, batch_executor, batch_state, process_book_sync, run_batch from logic.batch import (
add_to_queue,
archive_executor,
batch_executor,
batch_state,
get_batch_task,
get_pending_batch,
process_book_sync,
run_batch_consumer,
subscribe_batch,
unsubscribe_batch,
)
from logic.boundaries import book_spine_source, bounds_for_index, run_boundary_detector, shelf_source from logic.boundaries import book_spine_source, bounds_for_index, run_boundary_detector, shelf_source
from logic.identification import ( from logic.identification import (
AI_FIELDS, AI_FIELDS,
@@ -17,6 +41,7 @@ from logic.identification import (
compute_status, compute_status,
dismiss_field, dismiss_field,
run_book_identifier, run_book_identifier,
run_identify_pipeline,
run_text_recognizer, run_text_recognizer,
save_user_fields, save_user_fields,
) )
@@ -24,6 +49,7 @@ from logic.images import prep_img_b64, crop_save, serve_crop
__all__ = [ __all__ = [
"AI_FIELDS", "AI_FIELDS",
"add_to_queue",
"apply_ai_result", "apply_ai_result",
"archive_executor", "archive_executor",
"batch_executor", "batch_executor",
@@ -35,17 +61,31 @@ __all__ = [
"crop_save", "crop_save",
"dismiss_field", "dismiss_field",
"dispatch_plugin", "dispatch_plugin",
"get_batch_task",
"get_pending_batch",
"get_snapshot",
"init_thread_logging",
"load_from_db",
"log_finish",
"log_start",
"notify_entity_update",
"prep_img_b64",
"process_book_sync", "process_book_sync",
"run_archive_searcher", "run_archive_searcher",
"run_archive_searcher_bg", "run_archive_searcher_bg",
"run_batch", "run_batch_consumer",
"run_book_identifier", "run_book_identifier",
"run_boundary_detector", "run_boundary_detector",
"run_identify_pipeline",
"run_text_recognizer", "run_text_recognizer",
"save_user_fields", "save_user_fields",
"serve_crop", "serve_crop",
"set_max_entries",
"shelf_source", "shelf_source",
"prep_img_b64", "subscribe_batch",
"subscribe_log",
"unsubscribe_batch",
"unsubscribe_log",
] ]
@@ -58,6 +98,10 @@ async def dispatch_plugin(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Validate plugin/entity compatibility, run the plugin, and trigger auto-queue follow-ups. """Validate plugin/entity compatibility, run the plugin, and trigger auto-queue follow-ups.
Sets the log context ContextVar before each run_in_executor call so that
AIClient and archive runner logging is attributed to the correct plugin and entity.
After a successful run, broadcasts an entity_update to WebSocket subscribers.
Args: Args:
plugin_id: The plugin ID string (used in error reporting). plugin_id: The plugin ID string (used in error reporting).
lookup: Discriminated tuple from plugins.get_plugin(); (None, None) if not found. lookup: Discriminated tuple from plugins.get_plugin(); (None, None) if not found.
@@ -84,25 +128,65 @@ async def dispatch_plugin(
raise PluginTargetMismatchError(plugin.plugin_id, "shelves", plugin.target) raise PluginTargetMismatchError(plugin.plugin_id, "shelves", plugin.target)
if entity_type == "shelves" and plugin.target != "books": if entity_type == "shelves" and plugin.target != "books":
raise PluginTargetMismatchError(plugin.plugin_id, "books", plugin.target) raise PluginTargetMismatchError(plugin.plugin_id, "books", plugin.target)
started = time.time()
entry_id = log_start(plugin_id, entity_type, entity_id, plugin.model, entity_id)
log_thread.set_log_ctx(plugin_id, entity_type, entity_id)
try:
result = await loop.run_in_executor(None, run_boundary_detector, plugin, entity_type, entity_id) result = await loop.run_in_executor(None, run_boundary_detector, plugin, entity_type, entity_id)
return dataclasses.asdict(result) log_finish(entry_id, "ok", "done", started)
except Exception as exc:
log_finish(entry_id, "error", str(exc), started)
raise
result_dict = dataclasses.asdict(result)
notify_entity_update(entity_type, entity_id, result_dict)
return result_dict
case ("text_recognizer", plugin): case ("text_recognizer", plugin):
if entity_type != "books": if entity_type != "books":
raise InvalidPluginEntityError("text_recognizer", entity_type) raise InvalidPluginEntityError("text_recognizer", entity_type)
started = time.time()
entry_id = log_start(plugin_id, entity_type, entity_id, plugin.model, entity_id)
log_thread.set_log_ctx(plugin_id, entity_type, entity_id)
try:
result = await loop.run_in_executor(None, run_text_recognizer, plugin, entity_id) result = await loop.run_in_executor(None, run_text_recognizer, plugin, entity_id)
log_finish(entry_id, "ok", result.raw_text[:120] if result.raw_text else "", started)
except Exception as exc:
log_finish(entry_id, "error", str(exc), started)
raise
for ap in plugin_registry.get_auto_queue("archive_searchers"): for ap in plugin_registry.get_auto_queue("archive_searchers"):
loop.run_in_executor(archive_executor, run_archive_searcher_bg, ap, entity_id) loop.run_in_executor(archive_executor, run_archive_searcher_bg, ap, entity_id)
return dataclasses.asdict(result) result_dict = dataclasses.asdict(result)
notify_entity_update(entity_type, entity_id, result_dict)
return result_dict
case ("book_identifier", plugin): case ("book_identifier", plugin):
if entity_type != "books": if entity_type != "books":
raise InvalidPluginEntityError("book_identifier", entity_type) raise InvalidPluginEntityError("book_identifier", entity_type)
started = time.time()
entry_id = log_start(plugin_id, entity_type, entity_id, plugin.model, entity_id)
log_thread.set_log_ctx(plugin_id, entity_type, entity_id)
try:
result = await loop.run_in_executor(None, run_book_identifier, plugin, entity_id) result = await loop.run_in_executor(None, run_book_identifier, plugin, entity_id)
return dataclasses.asdict(result) log_finish(entry_id, "ok", result.ai_title or "", started)
except Exception as exc:
log_finish(entry_id, "error", str(exc), started)
raise
result_dict = dataclasses.asdict(result)
notify_entity_update(entity_type, entity_id, result_dict)
return result_dict
case ("archive_searcher", plugin): case ("archive_searcher", plugin):
if entity_type != "books": if entity_type != "books":
raise InvalidPluginEntityError("archive_searcher", entity_type) raise InvalidPluginEntityError("archive_searcher", entity_type)
started = time.time()
entry_id = log_start(plugin_id, entity_type, entity_id, "", entity_id)
log_thread.set_log_ctx(plugin_id, entity_type, entity_id)
try:
result = await loop.run_in_executor(archive_executor, run_archive_searcher, plugin, entity_id) result = await loop.run_in_executor(archive_executor, run_archive_searcher, plugin, entity_id)
return dataclasses.asdict(result) log_finish(entry_id, "ok", "done", started)
except Exception as exc:
log_finish(entry_id, "error", str(exc), started)
raise
result_dict = dataclasses.asdict(result)
notify_entity_update(entity_type, entity_id, result_dict)
return result_dict

190
src/logic/ai_log.py Normal file
View File

@@ -0,0 +1,190 @@
"""AI request log: ring buffer with WebSocket pub-sub for live UI updates.
Entries are persisted to the ai_log table so they survive service restarts.
Call load_from_db() once at startup after init_db() to populate the ring buffer.
Call init_thread_logging() once at startup to enable logging from executor threads.
"""
import asyncio
import time
from collections import deque
from typing import Any
import db
import log_thread
from models import AiLogEntry
# Ring buffer; max size set at runtime by set_max_entries().
_log: deque[AiLogEntry] = deque(maxlen=100)
_log_subs: set[asyncio.Queue[dict[str, Any]]] = set()
_next_id: list[int] = [0]
def set_max_entries(n: int) -> None:
"""Resize the ring buffer.
Args:
n: Maximum number of entries to retain.
"""
global _log
_log = deque(_log, maxlen=n)
def subscribe_log() -> asyncio.Queue[dict[str, Any]]:
"""Register a subscriber for AI log updates.
Returns:
Queue that will receive update messages as dicts with keys
``type`` (``"snapshot"`` or ``"update"``) and either ``entries``
or ``entry``.
"""
q: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
_log_subs.add(q)
return q
def unsubscribe_log(q: asyncio.Queue[dict[str, Any]]) -> None:
"""Remove a subscriber queue.
Args:
q: Queue previously returned by subscribe_log().
"""
_log_subs.discard(q)
def get_snapshot() -> list[AiLogEntry]:
"""Return a copy of the current log for snapshot delivery on WS connect.
Returns:
List of AiLogEntry dicts, oldest first.
"""
return list(_log)
def load_from_db(limit: int = 100) -> None:
"""Populate the in-memory ring buffer from the database.
Call once at startup after init_db(). Does not push WS notifications.
Any numeric IDs loaded from the DB advance _next_id to avoid collisions.
Args:
limit: Maximum number of entries to load (most recent).
"""
with db.connection() as c:
rows = db.get_ai_log_entries(c, limit)
for row in rows:
entry: AiLogEntry = {
"id": str(row["id"]),
"ts": float(str(row["ts"])),
"plugin_id": str(row["plugin_id"]),
"entity_type": str(row["entity_type"]),
"entity_id": str(row["entity_id"]),
"model": str(row["model"]),
"request": str(row["request"]),
"status": str(row["status"]),
"response": str(row["response"]),
"duration_ms": int(str(row["duration_ms"])),
}
_log.append(entry)
try:
num = int(entry["id"])
if num >= _next_id[0]:
_next_id[0] = num + 1
except ValueError:
pass
def log_start(plugin_id: str, entity_type: str, entity_id: str, model: str, request_summary: str) -> str:
"""Record the start of an AI request and return its log entry ID.
Must be called from the asyncio event loop thread. Persists the entry to DB.
Args:
plugin_id: Plugin that is running.
entity_type: Entity type (e.g. ``"books"``).
entity_id: Entity ID.
model: Model name used for the request.
request_summary: Short human-readable description of the request.
Returns:
Opaque string ID for the log entry, to be passed to log_finish().
"""
_next_id[0] += 1
entry_id = str(_next_id[0])
ts = time.time()
entry: AiLogEntry = {
"id": entry_id,
"ts": ts,
"plugin_id": plugin_id,
"entity_type": entity_type,
"entity_id": entity_id,
"model": model,
"request": request_summary,
"status": "running",
"response": "",
"duration_ms": 0,
}
_log.append(entry)
_notify({"type": "update", "entry": dict(entry)})
try:
with db.transaction() as c:
db.insert_ai_log_entry(c, entry_id, ts, plugin_id, entity_type, entity_id, model, request_summary)
except Exception:
pass # log persistence is best-effort
return entry_id
def log_finish(entry_id: str, status: str, response: str, started_at: float) -> None:
"""Update a log entry with the result of an AI request.
Must be called from the asyncio event loop thread. Persists the update to DB.
Args:
entry_id: ID returned by log_start().
status: ``"ok"`` or ``"error"``.
response: Short summary of the response or error message.
started_at: ``time.time()`` value recorded before the request.
"""
duration_ms = int((time.time() - started_at) * 1000)
for entry in _log:
if entry["id"] == entry_id:
entry["status"] = status
entry["response"] = response
entry["duration_ms"] = duration_ms
_notify({"type": "update", "entry": dict(entry)})
break
try:
with db.transaction() as c:
db.update_ai_log_entry(c, entry_id, status, response, duration_ms)
except Exception:
pass # log persistence is best-effort
def init_thread_logging(loop: asyncio.AbstractEventLoop) -> None:
"""Enable log_start / log_finish calls from executor threads.
Must be called once at app startup after the event loop is running.
Stores the loop and function references in log_thread for use from workers.
Args:
loop: The running asyncio event loop.
"""
log_thread.set_app_loop(loop, log_start, log_finish)
def notify_entity_update(entity_type: str, entity_id: str, data: dict[str, Any]) -> None:
"""Broadcast an entity update to all AI-log WebSocket subscribers.
Must be called from the asyncio event loop thread.
Args:
entity_type: Entity type string (e.g. ``"books"``).
entity_id: Entity ID.
data: Dict representation of the updated entity row.
"""
_notify({"type": "entity_update", "entity_type": entity_type, "entity_id": entity_id, "data": data})
def _notify(msg: dict[str, Any]) -> None:
for q in _log_subs:
q.put_nowait(msg)

View File

@@ -1,8 +1,10 @@
"""Archive search plugin runner.""" """Archive search plugin runner."""
import json import json
import time
import db import db
import log_thread
from errors import BookNotFoundError from errors import BookNotFoundError
from models import ArchiveSearcherPlugin, BookRow, CandidateRecord from models import ArchiveSearcherPlugin, BookRow, CandidateRecord
from logic.identification import build_query from logic.identification import build_query
@@ -11,6 +13,9 @@ from logic.identification import build_query
def run_archive_searcher(plugin: ArchiveSearcherPlugin, book_id: str) -> BookRow: def run_archive_searcher(plugin: ArchiveSearcherPlugin, book_id: str) -> BookRow:
"""Run an archive search for a book and merge results into the candidates list. """Run an archive search for a book and merge results into the candidates list.
Sets the log context for this thread so individual HTTP requests logged inside
the plugin are attributed to the correct plugin and entity.
Args: Args:
plugin: The archive searcher plugin to execute. plugin: The archive searcher plugin to execute.
book_id: ID of the book to search for. book_id: ID of the book to search for.
@@ -21,6 +26,7 @@ def run_archive_searcher(plugin: ArchiveSearcherPlugin, book_id: str) -> BookRow
Raises: Raises:
BookNotFoundError: If book_id does not exist. BookNotFoundError: If book_id does not exist.
""" """
log_thread.set_log_ctx(plugin.plugin_id, "books", book_id)
with db.transaction() as c: with db.transaction() as c:
book = db.get_book(c, book_id) book = db.get_book(c, book_id)
if not book: if not book:
@@ -28,7 +34,14 @@ def run_archive_searcher(plugin: ArchiveSearcherPlugin, book_id: str) -> BookRow
query = build_query(book) query = build_query(book)
if not query: if not query:
return book return book
started = time.time()
entry_id = log_thread.start_entry("", f"search: {query[:80]}")
try:
results: list[CandidateRecord] = plugin.search(query) results: list[CandidateRecord] = plugin.search(query)
log_thread.finish_entry(entry_id, "ok", f"{len(results)} result(s)", started)
except Exception as exc:
log_thread.finish_entry(entry_id, "error", str(exc), started)
raise
existing: list[CandidateRecord] = json.loads(book.candidates or "[]") existing: list[CandidateRecord] = json.loads(book.candidates or "[]")
existing = [cd for cd in existing if cd.get("source") != plugin.plugin_id] existing = [cd for cd in existing if cd.get("source") != plugin.plugin_id]
existing.extend(results) existing.extend(results)

View File

@@ -1,66 +1,168 @@
"""Batch processing pipeline: auto-queue text recognition and archive search.""" """Batch processing pipeline: auto-queue text recognition and archive search."""
import asyncio import asyncio
import dataclasses
import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Any
import db import db
import plugins as plugin_registry from logic.ai_log import log_finish, log_start, notify_entity_update
from logic.identification import run_identify_pipeline
from models import BatchState from models import BatchState
from logic.identification import run_text_recognizer
from logic.archive import run_archive_searcher
batch_state: BatchState = {"running": False, "total": 0, "done": 0, "errors": 0, "current": ""} batch_state: BatchState = {"running": False, "total": 0, "done": 0, "errors": 0, "current": ""}
batch_executor = ThreadPoolExecutor(max_workers=1) batch_executor = ThreadPoolExecutor(max_workers=1)
archive_executor = ThreadPoolExecutor(max_workers=8) archive_executor = ThreadPoolExecutor(max_workers=8)
# WebSocket subscribers: each is a queue that receives batch_state snapshots.
_batch_subs: set[asyncio.Queue[dict[str, Any]]] = set()
# Tracked asyncio task for the running batch (for cancellation on shutdown).
_batch_task: asyncio.Task[None] | None = None
def subscribe_batch() -> asyncio.Queue[dict[str, Any]]:
"""Register a new subscriber for batch state updates.
Returns:
A queue that will receive a dict snapshot after each state change.
"""
q: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
_batch_subs.add(q)
return q
def unsubscribe_batch(q: asyncio.Queue[dict[str, Any]]) -> None:
"""Remove a subscriber queue from batch state notifications.
Args:
q: Queue previously returned by subscribe_batch().
"""
_batch_subs.discard(q)
def get_batch_task() -> "asyncio.Task[None] | None":
"""Return the currently running batch asyncio task, or None.
Returns:
The running Task, or None if no batch is active.
"""
return _batch_task
def get_pending_batch() -> list[str]:
"""Return pending book IDs from the database batch queue.
Used at startup to resume an interrupted batch.
Returns:
List of book IDs in queue order, or [] if queue is empty.
"""
with db.connection() as c:
return db.get_batch_queue(c)
def add_to_queue(book_ids: list[str]) -> int:
"""Add books to the DB batch queue, skipping duplicates.
Args:
book_ids: Candidate book IDs to enqueue.
Returns:
Number of books actually added (not already in queue).
"""
with db.connection() as c:
existing = set(db.get_batch_queue(c))
new_ids = [bid for bid in book_ids if bid not in existing]
if new_ids:
with db.transaction() as c:
db.add_to_batch_queue(c, new_ids)
return len(new_ids)
def _notify_subs() -> None:
snap: dict[str, Any] = {
"running": batch_state["running"],
"total": batch_state["total"],
"done": batch_state["done"],
"errors": batch_state["errors"],
"current": batch_state["current"],
}
for q in _batch_subs:
q.put_nowait(snap)
def process_book_sync(book_id: str) -> None: def process_book_sync(book_id: str) -> None:
"""Run the full auto-queue pipeline for a single book synchronously. """Run the full identification pipeline for a single book synchronously.
Runs all auto_queue text_recognizers (if book has no raw_text yet), then all Exceptions from the pipeline propagate to the caller.
auto_queue archive_searchers. Exceptions from individual plugins are suppressed.
Args: Args:
book_id: ID of the book to process. book_id: ID of the book to process.
Raises:
Any exception raised by run_identify_pipeline.
""" """
with db.connection() as c: run_identify_pipeline(book_id)
book = db.get_book(c, book_id)
has_text = bool((book.raw_text if book else "").strip())
if not has_text:
for p in plugin_registry.get_auto_queue("text_recognizers"):
try:
run_text_recognizer(p, book_id)
except Exception:
pass
for p in plugin_registry.get_auto_queue("archive_searchers"):
try:
run_archive_searcher(p, book_id)
except Exception:
pass
async def run_batch(book_ids: list[str]) -> None: async def run_batch_consumer() -> None:
"""Process a list of books through the auto-queue pipeline sequentially. """Process books from the DB batch queue until the queue is empty.
Updates batch_state throughout execution. Exceptions from individual books Reads pending book IDs from the database queue. Each book is processed
are counted in batch_state['errors'] and do not abort the run. sequentially via process_book_sync in the batch_executor. New books may
be added to the queue while this consumer is running and will be picked up
Args: automatically. Batch state is broadcast to WebSocket subscribers after each
book_ids: List of book IDs to process. book. Individual book errors are counted but do not abort the run.
""" """
global _batch_task
_batch_task = asyncio.current_task()
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
batch_state["running"] = True batch_state["running"] = True
batch_state["total"] = len(book_ids)
batch_state["done"] = 0 batch_state["done"] = 0
batch_state["errors"] = 0 batch_state["errors"] = 0
for bid in book_ids:
with db.connection() as c:
pending = db.get_batch_queue(c)
batch_state["total"] = len(pending)
_notify_subs()
try:
while True:
with db.connection() as c:
pending = db.get_batch_queue(c)
if not pending:
break
bid = pending[0]
batch_state["current"] = bid batch_state["current"] = bid
batch_state["total"] = batch_state["done"] + len(pending)
_notify_subs()
wall_start = time.time()
entry_id = log_start("identify_pipeline", "books", bid, "pipeline", bid)
try: try:
await loop.run_in_executor(batch_executor, process_book_sync, bid) await loop.run_in_executor(batch_executor, process_book_sync, bid)
except Exception: log_finish(entry_id, "ok", "", wall_start)
# Push entity update so connected clients see the new book data.
with db.connection() as c:
book = db.get_book(c, bid)
if book is not None:
notify_entity_update("books", bid, dataclasses.asdict(book))
except asyncio.CancelledError:
log_finish(entry_id, "error", "cancelled", wall_start)
raise
except Exception as exc:
log_finish(entry_id, "error", str(exc), wall_start)
batch_state["errors"] += 1 batch_state["errors"] += 1
with db.transaction() as c:
db.remove_from_batch_queue(c, bid)
batch_state["done"] += 1 batch_state["done"] += 1
_notify_subs()
finally:
batch_state["running"] = False batch_state["running"] = False
batch_state["current"] = "" batch_state["current"] = ""
_notify_subs()
_batch_task = None

View File

@@ -64,15 +64,22 @@ def shelf_source(c: sqlite3.Connection, shelf_id: str) -> tuple[Path, tuple[floa
return IMAGES_DIR / cab.photo_filename, (0.0, y0, 1.0, y1) return IMAGES_DIR / cab.photo_filename, (0.0, y0, 1.0, y1)
def book_spine_source(c: sqlite3.Connection, book_id: str) -> tuple[Path, tuple[float, float, float, float]]: def book_spine_source(
c: sqlite3.Connection,
book_id: str,
padding_pct: float = 0.0,
) -> tuple[Path, tuple[float, float, float, float]]:
"""Return the image path and crop fractions for a book's spine image. """Return the image path and crop fractions for a book's spine image.
Composes the shelf's image source with the book's horizontal position within Composes the shelf's image source with the book's horizontal position within
the shelf's book boundaries. the shelf's book boundaries, then expands the x-extent by padding_pct of
the book width on each side to account for book inclination.
Args: Args:
c: Open database connection. c: Open database connection.
book_id: ID of the book to resolve. book_id: ID of the book to resolve.
padding_pct: Fraction of book width to add on each horizontal side
(e.g. 0.10 adds 10% on left and right). Clamped to image edges.
Returns: Returns:
(image_path, crop_frac) — always returns a crop (never None). (image_path, crop_frac) — always returns a crop (never None).
@@ -93,6 +100,11 @@ def book_spine_source(c: sqlite3.Connection, book_id: str) -> tuple[Path, tuple[
idx = db.get_book_rank(c, book_id) idx = db.get_book_rank(c, book_id)
x0, x1 = bounds_for_index(shelf.book_boundaries, idx) x0, x1 = bounds_for_index(shelf.book_boundaries, idx)
if padding_pct > 0.0:
pad = (x1 - x0) * padding_pct
x0 = max(0.0, x0 - pad)
x1 = min(1.0, x1 + pad)
if base_crop is None: if base_crop is None:
return base_path, (x0, 0.0, x1, 1.0) return base_path, (x0, 0.0, x1, 1.0)
else: else:

View File

@@ -1,17 +1,24 @@
"""Book identification logic: status computation, AI result application, plugin runners.""" """Book identification logic: status computation, AI result application, plugin runners."""
import json import json
import re
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import db import db
import log_thread
from config import get_config
from db import now from db import now
from errors import BookNotFoundError, NoRawTextError from errors import BookNotFoundError, NoPipelinePluginError, NoRawTextError
from logic.boundaries import book_spine_source from logic.boundaries import book_spine_source
from logic.images import prep_img_b64 from logic.images import prep_img_b64
from models import ( from models import (
AIIdentifyResult, AIIdentifyResult,
ArchiveSearcherPlugin,
BookIdentifierPlugin, BookIdentifierPlugin,
BookRow, BookRow,
CandidateRecord, CandidateRecord,
IdentifyBlock,
TextRecognizeResult, TextRecognizeResult,
TextRecognizerPlugin, TextRecognizerPlugin,
) )
@@ -19,6 +26,9 @@ from models import (
AI_FIELDS = ("title", "author", "year", "isbn", "publisher") AI_FIELDS = ("title", "author", "year", "isbn", "publisher")
_APPROVED_REQUIRED = ("title", "author", "year") _APPROVED_REQUIRED = ("title", "author", "year")
_ARCHIVE_PIPELINE_WORKERS = 8
_ARCHIVE_PIPELINE_TIMEOUT = 60.0
def compute_status(book: BookRow) -> str: def compute_status(book: BookRow) -> str:
"""Return the identification_status string derived from current book field values. """Return the identification_status string derived from current book field values.
@@ -173,7 +183,8 @@ def run_text_recognizer(plugin: TextRecognizerPlugin, book_id: str) -> BookRow:
book = db.get_book(c, book_id) book = db.get_book(c, book_id)
if not book: if not book:
raise BookNotFoundError(book_id) raise BookNotFoundError(book_id)
spine_path, spine_crop = book_spine_source(c, book_id) padding = get_config().ui.spine_padding_pct
spine_path, spine_crop = book_spine_source(c, book_id, padding)
b64, mt = prep_img_b64(spine_path, spine_crop, max_px=plugin.max_image_px) b64, mt = prep_img_b64(spine_path, spine_crop, max_px=plugin.max_image_px)
result: TextRecognizeResult = plugin.recognize(b64, mt) result: TextRecognizeResult = plugin.recognize(b64, mt)
raw_text = result.get("raw_text") or "" raw_text = result.get("raw_text") or ""
@@ -198,9 +209,10 @@ def run_text_recognizer(plugin: TextRecognizerPlugin, book_id: str) -> BookRow:
def run_book_identifier(plugin: BookIdentifierPlugin, book_id: str) -> BookRow: def run_book_identifier(plugin: BookIdentifierPlugin, book_id: str) -> BookRow:
"""Identify a book using AI and update ai_* fields and candidates. """Identify a book using the AI identifier plugin and update ai_blocks and ai_* fields.
Requires raw_text to have been populated by a text recognizer first. Standalone mode: passes empty archive results and no images.
For the full multi-step pipeline use run_identify_pipeline instead.
Args: Args:
plugin: The book identifier plugin to execute. plugin: The book identifier plugin to execute.
@@ -220,26 +232,242 @@ def run_book_identifier(plugin: BookIdentifierPlugin, book_id: str) -> BookRow:
raw_text = (book.raw_text or "").strip() raw_text = (book.raw_text or "").strip()
if not raw_text: if not raw_text:
raise NoRawTextError(book_id) raise NoRawTextError(book_id)
result: AIIdentifyResult = plugin.identify(raw_text) blocks: list[IdentifyBlock] = plugin.identify(raw_text, [], [])
# apply_ai_result manages its own transaction db.set_book_ai_blocks(c, book_id, json.dumps(blocks, ensure_ascii=False))
apply_ai_result(book_id, result, plugin.confidence_threshold) top_score = float(blocks[0].get("score") or 0.0) if blocks else 0.0
with db.transaction() as c: if blocks and top_score >= plugin.confidence_threshold:
top = blocks[0]
db.set_book_ai_fields(
c,
book_id,
top.get("title") or "",
top.get("author") or "",
top.get("year") or "",
top.get("isbn") or "",
top.get("publisher") or "",
)
db.set_book_confidence(c, book_id, top_score, now())
book = db.get_book(c, book_id) book = db.get_book(c, book_id)
if not book: if not book:
raise BookNotFoundError(book_id) raise BookNotFoundError(book_id)
cand: CandidateRecord = { db.set_book_status(c, book_id, compute_status(book))
"source": plugin.plugin_id,
"title": (result.get("title") or "").strip(),
"author": (result.get("author") or "").strip(),
"year": (result.get("year") or "").strip(),
"isbn": (result.get("isbn") or "").strip(),
"publisher": (result.get("publisher") or "").strip(),
}
existing: list[CandidateRecord] = json.loads(book.candidates or "[]")
existing = [cd for cd in existing if cd.get("source") != plugin.plugin_id]
existing.append(cand)
db.set_book_candidates(c, book_id, json.dumps(existing))
updated = db.get_book(c, book_id) updated = db.get_book(c, book_id)
if not updated: if not updated:
raise BookNotFoundError(book_id) raise BookNotFoundError(book_id)
return updated return updated
# ── Identification pipeline ───────────────────────────────────────────────────
def _normalize_field(value: str) -> str:
"""Lowercase, strip punctuation, and collapse spaces for candidate deduplication.
Args:
value: Raw field string.
Returns:
Normalized string.
"""
v = value.lower()
v = re.sub(r"[^\w\s]", "", v)
return " ".join(v.split())
def _candidate_key(c: CandidateRecord) -> tuple[str, str, str, str, str]:
return (
_normalize_field(c.get("title") or ""),
_normalize_field(c.get("author") or ""),
_normalize_field(c.get("year") or ""),
_normalize_field(c.get("isbn") or ""),
_normalize_field(c.get("publisher") or ""),
)
def _deduplicate_candidates(candidates: list[CandidateRecord]) -> list[CandidateRecord]:
"""Merge candidates that are identical after normalization, unioning their sources.
Two candidates match if title, author, year, isbn, and publisher all match
case-insensitively with punctuation removed and spaces normalized. Candidates
differing in any field (e.g. same title+author but different year) are kept separate.
Args:
candidates: Raw candidate list from multiple archive sources.
Returns:
Deduplicated list; first occurrence order preserved; sources merged with ', '.
"""
seen: dict[tuple[str, str, str, str, str], CandidateRecord] = {}
for cand in candidates:
key = _candidate_key(cand)
if key in seen:
existing_src = seen[key].get("source") or ""
new_src = cand.get("source") or ""
if new_src and new_src not in existing_src:
seen[key]["source"] = f"{existing_src}, {new_src}" if existing_src else new_src
else:
seen[key] = {
"source": cand.get("source") or "",
"title": cand.get("title") or "",
"author": cand.get("author") or "",
"year": cand.get("year") or "",
"isbn": cand.get("isbn") or "",
"publisher": cand.get("publisher") or "",
}
return list(seen.values())
def _get_book_images(book_id: str, max_image_px: int) -> list[tuple[str, str]]:
"""Collect spine and title-page images for a book, encoded as base64.
Silently skips images that cannot be loaded.
Args:
book_id: ID of the book.
max_image_px: Maximum pixel dimension for downscaling.
Returns:
List of (base64_string, mime_type) tuples; may be empty.
"""
images: list[tuple[str, str]] = []
padding = get_config().ui.spine_padding_pct
with db.connection() as c:
try:
spine_path, spine_crop = book_spine_source(c, book_id, padding)
b64, mt = prep_img_b64(spine_path, spine_crop, max_px=max_image_px)
images.append((b64, mt))
except Exception:
pass
book = db.get_book(c, book_id)
if book and book.image_filename:
from files import IMAGES_DIR
try:
b64_tp, mt_tp = prep_img_b64(IMAGES_DIR / book.image_filename, max_px=max_image_px)
images.append((b64_tp, mt_tp))
except Exception:
pass
return images
def _search_with_log(searcher: ArchiveSearcherPlugin, query: str, book_id: str) -> list[CandidateRecord]:
"""Run one archive search call with thread-safe logging."""
log_thread.set_log_ctx(searcher.plugin_id, "books", book_id)
started = time.time()
entry_id = log_thread.start_entry("", f"search: {query[:80]}")
try:
results = searcher.search(query)
log_thread.finish_entry(entry_id, "ok", f"{len(results)} result(s)", started)
return results
except Exception as exc:
log_thread.finish_entry(entry_id, "error", str(exc), started)
raise
def run_identify_pipeline(book_id: str) -> BookRow:
"""Run the full identification pipeline: VLM recognition -> archives -> main model.
Steps:
1. VLM text recognizer reads the spine image -> raw_text and structured fields.
2. All archive searchers run in parallel using title+author and title-only queries.
3. Archive results are deduplicated by normalized full-field match.
4. The main identifier model receives raw_text, deduplicated archive results, and
(if is_vlm is True) the spine and title-page images.
5. The model returns ranked IdentifyBlock list stored in books.ai_blocks (never cleared).
6. The top block (if score >= confidence_threshold) updates books.ai_* fields.
Args:
book_id: ID of the book to identify.
Returns:
Updated BookRow after completing the pipeline.
Raises:
BookNotFoundError: If book_id does not exist.
NoPipelinePluginError: If no text_recognizer or book_identifier is configured.
"""
import plugins as plugin_registry
with db.connection() as c:
if not db.get_book(c, book_id):
raise BookNotFoundError(book_id)
recognizers = plugin_registry.get_all_text_recognizers()
if not recognizers:
raise NoPipelinePluginError("text_recognizer")
recognizer = recognizers[0]
identifiers = plugin_registry.get_all_book_identifiers()
if not identifiers:
raise NoPipelinePluginError("book_identifier")
identifier = identifiers[0]
# Step 1: VLM recognition — set log context so AIClient.call() attributes the LLM call
log_thread.set_log_ctx(recognizer.plugin_id, "books", book_id)
book = run_text_recognizer(recognizer, book_id)
raw_text = (book.raw_text or "").strip()
candidates: list[CandidateRecord] = json.loads(book.candidates or "[]")
vlm_cand = next((c for c in candidates if c.get("source") == recognizer.plugin_id), None)
title = (vlm_cand.get("title") or "").strip() if vlm_cand else ""
author = (vlm_cand.get("author") or "").strip() if vlm_cand else ""
queries: list[str] = []
if title and author:
queries.append(f"{author} {title}")
if title:
queries.append(title)
if not queries and raw_text:
queries.append(raw_text[:200])
# Step 2: Parallel archive search — each call sets its own log context via _search_with_log
searchers = plugin_registry.get_all_archive_searchers()
all_archive: list[CandidateRecord] = []
if searchers and queries:
unique_queries = list(dict.fromkeys(queries))
with ThreadPoolExecutor(max_workers=_ARCHIVE_PIPELINE_WORKERS) as pool:
futs = {
pool.submit(_search_with_log, s, q, book_id): s.plugin_id for s in searchers for q in unique_queries
}
for fut in as_completed(futs, timeout=_ARCHIVE_PIPELINE_TIMEOUT):
try:
all_archive.extend(fut.result())
except Exception:
pass
# Step 3: Deduplicate
deduped = _deduplicate_candidates(all_archive)
# Step 4: Collect images if identifier is a VLM
images: list[tuple[str, str]] = []
if identifier.is_vlm:
images = _get_book_images(book_id, identifier.max_image_px)
# Step 5: Call main identifier — set log context so AIClient.call() logs the LLM call
log_thread.set_log_ctx(identifier.plugin_id, "books", book_id)
blocks: list[IdentifyBlock] = identifier.identify(raw_text, deduped, images)
# Step 6: Persist results (ai_blocks are never removed; overwritten each pipeline run)
with db.transaction() as c:
db.set_book_ai_blocks(c, book_id, json.dumps(blocks, ensure_ascii=False))
top_score = float(blocks[0].get("score") or 0.0) if blocks else 0.0
if blocks and top_score >= identifier.confidence_threshold:
top = blocks[0]
db.set_book_ai_fields(
c,
book_id,
top.get("title") or "",
top.get("author") or "",
top.get("year") or "",
top.get("isbn") or "",
top.get("publisher") or "",
)
db.set_book_confidence(c, book_id, top_score, now())
updated_book = db.get_book(c, book_id)
if not updated_book:
raise BookNotFoundError(book_id)
db.set_book_status(c, book_id, compute_status(updated_book))
final = db.get_book(c, book_id)
if not final:
raise BookNotFoundError(book_id)
return final

72
src/migrate.py Normal file
View File

@@ -0,0 +1,72 @@
"""Database migration functions.
Each migration is idempotent and safe to run on a database that has already been migrated.
Run via run_migration() called from app startup after init_db().
"""
import sqlite3
from db import DB_PATH
def run_migration() -> None:
"""Apply all pending schema migrations in order.
Currently applies:
- v1: Add ai_blocks column to books; clear AI-derived data while preserving user data.
- v2: Add batch_queue table for persistent batch processing queue.
Migrations are idempotent — running them on an already-migrated database is a no-op.
"""
c = sqlite3.connect(DB_PATH)
c.row_factory = sqlite3.Row
c.execute("PRAGMA foreign_keys = ON")
try:
_migrate_v1(c)
_migrate_v2(c)
c.commit()
except Exception:
c.rollback()
raise
finally:
c.close()
def _migrate_v1(c: sqlite3.Connection) -> None:
"""Add ai_blocks column and clear stale AI data from all books (first run only).
- Adds ai_blocks TEXT DEFAULT NULL column if it does not exist.
- On first run only (when the column is absent): clears raw_text, ai_*, title_confidence,
analyzed_at, candidates, ai_blocks from all books (these are regenerated by the new pipeline).
- For user_approved books: copies user fields back to ai_* so that
compute_status() still returns 'user_approved' after the ai_* clear.
This migration assumes the database already has the base books schema.
It is a no-op if ai_blocks already exists.
"""
cols = {row["name"] for row in c.execute("PRAGMA table_info(books)")}
if "ai_blocks" not in cols:
c.execute("ALTER TABLE books ADD COLUMN ai_blocks TEXT DEFAULT NULL")
# Clear AI-derived fields only when first adding the column.
c.execute(
"UPDATE books SET "
"raw_text='', ai_title='', ai_author='', ai_year='', ai_isbn='', ai_publisher='', "
"title_confidence=0, analyzed_at=NULL, candidates=NULL, ai_blocks=NULL"
)
# For user_approved books, restore ai_* = user fields so status stays user_approved.
c.execute(
"UPDATE books SET "
"ai_title=title, ai_author=author, ai_year=year, ai_isbn=isbn, ai_publisher=publisher "
"WHERE identification_status='user_approved'"
)
def _migrate_v2(c: sqlite3.Connection) -> None:
"""Add batch_queue table for persistent batch processing queue.
Replaces data/batch_pending.json with a DB table so batch state survives
across restarts alongside all other persistent data.
"""
c.execute("CREATE TABLE IF NOT EXISTS batch_queue (" "book_id TEXT PRIMARY KEY," "added_at REAL NOT NULL" ")")

View File

@@ -29,6 +29,16 @@ class AIIdentifyResult(TypedDict, total=False):
confidence: float confidence: float
class IdentifyBlock(TypedDict, total=False):
title: str
author: str
year: str
isbn: str
publisher: str
score: float
sources: list[str]
# ── Candidate + AI config ───────────────────────────────────────────────────── # ── Candidate + AI config ─────────────────────────────────────────────────────
@@ -48,6 +58,7 @@ class AIConfig(TypedDict):
max_image_px: int max_image_px: int
confidence_threshold: float confidence_threshold: float
extra_body: dict[str, Any] extra_body: dict[str, Any]
is_vlm: bool
# ── Application state ───────────────────────────────────────────────────────── # ── Application state ─────────────────────────────────────────────────────────
@@ -61,6 +72,19 @@ class BatchState(TypedDict):
current: str current: str
class AiLogEntry(TypedDict):
id: str
ts: float
plugin_id: str
entity_type: str
entity_id: str
model: str
request: str
status: str # "running" | "ok" | "error"
response: str
duration_ms: int
# ── Plugin manifest ─────────────────────────────────────────────────────────── # ── Plugin manifest ───────────────────────────────────────────────────────────
@@ -84,6 +108,9 @@ class BoundaryDetectorPlugin(Protocol):
auto_queue: bool auto_queue: bool
target: str target: str
@property
def model(self) -> str: ...
@property @property
def max_image_px(self) -> int: ... def max_image_px(self) -> int: ...
@@ -95,6 +122,9 @@ class TextRecognizerPlugin(Protocol):
name: str name: str
auto_queue: bool auto_queue: bool
@property
def model(self) -> str: ...
@property @property
def max_image_px(self) -> int: ... def max_image_px(self) -> int: ...
@@ -106,10 +136,24 @@ class BookIdentifierPlugin(Protocol):
name: str name: str
auto_queue: bool auto_queue: bool
@property
def model(self) -> str: ...
@property
def max_image_px(self) -> int: ...
@property @property
def confidence_threshold(self) -> float: ... def confidence_threshold(self) -> float: ...
def identify(self, raw_text: str) -> AIIdentifyResult: ... @property
def is_vlm(self) -> bool: ...
def identify(
self,
raw_text: str,
archive_results: list["CandidateRecord"],
images: list[tuple[str, str]],
) -> list["IdentifyBlock"]: ...
class ArchiveSearcherPlugin(Protocol): class ArchiveSearcherPlugin(Protocol):
@@ -197,6 +241,7 @@ class BookRow:
analyzed_at: str | None analyzed_at: str | None
created_at: str created_at: str
candidates: str | None candidates: str | None
ai_blocks: str | None
# ── API request payload dataclasses ────────────────────────────────────────── # ── API request payload dataclasses ──────────────────────────────────────────

View File

@@ -41,16 +41,20 @@ _type_to_class: dict[str, Any] = {} # populated lazily on first call
def _archive_classes() -> dict[str, Any]: def _archive_classes() -> dict[str, Any]:
if not _type_to_class: if not _type_to_class:
from .archives.html_scraper import HtmlScraperPlugin from .archives.alib import AlibPlugin
from .archives.openlibrary import OpenLibraryPlugin from .archives.openlibrary import OpenLibraryPlugin
from .archives.rsl import RSLPlugin from .archives.rsl import RSLPlugin
from .archives.rusneb import RusnebPlugin
from .archives.shpl import ShplPlugin
from .archives.sru_catalog import SRUCatalogPlugin from .archives.sru_catalog import SRUCatalogPlugin
_type_to_class.update( _type_to_class.update(
{ {
"openlibrary": OpenLibraryPlugin, "openlibrary": OpenLibraryPlugin,
"rsl": RSLPlugin, "rsl": RSLPlugin,
"html_scraper": HtmlScraperPlugin, "rusneb": RusnebPlugin,
"alib_web": AlibPlugin,
"shpl": ShplPlugin,
"sru_catalog": SRUCatalogPlugin, "sru_catalog": SRUCatalogPlugin,
} }
) )
@@ -66,6 +70,7 @@ def _build_ai_cfg(model_cfg: ModelConfig, cred_cfg: CredentialConfig, func: AIFu
max_image_px=func.max_image_px, max_image_px=func.max_image_px,
confidence_threshold=func.confidence_threshold, confidence_threshold=func.confidence_threshold,
extra_body=model_cfg.extra_body, extra_body=model_cfg.extra_body,
is_vlm=func.is_vlm,
) )
@@ -223,6 +228,21 @@ def get_auto_queue(
return [] return []
def get_all_text_recognizers() -> list[TextRecognizerPlugin]:
"""Return all registered text recognizer plugins."""
return list(_text_recognizers.values())
def get_all_book_identifiers() -> list[BookIdentifierPlugin]:
"""Return all registered book identifier plugins."""
return list(_book_identifiers.values())
def get_all_archive_searchers() -> list[ArchiveSearcherPlugin]:
"""Return all registered archive searcher plugins."""
return list(_archive_searchers.values())
def get_plugin(plugin_id: str) -> PluginLookupResult: def get_plugin(plugin_id: str) -> PluginLookupResult:
"""Find a plugin by ID across all categories. Returns a discriminated (category, plugin) tuple.""" """Find a plugin by ID across all categories. Returns a discriminated (category, plugin) tuple."""
if plugin_id in _boundary_detectors: if plugin_id in _boundary_detectors:

View File

@@ -2,12 +2,14 @@
Caches openai.OpenAI instances per (base_url, api_key) to avoid re-creating on each call. Caches openai.OpenAI instances per (base_url, api_key) to avoid re-creating on each call.
AIClient wraps the raw API call: fills prompt template, encodes images, parses JSON response. AIClient wraps the raw API call: fills prompt template, encodes images, parses JSON response.
Individual LLM API calls are logged via log_thread if a log context is set.
""" """
import json import json
import re import re
import time
from string import Template from string import Template
from typing import Any, cast from typing import Any, Literal, cast, overload
import openai import openai
from openai.types.chat import ChatCompletionMessageParam from openai.types.chat import ChatCompletionMessageParam
@@ -17,6 +19,7 @@ from openai.types.chat.chat_completion_content_part_image_param import (
) )
from openai.types.chat.chat_completion_content_part_text_param import ChatCompletionContentPartTextParam from openai.types.chat.chat_completion_content_part_text_param import ChatCompletionContentPartTextParam
import log_thread
from models import AIConfig from models import AIConfig
# Module-level cache of openai.OpenAI instances keyed by (base_url, api_key) # Module-level cache of openai.OpenAI instances keyed by (base_url, api_key)
@@ -48,6 +51,24 @@ def _parse_json(text: str) -> dict[str, Any]:
return cast(dict[str, Any], result) return cast(dict[str, Any], result)
def _parse_json_list(text: str) -> list[Any]:
"""Extract and parse the first JSON array found in text.
Raises ValueError if no JSON array is found or the JSON is malformed.
"""
text = text.strip()
m = re.search(r"\[.*\]", text, re.DOTALL)
if not m:
raise ValueError(f"No JSON array found in AI response: {text[:200]!r}")
try:
result = json.loads(m.group())
except json.JSONDecodeError as exc:
raise ValueError(f"Failed to parse AI response as JSON: {exc}") from exc
if not isinstance(result, list):
raise ValueError(f"Expected JSON array, got {type(result).__name__}")
return cast(list[Any], result)
ContentPart = ChatCompletionContentPartImageParam | ChatCompletionContentPartTextParam ContentPart = ChatCompletionContentPartImageParam | ChatCompletionContentPartTextParam
@@ -62,16 +83,41 @@ class AIClient:
self.cfg = cfg self.cfg = cfg
self.output_format = output_format self.output_format = output_format
@overload
def call( def call(
self, self,
prompt_template: str, prompt_template: str,
images: list[tuple[str, str]], images: list[tuple[str, str]],
text_vars: dict[str, str] | None = None, text_vars: dict[str, str] | None = None,
) -> dict[str, Any]: output_is_list: Literal[False] = False,
) -> dict[str, Any]: ...
@overload
def call(
self,
prompt_template: str,
images: list[tuple[str, str]],
text_vars: dict[str, str] | None,
output_is_list: Literal[True],
) -> list[Any]: ...
def call(
self,
prompt_template: str,
images: list[tuple[str, str]],
text_vars: dict[str, str] | None = None,
output_is_list: bool = False,
) -> dict[str, Any] | list[Any]:
"""Substitute template vars, call API with optional images, return parsed JSON. """Substitute template vars, call API with optional images, return parsed JSON.
images: list of (base64_str, mime_type) tuples. Args:
text_vars: extra ${KEY} substitutions beyond ${OUTPUT_FORMAT}. prompt_template: Prompt string with ${KEY} placeholders.
images: List of (base64_str, mime_type) tuples.
text_vars: Extra ${KEY} substitutions beyond ${OUTPUT_FORMAT}.
output_is_list: If True, parse the response as a JSON array instead of object.
Returns:
Parsed JSON — dict if output_is_list is False, list otherwise.
""" """
vars_: dict[str, str] = {"OUTPUT_FORMAT": self.output_format} vars_: dict[str, str] = {"OUTPUT_FORMAT": self.output_format}
if text_vars: if text_vars:
@@ -87,8 +133,17 @@ class AIClient:
] ]
parts.append(ChatCompletionContentPartTextParam(type="text", text=prompt)) parts.append(ChatCompletionContentPartTextParam(type="text", text=prompt))
messages: list[ChatCompletionMessageParam] = [{"role": "user", "content": parts}] messages: list[ChatCompletionMessageParam] = [{"role": "user", "content": parts}]
started = time.time()
entry_id = log_thread.start_entry(self.cfg["model"], prompt[:120])
try:
r = client.chat.completions.create( r = client.chat.completions.create(
model=self.cfg["model"], max_tokens=2048, messages=messages, extra_body=self.cfg["extra_body"] model=self.cfg["model"], max_tokens=4096, messages=messages, extra_body=self.cfg["extra_body"]
) )
raw = r.choices[0].message.content or "" raw = r.choices[0].message.content or ""
log_thread.finish_entry(entry_id, "ok", raw[:120], started)
except Exception as exc:
log_thread.finish_entry(entry_id, "error", str(exc), started)
raise
if output_is_list:
return _parse_json_list(raw)
return _parse_json(raw) return _parse_json(raw)

View File

@@ -1,23 +1,38 @@
"""Book identifier plugin — raw spine text → bibliographic metadata. """Book identifier plugin — VLM result + archive candidates → ranked identification blocks.
Input: raw_text string (from text_recognizer). Input: raw_text string (from text_recognizer), archive_results (deduplicated candidates),
Output: {"title": "...", "author": "...", "year": "...", "isbn": "...", images (list of (b64, mime) pairs if is_vlm).
"publisher": "...", "confidence": 0.95} Output: list of IdentifyBlock dicts ranked by descending confidence score.
confidence — float 0-1; results below confidence_threshold are discarded by logic.py. Result stored as books.ai_blocks JSON.
Result added to books.candidates and books.ai_* fields.
""" """
from models import AIConfig, AIIdentifyResult import json
from typing import Any, TypeGuard
from models import AIConfig, CandidateRecord, IdentifyBlock
from ._client import AIClient from ._client import AIClient
def _is_str_dict(v: object) -> TypeGuard[dict[str, Any]]:
return isinstance(v, dict)
def _is_any_list(v: object) -> TypeGuard[list[Any]]:
return isinstance(v, list)
class BookIdentifierPlugin: class BookIdentifierPlugin:
"""Identifies a book from spine text using a VLM with web-search capability.""" """Identifies a book by combining VLM spine text with archive search results."""
category = "book_identifiers" category = "book_identifiers"
OUTPUT_FORMAT = ( OUTPUT_FORMAT = (
'{"title": "...", "author": "...", "year": "...", ' '"isbn": "...", "publisher": "...", "confidence": 0.95}' '[{"title": "The Master and Margarita", "author": "Mikhail Bulgakov", '
'"year": "1967", "isbn": "", "publisher": "YMCA Press", '
'"score": 0.95, "sources": ["rusneb", "openlibrary"]}, '
'{"title": "Master i Margarita", "author": "M. Bulgakov", '
'"year": "2005", "isbn": "978-5-17-123456-7", "publisher": "AST", '
'"score": 0.72, "sources": ["web"]}]'
) )
def __init__( def __init__(
@@ -36,21 +51,67 @@ class BookIdentifierPlugin:
self._client = AIClient(ai_config, self.OUTPUT_FORMAT) self._client = AIClient(ai_config, self.OUTPUT_FORMAT)
self._prompt_text = prompt_text self._prompt_text = prompt_text
def identify(self, raw_text: str) -> AIIdentifyResult: def identify(
"""Returns AIIdentifyResult with title/author/year/isbn/publisher/confidence.""" self,
raw = self._client.call(self._prompt_text, [], text_vars={"RAW_TEXT": raw_text}) raw_text: str,
result = AIIdentifyResult( archive_results: list[CandidateRecord],
title=str(raw.get("title") or ""), images: list[tuple[str, str]],
author=str(raw.get("author") or ""), ) -> list[IdentifyBlock]:
year=str(raw.get("year") or ""), """Call the AI model to produce ranked identification blocks.
isbn=str(raw.get("isbn") or ""),
publisher=str(raw.get("publisher") or ""), Args:
raw_text: Verbatim text read from the book spine.
archive_results: Deduplicated candidates from archive searchers.
images: (base64, mime_type) pairs; non-empty only when is_vlm is True.
Returns:
List of IdentifyBlock dicts ranked by descending score.
"""
archive_json = json.dumps(archive_results, ensure_ascii=False)
raw = self._client.call(
self._prompt_text,
images,
text_vars={"RAW_TEXT": raw_text, "ARCHIVE_RESULTS": archive_json},
output_is_list=True,
) )
conf = raw.get("confidence") blocks: list[IdentifyBlock] = []
if conf is not None: for item in raw:
result["confidence"] = float(conf) if not _is_str_dict(item):
return result continue
sources: list[str] = []
sources_val = item.get("sources")
if _is_any_list(sources_val):
for sv in sources_val:
if isinstance(sv, str):
sources.append(sv)
block = IdentifyBlock(
title=str(item.get("title") or "").strip(),
author=str(item.get("author") or "").strip(),
year=str(item.get("year") or "").strip(),
isbn=str(item.get("isbn") or "").strip(),
publisher=str(item.get("publisher") or "").strip(),
score=float(item.get("score") or 0.0),
sources=sources,
)
blocks.append(block)
return sorted(blocks, key=lambda b: b.get("score", 0.0), reverse=True)
@property
def model(self) -> str:
"""AI model name used for identification."""
return self._client.cfg["model"]
@property
def max_image_px(self) -> int:
"""Maximum pixel dimension for images passed to the AI model."""
return self._client.cfg["max_image_px"]
@property @property
def confidence_threshold(self) -> float: def confidence_threshold(self) -> float:
"""Minimum score threshold for the top block to set ai_* fields."""
return self._client.cfg["confidence_threshold"] return self._client.cfg["confidence_threshold"]
@property
def is_vlm(self) -> bool:
"""True if images should be included in the request."""
return self._client.cfg["is_vlm"]

View File

@@ -41,6 +41,10 @@ class BoundaryDetectorBooksPlugin:
boundaries: list[float] = [float(b) for b in raw_bounds if isinstance(b, (int, float))] boundaries: list[float] = [float(b) for b in raw_bounds if isinstance(b, (int, float))]
return BoundaryDetectResult(boundaries=boundaries) return BoundaryDetectResult(boundaries=boundaries)
@property
def model(self) -> str:
return self._client.cfg["model"]
@property @property
def max_image_px(self) -> int: def max_image_px(self) -> int:
return self._client.cfg["max_image_px"] return self._client.cfg["max_image_px"]

View File

@@ -46,6 +46,10 @@ class BoundaryDetectorShelvesPlugin:
result["confidence"] = float(conf) result["confidence"] = float(conf)
return result return result
@property
def model(self) -> str:
return self._client.cfg["model"]
@property @property
def max_image_px(self) -> int: def max_image_px(self) -> int:
return self._client.cfg["max_image_px"] return self._client.cfg["max_image_px"]

View File

@@ -51,6 +51,10 @@ class TextRecognizerPlugin:
other=str(raw.get("other") or ""), other=str(raw.get("other") or ""),
) )
@property
def model(self) -> str:
return self._client.cfg["model"]
@property @property
def max_image_px(self) -> int: def max_image_px(self) -> int:
return self._client.cfg["max_image_px"] return self._client.cfg["max_image_px"]

View File

@@ -0,0 +1,70 @@
"""Alib (alib.ru) archive search plugin."""
import re
from urllib.parse import quote
import httpx
from models import CandidateRecord
from .html_scraper import AUTHOR_PREFIX_PAT, YEAR_RE, HtmlScraperPlugin
_URL = "https://www.alib.ru/find3.php4"
_DOMAIN = "www.alib.ru"
_ENCODING = "cp1251"
_EXTRA_PARAMS: dict[str, str] = {"f": "5", "s": "0"}
# Book entries appear as <p><b>Author Title Year Publisher…</b>
_ENTRY_RE = re.compile(r"<p><b>([^<]{5,200})</b>")
class AlibPlugin(HtmlScraperPlugin):
"""Archive searcher for alib.ru.
Fetches search results with Windows-1251 encoding and extracts book records
from ``<p><b>Author Title Year...</b>`` entries. Author surname and initials
are split from the remaining text using a Cyrillic/Latin initial pattern.
Year is extracted from within each entry rather than from the page globally.
"""
def search(self, query: str) -> list[CandidateRecord]:
"""Search Alib for books matching query.
Args:
query: Free-text search string.
Returns:
Up to three CandidateRecord dicts with source, title, author, year,
isbn, and publisher fields.
"""
self._rl.wait_and_record(_DOMAIN, self.rate_limit_seconds)
q_enc = quote(query.encode(_ENCODING, "replace"))
ep: dict[str, str] = dict(_EXTRA_PARAMS)
ep_parts = [f"{k}={quote(str(v).encode(_ENCODING, 'replace'))}" for k, v in ep.items()]
raw_qs = "&".join([f"tfind={q_enc}"] + ep_parts)
r = httpx.get(f"{_URL}?{raw_qs}", timeout=self.timeout, headers={"User-Agent": "Mozilla/5.0"})
html = r.content.decode(_ENCODING, errors="replace")
out: list[CandidateRecord] = []
for entry in _ENTRY_RE.findall(html)[:3]:
text = entry.strip()
year_m = YEAR_RE.search(text)
year = year_m.group(0) if year_m else ""
m = AUTHOR_PREFIX_PAT.match(text)
if m:
author = m.group(1).strip()
title = m.group(2).strip()
else:
author = ""
title = text
out.append(
CandidateRecord(
source=self.plugin_id,
title=title,
author=author,
year=year,
isbn="",
publisher="",
)
)
return out

View File

@@ -1,32 +1,72 @@
"""Config-driven HTML scraper for archive sites (rusneb, alib, shpl, etc.).""" """Base class and shared HTML parsing utilities for archive scraper plugins."""
import re import re
from typing import Any from typing import Any
from urllib.parse import urlparse
import httpx
from models import CandidateRecord from models import CandidateRecord
from ..rate_limiter import RateLimiter from ..rate_limiter import RateLimiter
_YEAR_RE = re.compile(r"\b(1[0-9]{3}|20[012][0-9])\b") YEAR_RE = re.compile(r"\b(1[0-9]{3}|20[012][0-9])\b")
AUTHOR_PREFIX_PAT = re.compile(r"^(\S+\s+(?:[А-ЯЁA-Z]\.){1,3}\s*)(.+)", re.DOTALL)
def _cls_re(cls_frag: str, min_len: int = 3, max_len: int = 120) -> re.Pattern[str]: def cls_inner_texts(html: str, cls_frag: str, min_len: int = 3, max_len: int = 80) -> list[str]:
return re.compile(rf'class="[^"]*{re.escape(cls_frag)}[^"]*"[^>]*>([^<]{{{min_len},{max_len}}})<') """Extract text content from elements whose class contains cls_frag.
Strips inner HTML tags and normalises whitespace, so elements like
``<span class=''><b>Name</b> I.N.</span>`` work correctly.
Args:
html: Raw HTML string to search.
cls_frag: Substring that must appear in the class attribute value.
min_len: Minimum length of extracted text to keep.
max_len: Maximum length of extracted text to keep.
Returns:
Up to three non-empty text strings in document order.
"""
raw = re.findall(rf'class=["\'][^"\']*{re.escape(cls_frag)}[^"\']*["\'][^>]*>(.*?)</', html, re.DOTALL)
out: list[str] = []
for m in raw:
text = re.sub(r"<[^>]+>", "", m)
text = re.sub(r"\s+", " ", text).strip()
if min_len <= len(text) <= max_len:
out.append(text)
if len(out) == 3:
break
return out
def img_alts(html: str, min_len: int = 5, max_len: int = 120) -> list[str]:
"""Extract non-empty alt attributes from <img> tags, normalising whitespace.
Args:
html: Raw HTML string to search.
min_len: Minimum character length to include.
max_len: Maximum character length to include.
Returns:
Up to three non-empty, whitespace-normalised alt strings.
"""
alts = re.findall(r'<img[^>]+alt=[\'"]([^\'"]+)[\'"]', html)
out: list[str] = []
for a in alts:
text = re.sub(r"\s+", " ", a).strip()
if min_len <= len(text) <= max_len:
out.append(text)
if len(out) == 3:
break
return out
class HtmlScraperPlugin: class HtmlScraperPlugin:
""" """Base class for HTML-scraping archive plugins.
Config-driven HTML scraper. Supported config keys:
url — search URL Handles common initialisation; subclasses implement search() with
search_param — query param name site-specific hardcoded logic. The config dict is accepted for
extra_params — dict of fixed extra query parameters registry compatibility but is not used by the base class; all scraping
title_class — CSS class fragment for title elements (class-based strategy) details are hardcoded in the subclass.
author_class — CSS class fragment for author elements
link_href_pattern — href regex to find title <a> links (link strategy, e.g. alib)
brief_class — CSS class for brief record rows (brief strategy, e.g. shpl)
""" """
category = "archive_searchers" category = "archive_searchers"
@@ -47,75 +87,15 @@ class HtmlScraperPlugin:
self.rate_limit_seconds = rate_limit_seconds self.rate_limit_seconds = rate_limit_seconds
self.auto_queue = auto_queue self.auto_queue = auto_queue
self.timeout = timeout self.timeout = timeout
self.config = config
self._domain: str = urlparse(str(config.get("url") or "")).netloc or plugin_id
def search(self, query: str) -> list[CandidateRecord]: def search(self, query: str) -> list[CandidateRecord]:
cfg = self.config """Search for books matching query.
self._rl.wait_and_record(self._domain, self.rate_limit_seconds)
params: dict[str, Any] = dict(cfg.get("extra_params") or {})
params[cfg["search_param"]] = query
r = httpx.get(
cfg["url"],
params=params,
timeout=self.timeout,
headers={"User-Agent": "Mozilla/5.0"},
)
html = r.text
years = _YEAR_RE.findall(html)
# Strategy: link_href_pattern (alib-style) Args:
if "link_href_pattern" in cfg: query: Free-text search string.
return self._parse_link(html, years, cfg)
# Strategy: brief_class (shpl-style) Returns:
if "brief_class" in cfg: Up to three CandidateRecord dicts with source, title, author, year,
return self._parse_brief(html, years, cfg) isbn, and publisher fields.
"""
# Strategy: title_class + author_class (rusneb-style) raise NotImplementedError
return self._parse_class(html, years, cfg)
def _parse_class(self, html: str, years: list[str], cfg: dict[str, Any]) -> list[CandidateRecord]:
titles = _cls_re(cfg.get("title_class", "title")).findall(html)[:3]
authors = _cls_re(cfg.get("author_class", "author"), 3, 80).findall(html)[:3]
return [
CandidateRecord(
source=self.plugin_id,
title=title.strip(),
author=authors[i].strip() if i < len(authors) else "",
year=years[i] if i < len(years) else "",
isbn="",
publisher="",
)
for i, title in enumerate(titles)
]
def _parse_link(self, html: str, years: list[str], cfg: dict[str, Any]) -> list[CandidateRecord]:
href_pat = cfg.get("link_href_pattern", r"")
titles = re.findall(rf'<a[^>]+href="[^"]*{href_pat}[^"]*"[^>]*>([^<]{{3,120}})</a>', html)[:3]
authors = _cls_re(cfg.get("author_class", "author"), 3, 80).findall(html)[:3]
return [
CandidateRecord(
source=self.plugin_id,
title=title.strip(),
author=authors[i].strip() if i < len(authors) else "",
year=years[i] if i < len(years) else "",
isbn="",
publisher="",
)
for i, title in enumerate(titles)
]
def _parse_brief(self, html: str, years: list[str], cfg: dict[str, Any]) -> list[CandidateRecord]:
titles = _cls_re(cfg.get("brief_class", "brief"), 3, 120).findall(html)[:3]
return [
CandidateRecord(
source=self.plugin_id,
title=t.strip(),
author="",
year=years[i] if i < len(years) else "",
isbn="",
publisher="",
)
for i, t in enumerate(titles)
]

View File

@@ -1,5 +1,17 @@
"""RSL (Russian State Library) AJAX JSON search API plugin (search.rsl.ru).""" """RSL (Russian State Library) search plugin (search.rsl.ru).
The search API requires a POST to ``/site/ajax-search?language=ru`` with
form-encoded body containing ``SearchFilterForm[search]`` and a CSRF token
obtained from the main search page. Query syntax is CQL:
``title:(<title words>) AND author:(<author words>)``.
Results come back as an HTML fragment in the ``content`` key of a JSON
envelope; individual records are identified by the CSS classes
``rsl-item-nocover-title`` (author) and ``rsl-item-nocover-descr`` (title).
Both fields contain ``<b>`` highlight tags that are stripped before returning.
"""
import re
from typing import Any from typing import Any
import httpx import httpx
@@ -9,9 +21,27 @@ from models import CandidateRecord
from ..rate_limiter import RateLimiter from ..rate_limiter import RateLimiter
_DOMAIN = "search.rsl.ru" _DOMAIN = "search.rsl.ru"
_SEARCH_URL = "https://search.rsl.ru/site/ajax-search"
_BASE_URL = "https://search.rsl.ru/ru/search"
_YEAR_RE = re.compile(r"\b(1[0-9]{3}|20[012][0-9])\b")
def _strip_tags(html_frag: str) -> str:
"""Strip HTML tags and decode basic entities from a fragment."""
text = re.sub(r"<[^>]+>", "", html_frag)
text = text.replace("&quot;", '"').replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
return re.sub(r"\s+", " ", text).strip()
class RSLPlugin: class RSLPlugin:
"""Archive searcher for search.rsl.ru.
Formats the query as CQL ``title:(title_words) AND author:(author_word)``
by treating the first whitespace-delimited token as the author surname and
the remainder as title keywords. When only one token is present, a plain
``title:(token) OR author:(token)`` query is used instead.
"""
category = "archive_searchers" category = "archive_searchers"
def __init__( def __init__(
@@ -32,28 +62,79 @@ class RSLPlugin:
self.timeout = timeout self.timeout = timeout
def search(self, query: str) -> list[CandidateRecord]: def search(self, query: str) -> list[CandidateRecord]:
"""Search RSL for books matching query.
Args:
query: Free-text string; the first token is treated as the author
surname and remaining tokens as title keywords.
Returns:
Up to three CandidateRecord dicts extracted from the RSL HTML
response, with ``<b>`` highlight tags stripped.
"""
self._rl.wait_and_record(_DOMAIN, self.rate_limit_seconds) self._rl.wait_and_record(_DOMAIN, self.rate_limit_seconds)
r = httpx.get(
"https://search.rsl.ru/site/ajax-search", cql = self._build_cql(query)
params={"language": "ru", "q": query, "page": 1, "perPage": 5}, client = httpx.Client()
# Fetch the main page to obtain a valid CSRF token.
r0 = client.get(_BASE_URL, timeout=self.timeout, headers={"User-Agent": "Mozilla/5.0"})
csrf_match = re.search(r'name="_csrf"\s+value="([^"]+)"', r0.text)
csrf = csrf_match.group(1) if csrf_match else ""
r = client.post(
_SEARCH_URL,
params={"language": "ru"},
data={"SearchFilterForm[search]": cql, "_csrf": csrf},
timeout=self.timeout, timeout=self.timeout,
headers={"Accept": "application/json"}, headers={
"Accept": "application/json",
"X-Requested-With": "XMLHttpRequest",
"Referer": _BASE_URL,
"User-Agent": "Mozilla/5.0",
},
) )
data: dict[str, Any] = r.json() data: dict[str, Any] = r.json()
records: list[dict[str, Any]] = data.get("records") or data.get("items") or data.get("data") or [] content = str(data.get("content") or "")
raw_titles = re.findall(r'rsl-item-nocover-descr[^"]*">(.*?)</div>', content)[:3]
raw_authors = re.findall(r'rsl-item-nocover-title[^"]*">(.*?)</div>', content)[:3]
years = _YEAR_RE.findall(content)[:3]
out: list[CandidateRecord] = [] out: list[CandidateRecord] = []
for rec in records[:3]: for i, raw_title in enumerate(raw_titles):
title = (str(rec.get("title") or rec.get("name") or "")).strip() title = _strip_tags(raw_title)
if not title: if not title:
continue continue
author = _strip_tags(raw_authors[i]) if i < len(raw_authors) else ""
out.append( out.append(
CandidateRecord( CandidateRecord(
source=self.plugin_id, source=self.plugin_id,
title=title, title=title,
author=(str(rec.get("author") or rec.get("authors") or "")).strip(), author=author,
year=str(rec.get("year") or rec.get("pubyear") or "").strip(), year=years[i] if i < len(years) else "",
isbn=(str(rec.get("isbn") or "")).strip(), isbn="",
publisher=(str(rec.get("publisher") or "")).strip(), publisher="",
) )
) )
return out return out
@staticmethod
def _build_cql(query: str) -> str:
"""Build a CQL query string for the RSL search API.
Args:
query: Raw query string, typically ``"Author Title keywords"``.
Returns:
CQL string in the form ``title:(…) AND author:(…)`` when the query
contains multiple tokens, or ``title:(…) OR author:(…)`` for a
single token.
"""
tokens = query.split()
if len(tokens) > 1:
author_part = tokens[0]
title_part = " ".join(tokens[1:])
return f"title:({title_part}) AND author:({author_part})"
token = tokens[0] if tokens else query
return f"title:({token}) OR author:({token})"

View File

@@ -0,0 +1,64 @@
"""НЭБ (rusneb.ru) archive search plugin."""
import re
import httpx
from models import CandidateRecord
from .html_scraper import HtmlScraperPlugin, YEAR_RE, cls_inner_texts, img_alts
_URL = "https://rusneb.ru/search/"
_DOMAIN = "rusneb.ru"
_AUTHOR_CLASS = "search-list__item_subtext"
# Each search result is a <li> whose class contains search-list__item but not a BEM
# child element suffix (which would begin with underscore, e.g. __item_subtext).
_ITEM_RE = re.compile(
r'<li[^>]*class=["\'][^"\']*search-list__item(?!_)[^"\']*["\'][^>]*>(.*?)</li>',
re.DOTALL,
)
class RusnebPlugin(HtmlScraperPlugin):
"""Archive searcher for rusneb.ru (НЭБ — Национальная электронная библиотека).
Extracts book titles from ``<img alt>`` attributes within search result list
items and authors from ``.search-list__item_subtext`` spans. Years are
extracted per list item to avoid picking up unrelated page-level dates.
"""
def search(self, query: str) -> list[CandidateRecord]:
"""Search НЭБ for books matching query.
Args:
query: Free-text search string.
Returns:
Up to three CandidateRecord dicts with source, title, author, year,
isbn, and publisher fields.
"""
self._rl.wait_and_record(_DOMAIN, self.rate_limit_seconds)
r = httpx.get(_URL, params={"q": query}, timeout=self.timeout, headers={"User-Agent": "Mozilla/5.0"})
html = r.text
out: list[CandidateRecord] = []
for item_html in _ITEM_RE.findall(html):
alts = img_alts(item_html)
if not alts:
continue
authors = cls_inner_texts(item_html, _AUTHOR_CLASS, 3, 80)
year_m = YEAR_RE.search(item_html)
out.append(
CandidateRecord(
source=self.plugin_id,
title=alts[0],
author=authors[0] if authors else "",
year=year_m.group(0) if year_m else "",
isbn="",
publisher="",
)
)
if len(out) == 3:
break
return out

View File

@@ -0,0 +1,63 @@
"""ШПИЛ archive search plugin.
Note: the IRBIS64 CGI endpoint currently returns HTTP 404 and this plugin
produces no results. The class is retained so the configuration entry can
be re-enabled if the endpoint is restored.
"""
import re
import httpx
from models import CandidateRecord
from .html_scraper import YEAR_RE, HtmlScraperPlugin
_URL = "https://www.shpl.ru/cgi-bin/irbis64/cgiirbis_64.exe"
_DOMAIN = "www.shpl.ru"
_EXTRA_PARAMS: dict[str, str] = {
"C21COM": "S",
"I21DBN": "BIBL",
"P21DBN": "BIBL",
"S21FMT": "briefWebRus",
"Z21ID": "",
}
_BRIEF_RE = re.compile(r'class=["\']brief["\'][^>]*>([^<]{3,120})<')
class ShplPlugin(HtmlScraperPlugin):
"""Archive searcher for shpl.ru (ШПИЛ — Государственная публичная историческая библиотека).
Extracts brief record entries from elements with class ``brief``.
The remote IRBIS64 CGI endpoint is currently offline (HTTP 404).
"""
def search(self, query: str) -> list[CandidateRecord]:
"""Search ШПИЛ for books matching query.
Args:
query: Free-text search string.
Returns:
Up to three CandidateRecord dicts with source, title, author, year,
isbn, and publisher fields.
"""
self._rl.wait_and_record(_DOMAIN, self.rate_limit_seconds)
params: dict[str, str] = dict(_EXTRA_PARAMS)
params["S21ALL"] = query
r = httpx.get(_URL, params=params, timeout=self.timeout, headers={"User-Agent": "Mozilla/5.0"})
html = r.text
years = YEAR_RE.findall(html)
titles = _BRIEF_RE.findall(html)[:3]
return [
CandidateRecord(
source=self.plugin_id,
title=t.strip(),
author="",
year=years[i] if i < len(years) else "",
isbn="",
publisher="",
)
for i, t in enumerate(titles)
]

View File

@@ -1,29 +1,37 @@
/* /*
* layout.css * layout.css
* Top-level layout: sticky header bar, two-column desktop layout * Top-level layout: global header spanning full width, two-column desktop
* (300px sidebar + flex main panel), mobile single-column default, * layout (300px sidebar + flex main panel), mobile single-column default,
* and the contenteditable header span used for inline entity renaming. * and the contenteditable header span used for inline entity renaming.
* *
* Breakpoint: ≥768px = desktop two-column; <768px = mobile accordion. * Breakpoint: ≥768px = desktop two-column; <768px = mobile accordion.
*/ */
/* ── Header ── */ /* ── Page wrapper (header + content area) ── */
.page-wrap{display:flex;flex-direction:column;min-height:100vh}
/* ── Global header ── */
.hdr{background:#1e3a5f;color:white;padding:10px 14px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:100;box-shadow:0 2px 6px rgba(0,0,0,.3);flex-shrink:0} .hdr{background:#1e3a5f;color:white;padding:10px 14px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:100;box-shadow:0 2px 6px rgba(0,0,0,.3);flex-shrink:0}
.hdr h1{flex:1;font-size:.96rem;font-weight:600} .hdr h1{font-size:.96rem;font-weight:600}
.hbtn{background:none;border:none;color:white;min-width:34px;min-height:34px;border-radius:50%;cursor:pointer;font-size:1rem;display:flex;align-items:center;justify-content:center;flex-shrink:0} .hbtn{background:none;border:none;color:white;min-width:34px;min-height:34px;border-radius:50%;cursor:pointer;font-size:1rem;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.hbtn:active{background:rgba(255,255,255,.2)} .hbtn:active{background:rgba(255,255,255,.2)}
/* ── AI active indicator (in global header) ── */
.ai-indicator{display:inline-flex;align-items:center;gap:5px;font-size:.75rem;color:rgba(255,255,255,.9);padding:2px 8px;border-radius:10px;background:rgba(255,255,255,.12)}
.ai-dot{width:7px;height:7px;border-radius:50%;background:#f59e0b;animation:pulse 1.2s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.5;transform:scale(.8)}}
/* ── Mobile layout (default) ── */ /* ── Mobile layout (default) ── */
.layout{display:flex;flex-direction:column;min-height:100vh} .layout{display:flex;flex-direction:column;flex:1}
.sidebar{flex:1} .sidebar{flex:1}
.main-panel{display:none} .main-panel{display:none}
/* ── Desktop layout ── */ /* ── Desktop layout ── */
@media(min-width:768px){ @media(min-width:768px){
body{overflow:hidden} body{overflow:hidden}
.layout{flex-direction:row;height:100vh;overflow:hidden} .page-wrap{height:100vh;overflow:hidden}
.layout{flex-direction:row;flex:1;overflow:hidden}
.sidebar{width:300px;display:flex;flex-direction:column;border-right:1px solid #cbd5e1;overflow:hidden;flex-shrink:0} .sidebar{width:300px;display:flex;flex-direction:column;border-right:1px solid #cbd5e1;overflow:hidden;flex-shrink:0}
.sidebar .hdr{padding:9px 12px}
.sidebar-body{flex:1;overflow-y:auto;padding:8px 10px 16px} .sidebar-body{flex:1;overflow-y:auto;padding:8px 10px 16px}
.main-panel{flex:1;display:flex;flex-direction:column;overflow:hidden;background:#e8eef5} .main-panel{flex:1;display:flex;flex-direction:column;overflow:hidden;background:#e8eef5}
.main-hdr{background:#1e3a5f;color:white;padding:9px 14px;display:flex;align-items:center;gap:8px;flex-shrink:0} .main-hdr{background:#1e3a5f;color:white;padding:9px 14px;display:flex;align-items:center;gap:8px;flex-shrink:0}
@@ -31,6 +39,12 @@
.main-body{flex:1;overflow:auto;padding:14px} .main-body{flex:1;overflow:auto;padding:14px}
} }
/* ── Root detail panel ── */
.det-root{max-width:640px}
.ai-log-entry{border-bottom:1px solid #f1f5f9;padding:0 2px}
.ai-log-entry:last-child{border-bottom:none}
.ai-log-entry summary::-webkit-details-marker{display:none}
/* ── Detail header editable name ── */ /* ── Detail header editable name ── */
.hdr-edit{display:block;outline:none;cursor:text;border-radius:3px;padding:1px 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .hdr-edit{display:block;outline:none;cursor:text;border-radius:3px;padding:1px 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.hdr-edit:focus{background:rgba(255,255,255,.15);white-space:normal;overflow:visible} .hdr-edit:focus{background:rgba(255,255,255,.15);white-space:normal;overflow:visible}

View File

@@ -29,3 +29,10 @@
.pq-skip-btn{background:rgba(255,255,255,.1);color:#cbd5e1;border:none;border-radius:8px;padding:12px 18px;font-size:.85rem;cursor:pointer;min-width:70px} .pq-skip-btn{background:rgba(255,255,255,.1);color:#cbd5e1;border:none;border-radius:8px;padding:12px 18px;font-size:.85rem;cursor:pointer;min-width:70px}
.pq-skip-btn:active{background:rgba(255,255,255,.2)} .pq-skip-btn:active{background:rgba(255,255,255,.2)}
.pq-processing{position:absolute;inset:0;background:rgba(15,23,42,.88);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;font-size:.9rem} .pq-processing{position:absolute;inset:0;background:rgba(15,23,42,.88);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;font-size:.9rem}
/* ── Image popup ── */
.img-popup{display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:500;align-items:center;justify-content:center}
.img-popup.open{display:flex}
.img-popup-inner{position:relative;max-width:90vw;max-height:90vh}
.img-popup-inner img{max-width:90vw;max-height:90vh;object-fit:contain;border-radius:4px;display:block}
.img-popup-close{position:absolute;top:-14px;right:-14px;background:#fff;border:none;border-radius:50%;width:28px;height:28px;cursor:pointer;font-size:18px;line-height:28px;text-align:center;padding:0;box-shadow:0 2px 6px rgba(0,0,0,.3)}

View File

@@ -33,6 +33,15 @@
<!-- Slide-in toast notification; text set by toast() in js/helpers.js --> <!-- Slide-in toast notification; text set by toast() in js/helpers.js -->
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<!-- Full-screen image popup: shown when user clicks a book spine or title-page image.
Closed by clicking outside or the × button. -->
<div id="img-popup" class="img-popup">
<div class="img-popup-inner">
<button class="img-popup-close" id="img-popup-close">×</button>
<img id="img-popup-img" src="" alt="">
</div>
</div>
<!-- SortableJS: drag-and-drop reordering for rooms, cabinets, shelves, and books --> <!-- SortableJS: drag-and-drop reordering for rooms, cabinets, shelves, and books -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
@@ -73,7 +82,7 @@
with all action cases; accordion expand helpers. --> with all action cases; accordion expand helpers. -->
<script src="js/events.js"></script> <script src="js/events.js"></script>
<!-- render(), renderDetail(), loadConfig(), startBatchPolling(), loadTree(), <!-- render(), renderDetail(), loadConfig(), connectBatchWs(), loadTree(),
and the bootstrap Promise.all([loadConfig(), loadTree()]) call. --> and the bootstrap Promise.all([loadConfig(), loadTree()]) call. -->
<script src="js/init.js"></script> <script src="js/init.js"></script>

View File

@@ -7,16 +7,22 @@
* Depends on: nothing * Depends on: nothing
*/ */
/* exported req */
// ── API ────────────────────────────────────────────────────────────────────── // ── API ──────────────────────────────────────────────────────────────────────
async function req(method, url, body = null, isForm = false) { async function req(method, url, body = null, isForm = false) {
const opts = {method}; const opts = { method };
if (body) { if (body) {
if (isForm) { opts.body = body; } if (isForm) {
else { opts.headers = {'Content-Type':'application/json'}; opts.body = JSON.stringify(body); } opts.body = body;
} else {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(body);
}
} }
const r = await fetch(url, opts); const r = await fetch(url, opts);
if (!r.ok) { if (!r.ok) {
const e = await r.json().catch(() => ({detail:'Request failed'})); const e = await r.json().catch(() => ({ detail: 'Request failed' }));
throw new Error(e.detail || 'Request failed'); throw new Error(e.detail || 'Request failed');
} }
return r.json(); return r.json();

View File

@@ -16,10 +16,16 @@
* setupDetailCanvas(), drawBnd(), clearSegHover() * setupDetailCanvas(), drawBnd(), clearSegHover()
*/ */
/* exported parseBounds, parseBndPluginResults, setupDetailCanvas, drawBnd */
// ── Boundary parsing helpers ───────────────────────────────────────────────── // ── Boundary parsing helpers ─────────────────────────────────────────────────
function parseBounds(json) { function parseBounds(json) {
if (!json) return []; if (!json) return [];
try { return JSON.parse(json) || []; } catch { return []; } try {
return JSON.parse(json) || [];
} catch {
return [];
}
} }
function parseBndPluginResults(json) { function parseBndPluginResults(json) {
@@ -28,11 +34,19 @@ function parseBndPluginResults(json) {
const v = JSON.parse(json); const v = JSON.parse(json);
if (Array.isArray(v) || !v || typeof v !== 'object') return {}; if (Array.isArray(v) || !v || typeof v !== 'object') return {};
return v; return v;
} catch { return {}; } } 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_FILLS = [
const SEG_STROKES = ['#3b82f6','#10b981','#f59e0b','#ef4444','#a855f7']; '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 ───────────────────────────────────────────────────────────── // ── Canvas setup ─────────────────────────────────────────────────────────────
function setupDetailCanvas() { function setupDetailCanvas() {
@@ -40,7 +54,7 @@ function setupDetailCanvas() {
const img = document.getElementById('bnd-img'); const img = document.getElementById('bnd-img');
const canvas = document.getElementById('bnd-canvas'); const canvas = document.getElementById('bnd-canvas');
if (!wrap || !img || !canvas || !S.selected) return; if (!wrap || !img || !canvas || !S.selected) return;
const {type, id} = S.selected; const { type, id } = S.selected;
const node = findNode(id); const node = findNode(id);
if (!node || (type !== 'cabinet' && type !== 'shelf')) return; if (!node || (type !== 'cabinet' && type !== 'shelf')) return;
@@ -48,16 +62,26 @@ function setupDetailCanvas() {
const boundaries = parseBounds(type === 'cabinet' ? node.shelf_boundaries : node.book_boundaries); 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 pluginResults = parseBndPluginResults(type === 'cabinet' ? node.ai_shelf_boundaries : node.ai_book_boundaries);
const pluginIds = Object.keys(pluginResults); const pluginIds = Object.keys(pluginResults);
const segments = type === 'cabinet' const segments =
? node.shelves.map((s,i) => ({id:s.id, label:s.name||`Shelf ${i+1}`})) type === 'cabinet'
: node.books.map((b,i) => ({id:b.id, label:b.title||`Book ${i+1}`})); ? 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 hasChildren = type === 'cabinet' ? node.shelves.length > 0 : node.books.length > 0;
const prevSel = (_bnd?.nodeId === id) ? _bnd.selectedPlugin const prevSel = _bnd?.nodeId === id ? _bnd.selectedPlugin : hasChildren ? null : (pluginIds[0] ?? null);
: (hasChildren ? null : pluginIds[0] ?? null);
_bnd = {wrap, img, canvas, axis, boundaries:[...boundaries], _bnd = {
pluginResults, selectedPlugin: prevSel, segments, nodeId:id, nodeType:type}; wrap,
img,
canvas,
axis,
boundaries: [...boundaries],
pluginResults,
selectedPlugin: prevSel,
segments,
nodeId: id,
nodeType: type,
};
function sizeAndDraw() { function sizeAndDraw() {
canvas.width = img.offsetWidth; canvas.width = img.offsetWidth;
@@ -78,8 +102,9 @@ function setupDetailCanvas() {
// ── Draw ───────────────────────────────────────────────────────────────────── // ── Draw ─────────────────────────────────────────────────────────────────────
function drawBnd(dragIdx = -1, dragVal = null) { function drawBnd(dragIdx = -1, dragVal = null) {
if (!_bnd || S._cropMode) return; if (!_bnd || S._cropMode) return;
const {canvas, axis, boundaries, segments} = _bnd; const { canvas, axis, boundaries, segments } = _bnd;
const W = canvas.width, H = canvas.height; const W = canvas.width,
H = canvas.height;
if (!W || !H) return; if (!W || !H) return;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H); ctx.clearRect(0, 0, W, H);
@@ -94,11 +119,12 @@ function drawBnd(dragIdx = -1, dragVal = null) {
// Draw segments // Draw segments
for (let i = 0; i < full.length - 1; i++) { for (let i = 0; i < full.length - 1; i++) {
const a = full[i], b = full[i + 1]; const a = full[i],
b = full[i + 1];
const ci = i % SEG_FILLS.length; const ci = i % SEG_FILLS.length;
ctx.fillStyle = SEG_FILLS[ci]; ctx.fillStyle = SEG_FILLS[ci];
if (axis === 'y') ctx.fillRect(0, a*H, W, (b-a)*H); if (axis === 'y') ctx.fillRect(0, a * H, W, (b - a) * H);
else ctx.fillRect(a*W, 0, (b-a)*W, H); else ctx.fillRect(a * W, 0, (b - a) * W, H);
// Label // Label
const seg = segments[i]; const seg = segments[i];
if (seg) { if (seg) {
@@ -106,10 +132,13 @@ function drawBnd(dragIdx = -1, dragVal = null) {
ctx.fillStyle = 'rgba(0,0,0,.5)'; ctx.fillStyle = 'rgba(0,0,0,.5)';
const lbl = seg.label.slice(0, 24); const lbl = seg.label.slice(0, 24);
if (axis === 'y') { if (axis === 'y') {
ctx.fillText(lbl, 4, a*H + 14); ctx.fillText(lbl, 4, a * H + 14);
} else { } else {
ctx.save(); ctx.translate(a*W + 12, 14); ctx.rotate(Math.PI/2); ctx.save();
ctx.fillText(lbl, 0, 0); ctx.restore(); ctx.translate(a * W + 12, 14);
ctx.rotate(Math.PI / 2);
ctx.fillText(lbl, 0, 0);
ctx.restore();
} }
} }
} }
@@ -118,26 +147,36 @@ function drawBnd(dragIdx = -1, dragVal = null) {
ctx.setLineDash([5, 3]); ctx.setLineDash([5, 3]);
ctx.lineWidth = 2; ctx.lineWidth = 2;
for (let i = 0; i < boundaries.length; i++) { for (let i = 0; i < boundaries.length; i++) {
const val = (dragIdx === i && dragVal !== null) ? full[i+1] : boundaries[i]; const val = dragIdx === i && dragVal !== null ? full[i + 1] : boundaries[i];
ctx.strokeStyle = '#1e3a5f'; ctx.strokeStyle = '#1e3a5f';
ctx.beginPath(); ctx.beginPath();
if (axis === 'y') { ctx.moveTo(0, val*H); ctx.lineTo(W, val*H); } if (axis === 'y') {
else { ctx.moveTo(val*W, 0); ctx.lineTo(val*W, H); } ctx.moveTo(0, val * H);
ctx.lineTo(W, val * H);
} else {
ctx.moveTo(val * W, 0);
ctx.lineTo(val * W, H);
}
ctx.stroke(); ctx.stroke();
} }
// Draw plugin boundary suggestions (dashed, non-interactive) // Draw plugin boundary suggestions (dashed, non-interactive)
const {pluginResults, selectedPlugin} = _bnd; const { pluginResults, selectedPlugin } = _bnd;
const pluginIds = Object.keys(pluginResults); const pluginIds = Object.keys(pluginResults);
if (selectedPlugin && pluginIds.length) { if (selectedPlugin && pluginIds.length) {
ctx.setLineDash([3, 6]); ctx.setLineDash([3, 6]);
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
const drawPluginBounds = (bounds, color) => { const drawPluginBounds = (bounds, color) => {
ctx.strokeStyle = color; ctx.strokeStyle = color;
for (const ab of (bounds || [])) { for (const ab of bounds || []) {
ctx.beginPath(); ctx.beginPath();
if (axis === 'y') { ctx.moveTo(0, ab*H); ctx.lineTo(W, ab*H); } if (axis === 'y') {
else { ctx.moveTo(ab*W, 0); ctx.lineTo(ab*W, H); } ctx.moveTo(0, ab * H);
ctx.lineTo(W, ab * H);
} else {
ctx.moveTo(ab * W, 0);
ctx.lineTo(ab * W, H);
}
ctx.stroke(); ctx.stroke();
} }
}; };
@@ -151,7 +190,8 @@ function drawBnd(dragIdx = -1, dragVal = null) {
} }
// ── Drag machinery ─────────────────────────────────────────────────────────── // ── Drag machinery ───────────────────────────────────────────────────────────
let _dragIdx = -1, _dragging = false; let _dragIdx = -1,
_dragging = false;
function fracFromEvt(e) { function fracFromEvt(e) {
const r = _bnd.canvas.getBoundingClientRect(); const r = _bnd.canvas.getBoundingClientRect();
@@ -161,27 +201,40 @@ function fracFromEvt(e) {
} }
function nearestBnd(frac) { function nearestBnd(frac) {
const {boundaries, canvas, axis} = _bnd; const { boundaries, canvas, axis } = _bnd;
const r = canvas.getBoundingClientRect(); const r = canvas.getBoundingClientRect();
const dim = axis === 'y' ? r.height : r.width; const dim = axis === 'y' ? r.height : r.width;
const thresh = (window._grabPx ?? 14) / dim; const thresh = (window._grabPx ?? 14) / dim;
let best = -1, bestD = thresh; let best = -1,
boundaries.forEach((b,i) => { const d=Math.abs(b-frac); if(d<bestD){bestD=d;best=i;} }); bestD = thresh;
boundaries.forEach((b, i) => {
const d = Math.abs(b - frac);
if (d < bestD) {
bestD = d;
best = i;
}
});
return best; return best;
} }
function snapToAi(frac) { function snapToAi(frac) {
if (!_bnd?.selectedPlugin) return frac; if (!_bnd?.selectedPlugin) return frac;
const {pluginResults, selectedPlugin} = _bnd; const { pluginResults, selectedPlugin } = _bnd;
const snapBounds = selectedPlugin === 'all' const snapBounds =
? Object.values(pluginResults).flat() selectedPlugin === 'all' ? Object.values(pluginResults).flat() : pluginResults[selectedPlugin] || [];
: (pluginResults[selectedPlugin] || []);
if (!snapBounds.length) return frac; if (!snapBounds.length) return frac;
const r = _bnd.canvas.getBoundingClientRect(); const r = _bnd.canvas.getBoundingClientRect();
const dim = _bnd.axis === 'y' ? r.height : r.width; const dim = _bnd.axis === 'y' ? r.height : r.width;
const thresh = (window._grabPx ?? 14) / dim; const thresh = (window._grabPx ?? 14) / dim;
let best = frac, bestD = thresh; let best = frac,
snapBounds.forEach(ab => { const d = Math.abs(ab - frac); if (d < bestD) { bestD = d; best = ab; } }); bestD = thresh;
snapBounds.forEach((ab) => {
const d = Math.abs(ab - frac);
if (d < bestD) {
bestD = d;
best = ab;
}
});
return best; return best;
} }
@@ -190,7 +243,8 @@ function bndPointerDown(e) {
const frac = fracFromEvt(e); const frac = fracFromEvt(e);
const idx = nearestBnd(frac); const idx = nearestBnd(frac);
if (idx >= 0) { if (idx >= 0) {
_dragIdx = idx; _dragging = true; _dragIdx = idx;
_dragging = true;
_bnd.canvas.setPointerCapture(e.pointerId); _bnd.canvas.setPointerCapture(e.pointerId);
e.stopPropagation(); e.stopPropagation();
} }
@@ -200,8 +254,7 @@ function bndPointerMove(e) {
if (!_bnd || S._cropMode) return; if (!_bnd || S._cropMode) return;
const frac = fracFromEvt(e); const frac = fracFromEvt(e);
const near = nearestBnd(frac); const near = nearestBnd(frac);
_bnd.canvas.style.cursor = (near >= 0 || _dragging) _bnd.canvas.style.cursor = near >= 0 || _dragging ? (_bnd.axis === 'y' ? 'ns-resize' : 'ew-resize') : 'default';
? (_bnd.axis==='y' ? 'ns-resize' : 'ew-resize') : 'default';
if (_dragging && _dragIdx >= 0) drawBnd(_dragIdx, frac); if (_dragging && _dragIdx >= 0) drawBnd(_dragIdx, frac);
} }
@@ -209,22 +262,24 @@ async function bndPointerUp(e) {
if (!_dragging || !_bnd || S._cropMode) return; if (!_dragging || !_bnd || S._cropMode) return;
const frac = fracFromEvt(e); const frac = fracFromEvt(e);
_dragging = false; _dragging = false;
const {boundaries, nodeId, nodeType} = _bnd; const { boundaries, nodeId, nodeType } = _bnd;
const full = [0, ...boundaries, 1]; const full = [0, ...boundaries, 1];
const clamped = Math.max(full[_dragIdx]+0.005, Math.min(full[_dragIdx+2]-0.005, frac)); const clamped = Math.max(full[_dragIdx] + 0.005, Math.min(full[_dragIdx + 2] - 0.005, frac));
boundaries[_dragIdx] = Math.round(snapToAi(clamped) * 10000) / 10000; boundaries[_dragIdx] = Math.round(snapToAi(clamped) * 10000) / 10000;
_bnd.boundaries = [...boundaries]; _bnd.boundaries = [...boundaries];
_dragIdx = -1; _dragIdx = -1;
drawBnd(); drawBnd();
const url = nodeType==='cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`; const url = nodeType === 'cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`;
try { try {
await req('PATCH', url, {boundaries}); await req('PATCH', url, { boundaries });
const node = findNode(nodeId); const node = findNode(nodeId);
if (node) { if (node) {
if (nodeType==='cabinet') node.shelf_boundaries = JSON.stringify(boundaries); if (nodeType === 'cabinet') node.shelf_boundaries = JSON.stringify(boundaries);
else node.book_boundaries = JSON.stringify(boundaries); else node.book_boundaries = JSON.stringify(boundaries);
} }
} catch(err) { toast('Save failed: ' + err.message); } } catch (err) {
toast('Save failed: ' + err.message);
}
} }
async function bndClick(e) { async function bndClick(e) {
@@ -232,40 +287,59 @@ async function bndClick(e) {
if (!e.ctrlKey || !e.altKey) return; if (!e.ctrlKey || !e.altKey) return;
e.preventDefault(); e.preventDefault();
const frac = snapToAi(fracFromEvt(e)); const frac = snapToAi(fracFromEvt(e));
const {boundaries, nodeId, nodeType} = _bnd; const { boundaries, nodeId, nodeType } = _bnd;
const newBounds = [...boundaries, frac].sort((a,b)=>a-b); const newBounds = [...boundaries, frac].sort((a, b) => a - b);
_bnd.boundaries = newBounds; _bnd.boundaries = newBounds;
const url = nodeType==='cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`; const url = nodeType === 'cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`;
try { try {
await req('PATCH', url, {boundaries: newBounds}); await req('PATCH', url, { boundaries: newBounds });
if (nodeType === 'cabinet') { if (nodeType === 'cabinet') {
const s = await req('POST', `/api/cabinets/${nodeId}/shelves`, null); const s = await req('POST', `/api/cabinets/${nodeId}/shelves`, null);
S.tree.forEach(r=>r.cabinets.forEach(c=>{ if(c.id===nodeId){ S.tree.forEach((r) =>
c.shelf_boundaries=JSON.stringify(newBounds); c.shelves.push({...s,books:[]}); r.cabinets.forEach((c) => {
}})); if (c.id === nodeId) {
c.shelf_boundaries = JSON.stringify(newBounds);
c.shelves.push({ ...s, books: [] });
}
}),
);
} else { } else {
const b = await req('POST', `/api/shelves/${nodeId}/books`); 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.tree.forEach((r) =>
s.book_boundaries=JSON.stringify(newBounds); s.books.push(b); r.cabinets.forEach((c) =>
}}))); c.shelves.forEach((s) => {
if (s.id === nodeId) {
s.book_boundaries = JSON.stringify(newBounds);
s.books.push(b);
}
}),
),
);
} }
render(); render();
} catch(err) { toast('Error: ' + err.message); } } catch (err) {
toast('Error: ' + err.message);
}
} }
function bndHover(e) { function bndHover(e) {
if (!_bnd || S._cropMode) return; if (!_bnd || S._cropMode) return;
const frac = fracFromEvt(e); const frac = fracFromEvt(e);
const {boundaries, segments} = _bnd; const { boundaries, segments } = _bnd;
const full = [0, ...boundaries, 1]; const full = [0, ...boundaries, 1];
let segIdx = -1; let segIdx = -1;
for (let i = 0; i < full.length-1; i++) { if(frac>=full[i]&&frac<full[i+1]){segIdx=i;break;} } for (let i = 0; i < full.length - 1; i++) {
if (frac >= full[i] && frac < full[i + 1]) {
segIdx = i;
break;
}
}
clearSegHover(); clearSegHover();
if (segIdx>=0 && segments[segIdx]) { if (segIdx >= 0 && segments[segIdx]) {
document.querySelector(`.node[data-id="${segments[segIdx].id}"] .nrow`)?.classList.add('seg-hover'); document.querySelector(`.node[data-id="${segments[segIdx].id}"] .nrow`)?.classList.add('seg-hover');
} }
} }
function clearSegHover() { function clearSegHover() {
document.querySelectorAll('.seg-hover').forEach(el=>el.classList.remove('seg-hover')); document.querySelectorAll('.seg-hover').forEach((el) => el.classList.remove('seg-hover'));
} }

View File

@@ -13,6 +13,8 @@
* Provides: startCropMode(), cancelCrop(), confirmCrop() * Provides: startCropMode(), cancelCrop(), confirmCrop()
*/ */
/* exported startCropMode */
// ── Crop state ─────────────────────────────────────────────────────────────── // ── Crop state ───────────────────────────────────────────────────────────────
let _cropState = null; // {x1,y1,x2,y2} fractions; null = not in crop mode 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 _cropDragPart = null; // 'tl','tr','bl','br','t','b','l','r','move' | null
@@ -23,8 +25,8 @@ function startCropMode(type, id) {
const canvas = document.getElementById('bnd-canvas'); const canvas = document.getElementById('bnd-canvas');
const wrap = document.getElementById('bnd-wrap'); const wrap = document.getElementById('bnd-wrap');
if (!canvas || !wrap) return; if (!canvas || !wrap) return;
S._cropMode = {type, id}; S._cropMode = { type, id };
_cropState = {x1: 0.05, y1: 0.05, x2: 0.95, y2: 0.95}; _cropState = { x1: 0.05, y1: 0.05, x2: 0.95, y2: 0.95 };
canvas.addEventListener('pointerdown', cropPointerDown); canvas.addEventListener('pointerdown', cropPointerDown);
canvas.addEventListener('pointermove', cropPointerMove); canvas.addEventListener('pointermove', cropPointerMove);
@@ -34,7 +36,8 @@ function startCropMode(type, id) {
const bar = document.createElement('div'); const bar = document.createElement('div');
bar.id = 'crop-bar'; bar.id = 'crop-bar';
bar.style.cssText = 'margin-top:10px;display:flex;gap:8px'; bar.style.cssText = 'margin-top:10px;display:flex;gap:8px';
bar.innerHTML = '<button class="btn btn-p" id="crop-ok">Confirm crop</button><button class="btn btn-s" id="crop-cancel">Cancel</button>'; bar.innerHTML =
'<button class="btn btn-p" id="crop-ok">Confirm crop</button><button class="btn btn-s" id="crop-cancel">Cancel</button>';
wrap.after(bar); wrap.after(bar);
document.getElementById('crop-ok').addEventListener('click', confirmCrop); document.getElementById('crop-ok').addEventListener('click', confirmCrop);
document.getElementById('crop-cancel').addEventListener('click', cancelCrop); document.getElementById('crop-cancel').addEventListener('click', cancelCrop);
@@ -47,63 +50,81 @@ function drawCropOverlay() {
const canvas = document.getElementById('bnd-canvas'); const canvas = document.getElementById('bnd-canvas');
if (!canvas || !_cropState) return; if (!canvas || !_cropState) return;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height; const W = canvas.width,
const {x1, y1, x2, y2} = _cropState; H = canvas.height;
const px1=x1*W, py1=y1*H, px2=x2*W, py2=y2*H; 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); ctx.clearRect(0, 0, W, H);
// Dark shadow outside crop rect // Dark shadow outside crop rect
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(0, 0, W, H); ctx.fillRect(0, 0, W, H);
ctx.clearRect(px1, py1, px2-px1, py2-py1); ctx.clearRect(px1, py1, px2 - px1, py2 - py1);
// Bright border // Bright border
ctx.strokeStyle = '#38bdf8'; ctx.lineWidth = 2; ctx.setLineDash([]); ctx.strokeStyle = '#38bdf8';
ctx.strokeRect(px1, py1, px2-px1, py2-py1); ctx.lineWidth = 2;
ctx.setLineDash([]);
ctx.strokeRect(px1, py1, px2 - px1, py2 - py1);
// Corner handles // Corner handles
const hs = 9; const hs = 9;
ctx.fillStyle = '#38bdf8'; ctx.fillStyle = '#38bdf8';
[[px1,py1],[px2,py1],[px1,py2],[px2,py2]].forEach(([x,y]) => ctx.fillRect(x-hs/2, y-hs/2, hs, hs)); [
[px1, py1],
[px2, py1],
[px1, py2],
[px2, py2],
].forEach(([x, y]) => ctx.fillRect(x - hs / 2, y - hs / 2, hs, hs));
} }
// ── Hit testing ────────────────────────────────────────────────────────────── // ── Hit testing ──────────────────────────────────────────────────────────────
function _cropFracFromEvt(e) { function _cropFracFromEvt(e) {
const canvas = document.getElementById('bnd-canvas'); const canvas = document.getElementById('bnd-canvas');
const r = canvas.getBoundingClientRect(); const r = canvas.getBoundingClientRect();
return {fx: (e.clientX-r.left)/r.width, fy: (e.clientY-r.top)/r.height}; return { fx: (e.clientX - r.left) / r.width, fy: (e.clientY - r.top) / r.height };
} }
function _getCropPart(fx, fy) { function _getCropPart(fx, fy) {
if (!_cropState) return null; if (!_cropState) return null;
const {x1, y1, x2, y2} = _cropState; const { x1, y1, x2, y2 } = _cropState;
const th = 0.05; const th = 0.05;
const inX=fx>=x1&&fx<=x2, inY=fy>=y1&&fy<=y2; const inX = fx >= x1 && fx <= x2,
const nX1=Math.abs(fx-x1)<th, nX2=Math.abs(fx-x2)<th; inY = fy >= y1 && fy <= y2;
const nY1=Math.abs(fy-y1)<th, nY2=Math.abs(fy-y2)<th; const nX1 = Math.abs(fx - x1) < th,
if (nX1&&nY1) return 'tl'; if (nX2&&nY1) return 'tr'; nX2 = Math.abs(fx - x2) < th;
if (nX1&&nY2) return 'bl'; if (nX2&&nY2) return 'br'; const nY1 = Math.abs(fy - y1) < th,
if (nY1&&inX) return 't'; if (nY2&&inX) return 'b'; nY2 = Math.abs(fy - y2) < th;
if (nX1&&inY) return 'l'; if (nX2&&inY) return 'r'; if (nX1 && nY1) return 'tl';
if (inX&&inY) return 'move'; if (nX2 && nY1) return 'tr';
if (nX1 && nY2) return 'bl';
if (nX2 && nY2) return 'br';
if (nY1 && inX) return 't';
if (nY2 && inX) return 'b';
if (nX1 && inY) return 'l';
if (nX2 && inY) return 'r';
if (inX && inY) return 'move';
return null; return null;
} }
function _cropPartCursor(part) { function _cropPartCursor(part) {
if (!part) return 'crosshair'; if (!part) return 'crosshair';
if (part==='move') return 'move'; if (part === 'move') return 'move';
if (part==='tl'||part==='br') return 'nwse-resize'; if (part === 'tl' || part === 'br') return 'nwse-resize';
if (part==='tr'||part==='bl') return 'nesw-resize'; if (part === 'tr' || part === 'bl') return 'nesw-resize';
if (part==='t'||part==='b') return 'ns-resize'; if (part === 't' || part === 'b') return 'ns-resize';
return 'ew-resize'; return 'ew-resize';
} }
// ── Pointer events ─────────────────────────────────────────────────────────── // ── Pointer events ───────────────────────────────────────────────────────────
function cropPointerDown(e) { function cropPointerDown(e) {
if (!_cropState) return; if (!_cropState) return;
const {fx, fy} = _cropFracFromEvt(e); const { fx, fy } = _cropFracFromEvt(e);
const part = _getCropPart(fx, fy); const part = _getCropPart(fx, fy);
if (part) { if (part) {
_cropDragPart = part; _cropDragPart = part;
_cropDragStart = {fx, fy, ..._cropState}; _cropDragStart = { fx, fy, ..._cropState };
document.getElementById('bnd-canvas').setPointerCapture(e.pointerId); document.getElementById('bnd-canvas').setPointerCapture(e.pointerId);
} }
} }
@@ -111,19 +132,23 @@ function cropPointerDown(e) {
function cropPointerMove(e) { function cropPointerMove(e) {
if (!_cropState) return; if (!_cropState) return;
const canvas = document.getElementById('bnd-canvas'); const canvas = document.getElementById('bnd-canvas');
const {fx, fy} = _cropFracFromEvt(e); const { fx, fy } = _cropFracFromEvt(e);
if (_cropDragPart && _cropDragStart) { if (_cropDragPart && _cropDragStart) {
const dx=fx-_cropDragStart.fx, dy=fy-_cropDragStart.fy; const dx = fx - _cropDragStart.fx,
const s = {..._cropState}; dy = fy - _cropDragStart.fy;
if (_cropDragPart==='move') { const s = { ..._cropState };
const w=_cropDragStart.x2-_cropDragStart.x1, h=_cropDragStart.y2-_cropDragStart.y1; if (_cropDragPart === 'move') {
s.x1=Math.max(0,Math.min(1-w,_cropDragStart.x1+dx)); s.y1=Math.max(0,Math.min(1-h,_cropDragStart.y1+dy)); const w = _cropDragStart.x2 - _cropDragStart.x1,
s.x2=s.x1+w; s.y2=s.y1+h; h = _cropDragStart.y2 - _cropDragStart.y1;
s.x1 = Math.max(0, Math.min(1 - w, _cropDragStart.x1 + dx));
s.y1 = Math.max(0, Math.min(1 - h, _cropDragStart.y1 + dy));
s.x2 = s.x1 + w;
s.y2 = s.y1 + h;
} else { } else {
if (_cropDragPart.includes('l')) s.x1=Math.max(0,Math.min(_cropDragStart.x2-0.05,_cropDragStart.x1+dx)); if (_cropDragPart.includes('l')) s.x1 = Math.max(0, Math.min(_cropDragStart.x2 - 0.05, _cropDragStart.x1 + dx));
if (_cropDragPart.includes('r')) s.x2=Math.min(1,Math.max(_cropDragStart.x1+0.05,_cropDragStart.x2+dx)); if (_cropDragPart.includes('r')) s.x2 = Math.min(1, Math.max(_cropDragStart.x1 + 0.05, _cropDragStart.x2 + dx));
if (_cropDragPart.includes('t')) s.y1=Math.max(0,Math.min(_cropDragStart.y2-0.05,_cropDragStart.y1+dy)); if (_cropDragPart.includes('t')) s.y1 = Math.max(0, Math.min(_cropDragStart.y2 - 0.05, _cropDragStart.y1 + dy));
if (_cropDragPart.includes('b')) s.y2=Math.min(1,Math.max(_cropDragStart.y1+0.05,_cropDragStart.y2+dy)); if (_cropDragPart.includes('b')) s.y2 = Math.min(1, Math.max(_cropDragStart.y1 + 0.05, _cropDragStart.y2 + dy));
} }
_cropState = s; _cropState = s;
drawCropOverlay(); drawCropOverlay();
@@ -133,27 +158,46 @@ function cropPointerMove(e) {
} }
} }
function cropPointerUp() { _cropDragPart = null; _cropDragStart = null; } function cropPointerUp() {
_cropDragPart = null;
_cropDragStart = null;
}
// ── Confirm / cancel ───────────────────────────────────────────────────────── // ── Confirm / cancel ─────────────────────────────────────────────────────────
async function confirmCrop() { async function confirmCrop() {
if (!_cropState || !S._cropMode) return; if (!_cropState || !S._cropMode) return;
const img = document.getElementById('bnd-img'); const img = document.getElementById('bnd-img');
if (!img) return; if (!img) return;
const {x1, y1, x2, y2} = _cropState; const { x1, y1, x2, y2 } = _cropState;
const W=img.naturalWidth, H=img.naturalHeight; const W = img.naturalWidth,
const px = {x:Math.round(x1*W), y:Math.round(y1*H), w:Math.round((x2-x1)*W), h:Math.round((y2-y1)*H)}; H = img.naturalHeight;
if (px.w<10||px.h<10) { toast('Selection too small'); return; } const px = {
const {type, id} = S._cropMode; x: Math.round(x1 * W),
const url = type==='cabinet' ? `/api/cabinets/${id}/crop` : `/api/shelves/${id}/crop`; y: Math.round(y1 * H),
w: Math.round((x2 - x1) * W),
h: Math.round((y2 - y1) * H),
};
if (px.w < 10 || px.h < 10) {
toast('Selection too small');
return;
}
const { type, id } = S._cropMode;
const url = type === 'cabinet' ? `/api/cabinets/${id}/crop` : `/api/shelves/${id}/crop`;
try { try {
await req('POST', url, px); await req('POST', url, px);
toast('Cropped'); cancelCrop(); render(); toast('Cropped');
} catch(err) { toast('Crop failed: '+err.message); } cancelCrop();
render();
} catch (err) {
toast('Crop failed: ' + err.message);
}
} }
function cancelCrop() { function cancelCrop() {
S._cropMode = null; _cropState = null; _cropDragPart = null; _cropDragStart = null; S._cropMode = null;
_cropState = null;
_cropDragPart = null;
_cropDragStart = null;
document.getElementById('crop-bar')?.remove(); document.getElementById('crop-bar')?.remove();
const canvas = document.getElementById('bnd-canvas'); const canvas = document.getElementById('bnd-canvas');
if (canvas) { if (canvas) {

View File

@@ -11,20 +11,70 @@
* vShelfDetail(), vBookDetail() * vShelfDetail(), vBookDetail()
*/ */
/* exported vDetailBody, aiBlocksShown */
// ── Room detail ────────────────────────────────────────────────────────────── // ── Room detail ──────────────────────────────────────────────────────────────
function vRoomDetail(r) { function vRoomDetail(r) {
const stats = getBookStats(r, 'room'); const stats = getBookStats(r, 'room');
const totalBooks = stats.total; const totalBooks = stats.total;
return `<div> return `<div>
${vAiProgressBar(stats)} ${vAiProgressBar(stats)}
<p style="font-size:.72rem;color:#64748b">${r.cabinets.length} cabinet${r.cabinets.length!==1?'s':''} · ${totalBooks} book${totalBooks!==1?'s':''}</p> <p style="font-size:.72rem;color:#64748b">${r.cabinets.length} cabinet${r.cabinets.length !== 1 ? 's' : ''} · ${totalBooks} book${totalBooks !== 1 ? 's' : ''}</p>
</div>`;
}
// ── Root detail (no selection) ────────────────────────────────────────────────
function vAiLogEntry(entry) {
const ts = new Date(entry.ts * 1000).toLocaleTimeString();
const statusColor = entry.status === 'ok' ? '#15803d' : entry.status === 'error' ? '#dc2626' : '#b45309';
const statusLabel = entry.status === 'running' ? '⏳' : entry.status === 'ok' ? '✓' : '✗';
const dur = entry.duration_ms > 0 ? ` ${entry.duration_ms}ms` : '';
const model = entry.model
? `<span style="font-size:.68rem;color:#94a3b8;margin-left:6px">${esc(entry.model)}</span>`
: '';
const isBook = entry.entity_type === 'books';
const entityLabel = isBook
? `<button data-a="select" data-type="book" data-id="${esc(entry.entity_id)}"
style="background:none;border:none;padding:0;cursor:pointer;color:#2563eb;font-size:.75rem;text-decoration:underline"
>${esc(entry.entity_id.slice(0, 8))}</button>`
: `<span>${esc(entry.entity_id.slice(0, 8))}</span>`;
const thumb = isBook
? `<img src="/api/books/${esc(entry.entity_id)}/spine" alt=""
style="height:30px;width:auto;vertical-align:middle;border-radius:2px;margin-left:2px"
onerror="this.style.display='none'">`
: '';
return `<details class="ai-log-entry">
<summary style="display:flex;align-items:center;gap:6px;cursor:pointer;list-style:none;padding:6px 0">
<span style="color:${statusColor};font-weight:600;font-size:.78rem;width:1.2rem;text-align:center">${statusLabel}</span>
<span style="font-size:.75rem;color:#475569;flex:1;display:flex;align-items:center;gap:4px;flex-wrap:wrap">
${esc(entry.plugin_id)} · ${entityLabel}${thumb}
</span>
<span style="font-size:.68rem;color:#94a3b8;white-space:nowrap">${ts}${dur}</span>
</summary>
<div style="padding:6px 0 6px 1.8rem;font-size:.75rem;color:#475569">
${model}
${entry.request ? `<div style="margin-top:4px;color:#64748b"><strong>Request:</strong> ${esc(entry.request)}</div>` : ''}
${entry.response ? `<div style="margin-top:4px;color:#64748b"><strong>Response:</strong> ${esc(entry.response)}</div>` : ''}
</div>
</details>`;
}
function vRootDetail() {
const log = (_aiLog || []).slice().reverse(); // newest first
return `<div style="padding:0">
<div style="font-size:.72rem;font-weight:600;color:#64748b;margin-bottom:8px;text-transform:uppercase;letter-spacing:.04em">AI Request Log</div>
${
log.length === 0
? `<div style="font-size:.78rem;color:#94a3b8">No AI requests yet. Use Identify or run a plugin on a book.</div>`
: log.map(vAiLogEntry).join('<hr style="border:none;border-top:1px solid #f1f5f9;margin:0">')
}
</div>`; </div>`;
} }
// ── Detail body (right panel) ──────────────────────────────────────────────── // ── Detail body (right panel) ────────────────────────────────────────────────
function vDetailBody() { function vDetailBody() {
if (!S.selected) return '<div class="det-empty">← Select a room, cabinet or shelf from the tree</div>'; if (!S.selected) return `<div class="det-root">${vRootDetail()}</div>`;
const {type, id} = S.selected; const { type, id } = S.selected;
const node = findNode(id); const node = findNode(id);
if (!node) return '<div class="det-empty">Not found</div>'; if (!node) return '<div class="det-empty">Not found</div>';
if (type === 'room') return vRoomDetail(node); if (type === 'room') return vRoomDetail(node);
@@ -42,29 +92,34 @@ function vCabinetDetail(cab) {
const bndPlugins = pluginsByTarget('boundary_detector', 'shelves'); const bndPlugins = pluginsByTarget('boundary_detector', 'shelves');
const pluginResults = parseBndPluginResults(cab.ai_shelf_boundaries); const pluginResults = parseBndPluginResults(cab.ai_shelf_boundaries);
const pluginIds = Object.keys(pluginResults); const pluginIds = Object.keys(pluginResults);
const sel = (_bnd?.nodeId === cab.id) ? _bnd.selectedPlugin const sel = _bnd?.nodeId === cab.id ? _bnd.selectedPlugin : cab.shelves.length > 0 ? null : (pluginIds[0] ?? null);
: (cab.shelves.length > 0 ? null : pluginIds[0] ?? null);
const selOpts = [ const selOpts = [
`<option value="">None</option>`, `<option value="">None</option>`,
...pluginIds.map(pid => `<option value="${pid}"${sel===pid?' selected':''}>${pid}</option>`), ...pluginIds.map((pid) => `<option value="${pid}"${sel === pid ? ' selected' : ''}>${pid}</option>`),
...(pluginIds.length > 1 ? [`<option value="all"${sel==='all'?' selected':''}>All</option>`] : []), ...(pluginIds.length > 1 ? [`<option value="all"${sel === 'all' ? ' selected' : ''}>All</option>`] : []),
].join(''); ].join('');
return `<div> return `<div>
${vAiProgressBar(stats)} ${vAiProgressBar(stats)}
${hasPhoto ${
hasPhoto
? `<div class="img-wrap" id="bnd-wrap" data-type="cabinet" data-id="${cab.id}"> ? `<div class="img-wrap" id="bnd-wrap" data-type="cabinet" data-id="${cab.id}">
<img id="bnd-img" src="/images/${cab.photo_filename}?t=${Date.now()}" alt=""> <img id="bnd-img" src="/images/${cab.photo_filename}?t=${Date.now()}" alt="">
<canvas id="bnd-canvas"></canvas> <canvas id="bnd-canvas"></canvas>
</div>` </div>`
: `<div class="empty"><div class="ei">📷</div><div>Upload a cabinet photo (📷 in header) to get started</div></div>`} : `<div class="empty"><div class="ei">📷</div><div>Upload a cabinet photo (📷 in header) to get started</div></div>`
}
${hasPhoto ? `<p style="font-size:.72rem;color:#94a3b8;margin-top:8px">Drag lines · Ctrl+Alt+Click to add · Snap to AI guides</p>` : ''} ${hasPhoto ? `<p style="font-size:.72rem;color:#94a3b8;margin-top:8px">Drag lines · Ctrl+Alt+Click to add · Snap to AI guides</p>` : ''}
${hasPhoto ? `<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:6px"> ${
${bounds.length ? `<span style="font-size:.72rem;color:#64748b">${cab.shelves.length} shelf${cab.shelves.length!==1?'s':''} · ${bounds.length} boundar${bounds.length!==1?'ies':'y'}</span>` : ''} hasPhoto
? `<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:6px">
${bounds.length ? `<span style="font-size:.72rem;color:#64748b">${cab.shelves.length} shelf${cab.shelves.length !== 1 ? 's' : ''} · ${bounds.length} boundar${bounds.length !== 1 ? 'ies' : 'y'}</span>` : ''}
<div style="display:flex;gap:4px;align-items:center;flex-wrap:wrap;margin-left:auto"> <div style="display:flex;gap:4px;align-items:center;flex-wrap:wrap;margin-left:auto">
${bndPlugins.map(p => vPluginBtn(p, cab.id, 'cabinets')).join('')} ${bndPlugins.map((p) => vPluginBtn(p, cab.id, 'cabinets')).join('')}
<select data-a="select-bnd-plugin" style="font-size:.72rem;padding:2px 5px;border:1px solid #e2e8f0;border-radius:4px;background:white;color:#475569">${selOpts}</select> <select data-a="select-bnd-plugin" style="font-size:.72rem;padding:2px 5px;border:1px solid #e2e8f0;border-radius:4px;background:white;color:#475569">${selOpts}</select>
</div> </div>
</div>` : ''} </div>`
: ''
}
</div>`; </div>`;
} }
@@ -75,12 +130,11 @@ function vShelfDetail(shelf) {
const bndPlugins = pluginsByTarget('boundary_detector', 'books'); const bndPlugins = pluginsByTarget('boundary_detector', 'books');
const pluginResults = parseBndPluginResults(shelf.ai_book_boundaries); const pluginResults = parseBndPluginResults(shelf.ai_book_boundaries);
const pluginIds = Object.keys(pluginResults); const pluginIds = Object.keys(pluginResults);
const sel = (_bnd?.nodeId === shelf.id) ? _bnd.selectedPlugin const sel = _bnd?.nodeId === shelf.id ? _bnd.selectedPlugin : shelf.books.length > 0 ? null : (pluginIds[0] ?? null);
: (shelf.books.length > 0 ? null : pluginIds[0] ?? null);
const selOpts = [ const selOpts = [
`<option value="">None</option>`, `<option value="">None</option>`,
...pluginIds.map(pid => `<option value="${pid}"${sel===pid?' selected':''}>${pid}</option>`), ...pluginIds.map((pid) => `<option value="${pid}"${sel === pid ? ' selected' : ''}>${pid}</option>`),
...(pluginIds.length > 1 ? [`<option value="all"${sel==='all'?' selected':''}>All</option>`] : []), ...(pluginIds.length > 1 ? [`<option value="all"${sel === 'all' ? ' selected' : ''}>All</option>`] : []),
].join(''); ].join('');
return `<div> return `<div>
${vAiProgressBar(stats)} ${vAiProgressBar(stats)}
@@ -91,72 +145,115 @@ function vShelfDetail(shelf) {
</div> </div>
<p style="font-size:.72rem;color:#94a3b8;margin-top:8px">Drag lines · Ctrl+Alt+Click to add · Snap to AI guides</p> <p style="font-size:.72rem;color:#94a3b8;margin-top:8px">Drag lines · Ctrl+Alt+Click to add · Snap to AI guides</p>
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:6px"> <div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:6px">
${bounds.length ? `<span style="font-size:.72rem;color:#64748b">${shelf.books.length} book${shelf.books.length!==1?'s':''} · ${bounds.length} boundary${bounds.length!==1?'ies':''}</span>` : ''} ${bounds.length ? `<span style="font-size:.72rem;color:#64748b">${shelf.books.length} book${shelf.books.length !== 1 ? 's' : ''} · ${bounds.length} boundary${bounds.length !== 1 ? 'ies' : ''}</span>` : ''}
<div style="display:flex;gap:4px;align-items:center;flex-wrap:wrap;margin-left:auto"> <div style="display:flex;gap:4px;align-items:center;flex-wrap:wrap;margin-left:auto">
${bndPlugins.map(p => vPluginBtn(p, shelf.id, 'shelves')).join('')} ${bndPlugins.map((p) => vPluginBtn(p, shelf.id, 'shelves')).join('')}
<select data-a="select-bnd-plugin" style="font-size:.72rem;padding:2px 5px;border:1px solid #e2e8f0;border-radius:4px;background:white;color:#475569">${selOpts}</select> <select data-a="select-bnd-plugin" style="font-size:.72rem;padding:2px 5px;border:1px solid #e2e8f0;border-radius:4px;background:white;color:#475569">${selOpts}</select>
</div> </div>
</div> </div>
</div>`; </div>`;
} }
// ── AI blocks helpers ─────────────────────────────────────────────────────────
function parseAiBlocks(json) {
if (!json) return [];
try {
return JSON.parse(json) || [];
} catch {
return [];
}
}
function aiBlocksShown(b) {
if (b.id in _aiBlocksVisible) return _aiBlocksVisible[b.id];
return b.identification_status !== 'user_approved';
}
function vAiBlock(block, bookId) {
const score = typeof block.score === 'number' ? (block.score * 100).toFixed(0) + '%' : '';
const sources = (block.sources || []).join(', ');
const fields = [
['title', block.title],
['author', block.author],
['year', block.year],
['isbn', block.isbn],
['publisher', block.publisher],
].filter(([, v]) => v && v.trim());
const rows = fields
.map(
([k, v]) =>
`<div style="font-size:.78rem;color:#475569"><span style="color:#94a3b8;min-width:4.5rem;display:inline-block">${k}</span> ${esc(v)}</div>`,
)
.join('');
const blockData = esc(JSON.stringify(block));
return `<div class="ai-block" data-a="apply-ai-block" data-id="${bookId}" data-block="${blockData}"
style="cursor:pointer;border:1px solid #e2e8f0;border-radius:6px;padding:8px 10px;margin-bottom:6px;background:#f8fafc">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;flex-wrap:wrap">
${score ? `<span style="background:#dbeafe;color:#1e40af;border-radius:4px;padding:1px 6px;font-size:.72rem;font-weight:600">${score}</span>` : ''}
${sources ? `<span style="font-size:.7rem;color:#64748b">${esc(sources)}</span>` : ''}
</div>
${rows}
</div>`;
}
// ── Book detail ────────────────────────────────────────────────────────────── // ── Book detail ──────────────────────────────────────────────────────────────
function vBookDetail(b) { function vBookDetail(b) {
const [sc, sl] = _STATUS_BADGE[b.identification_status] ?? _STATUS_BADGE.unidentified; const [sc, sl] = _STATUS_BADGE[b.identification_status] ?? _STATUS_BADGE.unidentified;
const recognizers = pluginsByCategory('text_recognizer'); const isLoading_ = isLoading('identify', b.id);
const identifiers = pluginsByCategory('book_identifier'); const blocks = parseAiBlocks(b.ai_blocks);
const searchers = pluginsByCategory('archive_searcher'); const shown = aiBlocksShown(b);
const hasRawText = !!(b.raw_text || '').trim(); const spineUrl = `/api/books/${b.id}/spine?t=${Date.now()}`;
const titleUrl = b.image_filename ? `/images/${b.image_filename}` : '';
return `<div class="book-panel"> return `<div class="book-panel">
<div> <div>
<div class="book-img-label">Spine</div> <div class="book-img-label">Spine</div>
<div class="book-img-box"><img src="/api/books/${b.id}/spine?t=${Date.now()}" alt="" <div class="book-img-box">
onerror="this.style.display='none'"></div> <img src="${spineUrl}" alt="" style="cursor:pointer"
${b.image_filename data-a="open-img-popup" data-src="${spineUrl}"
onerror="this.style.display='none'">
</div>
${
titleUrl
? `<div class="book-img-label">Title page</div> ? `<div class="book-img-label">Title page</div>
<div class="book-img-box"><img src="/images/${b.image_filename}" alt=""></div>` <div class="book-img-box">
: ''} <img src="${titleUrl}" alt="" style="cursor:pointer"
data-a="open-img-popup" data-src="${titleUrl}">
</div>`
: ''
}
</div> </div>
<div> <div>
<div class="card"> <div class="card">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap">
<span class="sbadge ${sc}" style="font-size:.7rem;padding:2px 7px">${sl}</span> <span class="sbadge ${sc}" style="font-size:.7rem;padding:2px 7px">${sl}</span>
<span style="font-size:.72rem;color:#64748b">${b.identification_status ?? 'unidentified'}</span> <span style="font-size:.72rem;color:#64748b">${b.identification_status ?? 'unidentified'}</span>
${b.analyzed_at ? `<span style="font-size:.68rem;color:#94a3b8;margin-left:auto">Identified ${b.analyzed_at.slice(0,10)}</span>` : ''} ${b.analyzed_at ? `<span style="font-size:.68rem;color:#94a3b8">Identified ${b.analyzed_at.slice(0, 10)}</span>` : ''}
<button class="btn btn-s" style="padding:2px 10px;font-size:.78rem;min-height:0;margin-left:auto"
data-a="identify-book" data-id="${b.id}"${isLoading_ ? ' disabled' : ''}>
${isLoading_ ? '⏳ Identifying…' : '🔍 Identify'}
</button>
</div> </div>
<div class="fgroup"> ${
<label class="flabel" style="display:flex;align-items:center;gap:6px;flex-wrap:wrap"> blocks.length
Recognition ? `<div style="margin-bottom:8px">
${recognizers.map(p => vPluginBtn(p, b.id, 'books')).join('')} <div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">
${identifiers.map(p => vPluginBtn(p, b.id, 'books', !hasRawText)).join('')} <span style="font-size:.72rem;font-weight:600;color:#475569">AI Results (${blocks.length})</span>
</label> <button class="btn btn-s" style="padding:1px 7px;font-size:.72rem;min-height:0;margin-left:auto"
<textarea class="finput" id="d-raw-text" style="height:72px;font-family:monospace;font-size:.8rem" readonly>${esc(b.raw_text ?? '')}</textarea> data-a="toggle-ai-blocks" data-id="${b.id}">${shown ? 'Hide' : 'Show'}</button>
</div> </div>
${searchers.length ? `<div class="fgroup"> ${shown ? blocks.map((bl) => vAiBlock(bl, b.id)).join('') : ''}
<label class="flabel" style="display:flex;align-items:center;gap:6px;flex-wrap:wrap"> </div>`
Archives : ''
${searchers.map(p => vPluginBtn(p, b.id, 'books')).join('')} }
</label> <div class="fgroup"><label class="flabel">Title</label>
</div>` : ''}
<div class="fgroup">
${candidateSugRows(b, 'title', 'd-title')}
<label class="flabel">Title</label>
<input class="finput" id="d-title" value="${esc(b.title ?? '')}"></div> <input class="finput" id="d-title" value="${esc(b.title ?? '')}"></div>
<div class="fgroup"> <div class="fgroup"><label class="flabel">Author</label>
${candidateSugRows(b, 'author', 'd-author')}
<label class="flabel">Author</label>
<input class="finput" id="d-author" value="${esc(b.author ?? '')}"></div> <input class="finput" id="d-author" value="${esc(b.author ?? '')}"></div>
<div class="fgroup"> <div class="fgroup"><label class="flabel">Year</label>
${candidateSugRows(b, 'year', 'd-year')}
<label class="flabel">Year</label>
<input class="finput" id="d-year" value="${esc(b.year ?? '')}" inputmode="numeric"></div> <input class="finput" id="d-year" value="${esc(b.year ?? '')}" inputmode="numeric"></div>
<div class="fgroup"> <div class="fgroup"><label class="flabel">ISBN</label>
${candidateSugRows(b, 'isbn', 'd-isbn')}
<label class="flabel">ISBN</label>
<input class="finput" id="d-isbn" value="${esc(b.isbn ?? '')}" inputmode="numeric"></div> <input class="finput" id="d-isbn" value="${esc(b.isbn ?? '')}" inputmode="numeric"></div>
<div class="fgroup"> <div class="fgroup"><label class="flabel">Publisher</label>
${candidateSugRows(b, 'publisher', 'd-pub')}
<label class="flabel">Publisher</label>
<input class="finput" id="d-pub" value="${esc(b.publisher ?? '')}"></div> <input class="finput" id="d-pub" value="${esc(b.publisher ?? '')}"></div>
<div class="fgroup"><label class="flabel">Notes</label> <div class="fgroup"><label class="flabel">Notes</label>
<textarea class="finput" id="d-notes">${esc(b.notes ?? '')}</textarea></div> <textarea class="finput" id="d-notes">${esc(b.notes ?? '')}</textarea></div>

View File

@@ -12,54 +12,84 @@
* Provides: attachEditables(), initSortables() * Provides: attachEditables(), initSortables()
*/ */
/* exported attachEditables, initSortables */
// ── SortableJS instances (destroyed and recreated on each render) ───────────── // ── SortableJS instances (destroyed and recreated on each render) ─────────────
let _sortables = []; let _sortables = [];
// ── Inline name editing ────────────────────────────────────────────────────── // ── Inline name editing ──────────────────────────────────────────────────────
function attachEditables() { function attachEditables() {
document.querySelectorAll('[contenteditable=true]').forEach(el => { document.querySelectorAll('[contenteditable=true]').forEach((el) => {
el.dataset.orig = el.textContent.trim(); el.dataset.orig = el.textContent.trim();
el.addEventListener('keydown', e => { el.addEventListener('keydown', (e) => {
if (e.key==='Enter') { e.preventDefault(); el.blur(); } if (e.key === 'Enter') {
if (e.key==='Escape') { el.textContent=el.dataset.orig; el.blur(); } e.preventDefault();
el.blur();
}
if (e.key === 'Escape') {
el.textContent = el.dataset.orig;
el.blur();
}
e.stopPropagation(); e.stopPropagation();
}); });
el.addEventListener('blur', async () => { el.addEventListener('blur', async () => {
const val = el.textContent.trim(); const val = el.textContent.trim();
if (!val||val===el.dataset.orig) { if(!val) el.textContent=el.dataset.orig; return; } if (!val || val === el.dataset.orig) {
const newName = val.replace(/^[🏠📚]\s*/u,'').trim(); if (!val) el.textContent = el.dataset.orig;
const {type, id} = el.dataset; return;
const url = {room:`/api/rooms/${id}`,cabinet:`/api/cabinets/${id}`,shelf:`/api/shelves/${id}`}[type]; }
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; if (!url) return;
try { try {
await req('PUT', url, {name: newName}); await req('PUT', url, { name: newName });
el.dataset.orig = el.textContent.trim(); el.dataset.orig = el.textContent.trim();
walkTree(n=>{ if(n.id===id) n.name=newName; }); walkTree((n) => {
if (n.id === id) n.name = newName;
});
// Update sidebar label if editing from header (sidebar has non-editable nname spans) // Update sidebar label if editing from header (sidebar has non-editable nname spans)
const sideLabel = document.querySelector(`.node[data-id="${id}"] .nname`); const sideLabel = document.querySelector(`.node[data-id="${id}"] .nname`);
if (sideLabel && sideLabel !== el) { if (sideLabel && sideLabel !== el) {
const prefix = type==='room' ? '🏠 ' : type==='cabinet' ? '📚 ' : ''; const prefix = type === 'room' ? '🏠 ' : type === 'cabinet' ? '📚 ' : '';
sideLabel.textContent = prefix + newName; sideLabel.textContent = prefix + newName;
} }
} catch(err) { el.textContent=el.dataset.orig; toast('Rename failed: '+err.message); } } catch (err) {
el.textContent = el.dataset.orig;
toast('Rename failed: ' + err.message);
}
}); });
el.addEventListener('click', e=>e.stopPropagation()); el.addEventListener('click', (e) => e.stopPropagation());
}); });
} }
// ── SortableJS drag-and-drop ───────────────────────────────────────────────── // ── SortableJS drag-and-drop ─────────────────────────────────────────────────
function initSortables() { function initSortables() {
_sortables.forEach(s=>{ try{s.destroy();}catch(_){} }); _sortables.forEach((s) => {
try {
s.destroy();
} catch (_) {
// ignore destroy errors on stale instances
}
});
_sortables = []; _sortables = [];
document.querySelectorAll('.sortable-list').forEach(el => { document.querySelectorAll('.sortable-list').forEach((el) => {
const type = el.dataset.type; const type = el.dataset.type;
_sortables.push(Sortable.create(el, { _sortables.push(
handle:'.drag-h', animation:120, ghostClass:'drag-ghost', Sortable.create(el, {
handle: '.drag-h',
animation: 120,
ghostClass: 'drag-ghost',
onEnd: async () => { onEnd: async () => {
const ids = [...el.querySelectorAll(':scope > .node')].map(n=>n.dataset.id); const ids = [...el.querySelectorAll(':scope > .node')].map((n) => n.dataset.id);
try { await req('PATCH',`/api/${type}/reorder`,{ids}); } try {
catch(err) { toast('Reorder failed'); await loadTree(); } await req('PATCH', `/api/${type}/reorder`, { ids });
} catch (_err) {
toast('Reorder failed');
await loadTree();
}
}, },
})); }),
);
}); });
} }

View File

@@ -12,7 +12,7 @@
* Depends on: S, _bnd, _batchState, _photoQueue (state.js); * Depends on: S, _bnd, _batchState, _photoQueue (state.js);
* req (api.js); toast, isDesktop (helpers.js); * req (api.js); toast, isDesktop (helpers.js);
* walkTree, removeNode, findNode, parseBounds (tree-render.js / * walkTree, removeNode, findNode, parseBounds (tree-render.js /
* canvas-boundary.js); render, renderDetail, startBatchPolling * canvas-boundary.js); render, renderDetail, connectBatchWs
* (init.js); startCropMode (canvas-crop.js); * (init.js); startCropMode (canvas-crop.js);
* triggerPhoto, collectQueueBooks, renderPhotoQueue (photo.js); * triggerPhoto, collectQueueBooks, renderPhotoQueue (photo.js);
* drawBnd (canvas-boundary.js) * drawBnd (canvas-boundary.js)
@@ -22,53 +22,61 @@
// ── Accordion helpers ──────────────────────────────────────────────────────── // ── Accordion helpers ────────────────────────────────────────────────────────
function getSiblingIds(id, type) { function getSiblingIds(id, type) {
if (!S.tree) return []; if (!S.tree) return [];
if (type === 'room') return S.tree.filter(r => r.id !== id).map(r => r.id); if (type === 'room') return S.tree.filter((r) => r.id !== id).map((r) => r.id);
for (const r of S.tree) { for (const r of S.tree) {
if (type === 'cabinet' && r.cabinets.some(c => c.id === id)) if (type === 'cabinet' && r.cabinets.some((c) => c.id === id))
return r.cabinets.filter(c => c.id !== id).map(c => c.id); return r.cabinets.filter((c) => c.id !== id).map((c) => c.id);
for (const c of r.cabinets) { for (const c of r.cabinets) {
if (type === 'shelf' && c.shelves.some(s => s.id === id)) if (type === 'shelf' && c.shelves.some((s) => s.id === id))
return c.shelves.filter(s => s.id !== id).map(s => s.id); return c.shelves.filter((s) => s.id !== id).map((s) => s.id);
} }
} }
return []; return [];
} }
function accordionExpand(id, type) { function accordionExpand(id, type) {
if (!isDesktop()) getSiblingIds(id, type).forEach(sid => S.expanded.delete(sid)); if (!isDesktop()) getSiblingIds(id, type).forEach((sid) => S.expanded.delete(sid));
S.expanded.add(id); S.expanded.add(id);
} }
// ── Event delegation ───────────────────────────────────────────────────────── // ── Event delegation ─────────────────────────────────────────────────────────
document.getElementById('app').addEventListener('click', async e => { document.getElementById('app').addEventListener('click', async (e) => {
const el = e.target.closest('[data-a]'); const el = e.target.closest('[data-a]');
if (!el) return; if (!el) return;
const d = el.dataset; const d = el.dataset;
try { await handle(d.a, d, e); } try {
catch(err) { toast('Error: '+err.message); } await handle(d.a, d, e);
} catch (err) {
toast('Error: ' + err.message);
}
}); });
document.getElementById('app').addEventListener('change', async e => { document.getElementById('app').addEventListener('change', async (e) => {
const el = e.target.closest('[data-a]'); const el = e.target.closest('[data-a]');
if (!el) return; if (!el) return;
const d = el.dataset; const d = el.dataset;
try { await handle(d.a, d, e); } try {
catch(err) { toast('Error: '+err.message); } await handle(d.a, d, e);
} catch (err) {
toast('Error: ' + err.message);
}
}); });
// Photo queue overlay is outside #app so needs its own listener // Photo queue overlay is outside #app so needs its own listener
document.getElementById('photo-queue-overlay').addEventListener('click', async e => { document.getElementById('photo-queue-overlay').addEventListener('click', async (e) => {
const el = e.target.closest('[data-a]'); const el = e.target.closest('[data-a]');
if (!el) return; if (!el) return;
const d = el.dataset; const d = el.dataset;
try { await handle(d.a, d, e); } try {
catch(err) { toast('Error: ' + err.message); } await handle(d.a, d, e);
} catch (err) {
toast('Error: ' + err.message);
}
}); });
// ── Action dispatcher ──────────────────────────────────────────────────────── // ── Action dispatcher ────────────────────────────────────────────────────────
async function handle(action, d, e) { async function handle(action, d, e) {
switch (action) { switch (action) {
case 'select': { case 'select': {
// Ignore if the click hit a button or editable inside the row // Ignore if the click hit a button or editable inside the row
if (e?.target?.closest('button,[contenteditable]')) return; if (e?.target?.closest('button,[contenteditable]')) return;
@@ -80,14 +88,16 @@ async function handle(action, d, e) {
} }
break; break;
} }
S.selected = {type: d.type, id: d.id}; S.selected = { type: d.type, id: d.id };
S._loading = {}; S._loading = {};
render(); break; render();
break;
} }
case 'deselect': { case 'deselect': {
S.selected = null; S.selected = null;
render(); break; render();
break;
} }
case 'toggle': { case 'toggle': {
@@ -95,95 +105,135 @@ async function handle(action, d, e) {
// Mobile: expand-only (no collapse to avoid accidental mistaps) // Mobile: expand-only (no collapse to avoid accidental mistaps)
accordionExpand(d.id, d.type); accordionExpand(d.id, d.type);
} else { } else {
if (S.expanded.has(d.id)) { S.expanded.delete(d.id); } if (S.expanded.has(d.id)) {
else { S.expanded.add(d.id); } S.expanded.delete(d.id);
} else {
S.expanded.add(d.id);
} }
render(); break; }
render();
break;
} }
// Rooms // Rooms
case 'add-room': { case 'add-room': {
const r = await req('POST','/api/rooms'); const r = await req('POST', '/api/rooms');
if (!S.tree) S.tree=[]; if (!S.tree) S.tree = [];
S.tree.push({...r, cabinets:[]}); S.tree.push({ ...r, cabinets: [] });
S.expanded.add(r.id); render(); break; S.expanded.add(r.id);
render();
break;
} }
case 'del-room': { case 'del-room': {
if (!confirm('Delete room and all contents?')) break; if (!confirm('Delete room and all contents?')) break;
await req('DELETE',`/api/rooms/${d.id}`); await req('DELETE', `/api/rooms/${d.id}`);
removeNode('room',d.id); removeNode('room', d.id);
if (S.selected?.id===d.id) S.selected=null; if (S.selected?.id === d.id) S.selected = null;
render(); break; render();
break;
} }
// Cabinets // Cabinets
case 'add-cabinet': { case 'add-cabinet': {
const c = await req('POST',`/api/rooms/${d.id}/cabinets`); 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.tree.forEach((r) => {
S.expanded.add(d.id); render(); break; // expand parent room if (r.id === d.id) r.cabinets.push({ ...c, shelves: [] });
});
S.expanded.add(d.id);
render();
break; // expand parent room
} }
case 'del-cabinet': { case 'del-cabinet': {
if (!confirm('Delete cabinet and all contents?')) break; if (!confirm('Delete cabinet and all contents?')) break;
await req('DELETE',`/api/cabinets/${d.id}`); await req('DELETE', `/api/cabinets/${d.id}`);
removeNode('cabinet',d.id); removeNode('cabinet', d.id);
if (S.selected?.id===d.id) S.selected=null; if (S.selected?.id === d.id) S.selected = null;
render(); break; render();
break;
} }
// Shelves // Shelves
case 'add-shelf': { case 'add-shelf': {
const cab = findNode(d.id); const cab = findNode(d.id);
const prevCount = cab ? cab.shelves.length : 0; const prevCount = cab ? cab.shelves.length : 0;
const s = await req('POST',`/api/cabinets/${d.id}/shelves`); 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:[]}); })); S.tree.forEach((r) =>
r.cabinets.forEach((c) => {
if (c.id === d.id) c.shelves.push({ ...s, books: [] });
}),
);
if (prevCount > 0) { if (prevCount > 0) {
// Split last segment in half to make room for new shelf // Split last segment in half to make room for new shelf
const bounds = parseBounds(cab.shelf_boundaries); const bounds = parseBounds(cab.shelf_boundaries);
const lastStart = bounds.length ? bounds[bounds.length-1] : 0.0; const lastStart = bounds.length ? bounds[bounds.length - 1] : 0.0;
const newBound = Math.round((lastStart + 1.0) / 2 * 10000) / 10000; const newBound = Math.round(((lastStart + 1.0) / 2) * 10000) / 10000;
const newBounds = [...bounds, newBound]; const newBounds = [...bounds, newBound];
await req('PATCH', `/api/cabinets/${d.id}/boundaries`, {boundaries: newBounds}); 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.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 S.expanded.add(d.id);
render();
break; // expand parent cabinet
} }
case 'del-shelf': { case 'del-shelf': {
if (!confirm('Delete shelf and all books?')) break; if (!confirm('Delete shelf and all books?')) break;
await req('DELETE',`/api/shelves/${d.id}`); await req('DELETE', `/api/shelves/${d.id}`);
removeNode('shelf',d.id); removeNode('shelf', d.id);
if (S.selected?.id===d.id) S.selected=null; if (S.selected?.id === d.id) S.selected = null;
render(); break; render();
break;
} }
// Books // Books
case 'add-book': { case 'add-book': {
const shelf = findNode(d.id); const shelf = findNode(d.id);
const prevCount = shelf ? shelf.books.length : 0; const prevCount = shelf ? shelf.books.length : 0;
const b = await req('POST',`/api/shelves/${d.id}/books`); 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); }))); S.tree.forEach((r) =>
r.cabinets.forEach((c) =>
c.shelves.forEach((s) => {
if (s.id === d.id) s.books.push(b);
}),
),
);
if (prevCount > 0) { if (prevCount > 0) {
// Split last segment in half to make room for new book // Split last segment in half to make room for new book
const bounds = parseBounds(shelf.book_boundaries); const bounds = parseBounds(shelf.book_boundaries);
const lastStart = bounds.length ? bounds[bounds.length-1] : 0.0; const lastStart = bounds.length ? bounds[bounds.length - 1] : 0.0;
const newBound = Math.round((lastStart + 1.0) / 2 * 10000) / 10000; const newBound = Math.round(((lastStart + 1.0) / 2) * 10000) / 10000;
const newBounds = [...bounds, newBound]; const newBounds = [...bounds, newBound];
await req('PATCH', `/api/shelves/${d.id}/boundaries`, {boundaries: newBounds}); 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.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 S.expanded.add(d.id);
render();
break; // expand parent shelf
} }
case 'del-book': { case 'del-book': {
if (!confirm('Delete this book?')) break; if (!confirm('Delete this book?')) break;
await req('DELETE',`/api/books/${d.id}`); await req('DELETE', `/api/books/${d.id}`);
removeNode('book',d.id); removeNode('book', d.id);
if (S.selected?.id===d.id) S.selected=null; if (S.selected?.id === d.id) S.selected = null;
render(); break; render();
break;
} }
case 'del-book-confirm': { case 'del-book-confirm': {
if (!confirm('Delete this book?')) break; if (!confirm('Delete this book?')) break;
await req('DELETE',`/api/books/${d.id}`); await req('DELETE', `/api/books/${d.id}`);
removeNode('book',d.id); removeNode('book', d.id);
S.selected=null; render(); break; S.selected = null;
render();
break;
} }
case 'save-book': { case 'save-book': {
const data = { const data = {
@@ -194,69 +244,190 @@ async function handle(action, d, e) {
publisher: document.getElementById('d-pub')?.value || '', publisher: document.getElementById('d-pub')?.value || '',
notes: document.getElementById('d-notes')?.value || '', notes: document.getElementById('d-notes')?.value || '',
}; };
const res = await req('PUT',`/api/books/${d.id}`,data); const res = await req('PUT', `/api/books/${d.id}`, data);
walkTree(n => { walkTree((n) => {
if (n.id === d.id) { if (n.id === d.id) {
Object.assign(n, data); Object.assign(n, data);
n.ai_title = data.title; n.ai_author = data.author; n.ai_year = data.year; n.ai_title = data.title;
n.ai_isbn = data.isbn; n.ai_publisher = data.publisher; 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; n.identification_status = res.identification_status ?? n.identification_status;
} }
}); });
toast('Saved'); render(); break; toast('Saved');
render();
break;
} }
case 'run-plugin': { case 'run-plugin': {
const key = `${d.plugin}:${d.id}`; const key = `${d.plugin}:${d.id}`;
S._loading[key] = true; renderDetail(); // Capture any unsaved field edits before the first renderDetail() overwrites them.
if (d.etype === 'books') {
walkTree((n) => {
if (n.id === d.id) {
n.title = document.getElementById('d-title')?.value ?? n.title;
n.author = document.getElementById('d-author')?.value ?? n.author;
n.year = document.getElementById('d-year')?.value ?? n.year;
n.isbn = document.getElementById('d-isbn')?.value ?? n.isbn;
n.publisher = document.getElementById('d-pub')?.value ?? n.publisher;
n.notes = document.getElementById('d-notes')?.value ?? n.notes;
}
});
}
S._loading[key] = true;
renderDetail();
try { try {
const res = await req('POST', `/api/${d.etype}/${d.id}/plugin/${d.plugin}`); const res = await req('POST', `/api/${d.etype}/${d.id}/plugin/${d.plugin}`);
walkTree(n => { if (n.id === d.id) Object.assign(n, res); }); walkTree((n) => {
} catch(err) { toast(`${d.plugin} failed: ${err.message}`); } if (n.id !== d.id) return;
delete S._loading[key]; renderDetail(); if (d.etype === 'books') {
// Server response must not overwrite user edits captured above.
const saved = {
title: n.title,
author: n.author,
year: n.year,
isbn: n.isbn,
publisher: n.publisher,
notes: n.notes,
};
Object.assign(n, res);
Object.assign(n, saved);
} else {
Object.assign(n, res);
}
});
} catch (err) {
toast(`${d.plugin} failed: ${err.message}`);
}
delete S._loading[key];
renderDetail();
break; break;
} }
case 'select-bnd-plugin': { case 'select-bnd-plugin': {
if (_bnd) { _bnd.selectedPlugin = e.target.value || null; drawBnd(); } if (_bnd) {
_bnd.selectedPlugin = e.target.value || null;
drawBnd();
}
break; break;
} }
case 'accept-field': { case 'accept-field': {
const inp = document.getElementById(d.input); const inp = document.getElementById(d.input);
if (inp) inp.value = d.value; if (inp) inp.value = d.value;
walkTree(n => { if (n.id === d.id) n[d.field] = d.value; }); walkTree((n) => {
renderDetail(); break; if (n.id === d.id) n[d.field] = d.value;
});
renderDetail();
break;
} }
case 'dismiss-field': { case 'dismiss-field': {
const res = await req('POST', `/api/books/${d.id}/dismiss-field`, {field: d.field, value: d.value || ''}); const res = await req('POST', `/api/books/${d.id}/dismiss-field`, { field: d.field, value: d.value || '' });
walkTree(n => { walkTree((n) => {
if (n.id === d.id) { if (n.id === d.id) {
n.candidates = JSON.stringify(res.candidates || []); n.candidates = JSON.stringify(res.candidates || []);
if (!d.value) n[`ai_${d.field}`] = n[d.field] || ''; if (!d.value) n[`ai_${d.field}`] = n[d.field] || '';
n.identification_status = res.identification_status ?? n.identification_status; n.identification_status = res.identification_status ?? n.identification_status;
} }
}); });
renderDetail(); break; renderDetail();
break;
}
case 'identify-book': {
const key = `identify:${d.id}`;
S._loading[key] = true;
renderDetail();
try {
const res = await req('POST', `/api/books/${d.id}/identify`);
walkTree((n) => {
if (n.id !== d.id) return;
const saved = {
title: n.title,
author: n.author,
year: n.year,
isbn: n.isbn,
publisher: n.publisher,
notes: n.notes,
};
Object.assign(n, res);
Object.assign(n, saved);
});
} catch (err) {
toast(`Identify failed: ${err.message}`);
}
delete S._loading[key];
renderDetail();
break;
}
case 'toggle-ai-blocks': {
walkTree((n) => {
if (n.id === d.id) _aiBlocksVisible[d.id] = !aiBlocksShown(n);
});
renderDetail();
break;
}
case 'apply-ai-block': {
let block;
try {
block = JSON.parse(d.block);
} catch {
break;
}
const fieldMap = { title: 'd-title', author: 'd-author', year: 'd-year', isbn: 'd-isbn', publisher: 'd-pub' };
for (const [field, inputId] of Object.entries(fieldMap)) {
const v = (block[field] || '').trim();
if (!v) continue;
const inp = document.getElementById(inputId);
if (inp) inp.value = v;
walkTree((n) => {
if (n.id === d.id) n[field] = v;
});
}
renderDetail();
break;
} }
case 'batch-start': { case 'batch-start': {
const res = await req('POST', '/api/batch'); const res = await req('POST', '/api/batch');
if (res.already_running) { toast('Batch already running'); break; } if (res.already_running) {
if (!res.started) { toast('No unidentified books'); break; } toast(res.added > 0 ? `Added ${res.added} book(s) to batch` : 'Batch already running');
_batchState = {running: true, total: res.total, done: 0, errors: 0, current: ''}; if (!_batchWs) connectBatchWs();
startBatchPolling(); renderDetail(); break; break;
}
if (!res.started) {
toast('No unidentified books');
break;
}
connectBatchWs();
renderDetail();
break;
}
case 'open-img-popup': {
const popup = document.getElementById('img-popup');
if (!popup) break;
document.getElementById('img-popup-img').src = d.src;
popup.classList.add('open');
break;
} }
// Photo // Photo
case 'photo': triggerPhoto(d.type, d.id); break; case 'photo':
triggerPhoto(d.type, d.id);
break;
// Crop // Crop
case 'crop-start': startCropMode(d.type, d.id); break; case 'crop-start':
startCropMode(d.type, d.id);
break;
// Photo queue // Photo queue
case 'photo-queue-start': { case 'photo-queue-start': {
const node = findNode(d.id); const node = findNode(d.id);
if (!node) break; if (!node) break;
const books = collectQueueBooks(node, d.type); const books = collectQueueBooks(node, d.type);
if (!books.length) { toast('No unidentified books'); break; } if (!books.length) {
_photoQueue = {books, index: 0, processing: false}; toast('No unidentified books');
break;
}
_photoQueue = { books, index: 0, processing: false };
renderPhotoQueue(); renderPhotoQueue();
break; break;
} }
@@ -278,6 +449,5 @@ async function handle(action, d, e) {
renderPhotoQueue(); renderPhotoQueue();
break; break;
} }
} }
} }

View File

@@ -6,16 +6,25 @@
* Provides: esc(), toast(), isDesktop() * Provides: esc(), toast(), isDesktop()
*/ */
/* exported esc, toast, isDesktop */
// ── Helpers ───────────────────────────────────────────────────────────────── // ── Helpers ─────────────────────────────────────────────────────────────────
function esc(s) { function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
} }
function toast(msg, dur = 2800) { function toast(msg, dur = 2800) {
const el = document.getElementById('toast'); const el = document.getElementById('toast');
el.textContent = msg; el.classList.add('on'); el.textContent = msg;
el.classList.add('on');
clearTimeout(toast._t); clearTimeout(toast._t);
toast._t = setTimeout(() => el.classList.remove('on'), dur); toast._t = setTimeout(() => el.classList.remove('on'), dur);
} }
function isDesktop() { return window.innerWidth >= 768; } function isDesktop() {
return window.innerWidth >= 768;
}

View File

@@ -10,16 +10,18 @@
* renderDetail() does a cheaper in-place update of the right panel only, * 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. * used during plugin runs and field edits to avoid re-rendering the sidebar.
* *
* Depends on: S, _plugins, _batchState, _batchPollTimer (state.js); * Depends on: S, _plugins, _batchState, _batchWs (state.js);
* req, toast (api.js / helpers.js); isDesktop (helpers.js); * req, toast (api.js / helpers.js); isDesktop (helpers.js);
* vApp, vDetailBody, mainTitle, mainHeaderBtns, vBatchBtn * vApp, vDetailBody, mainTitle, mainHeaderBtns, vBatchBtn
* (tree-render.js / detail-render.js); * (tree-render.js / detail-render.js);
* attachEditables, initSortables (editing.js); * attachEditables, initSortables (editing.js);
* setupDetailCanvas (canvas-boundary.js) * setupDetailCanvas (canvas-boundary.js)
* Provides: render(), renderDetail(), loadConfig(), startBatchPolling(), * Provides: render(), renderDetail(), loadConfig(), connectBatchWs(),
* loadTree() * loadTree()
*/ */
/* exported render, renderDetail, connectBatchWs, connectAiLogWs, loadTree */
// ── Full re-render ──────────────────────────────────────────────────────────── // ── Full re-render ────────────────────────────────────────────────────────────
function render() { function render() {
if (document.activeElement?.contentEditable === 'true') return; if (document.activeElement?.contentEditable === 'true') return;
@@ -37,11 +39,9 @@ function renderDetail() {
const body = document.getElementById('main-body'); const body = document.getElementById('main-body');
if (body) body.innerHTML = vDetailBody(); if (body) body.innerHTML = vDetailBody();
const t = document.getElementById('main-title'); const t = document.getElementById('main-title');
if (t) t.innerHTML = mainTitle(); // innerHTML: mainTitle() returns an HTML span if (t) t.innerHTML = mainTitle(); // innerHTML: mainTitle() returns an HTML string
const hb = document.getElementById('main-hdr-btns'); const hb = document.getElementById('main-hdr-btns');
if (hb) hb.innerHTML = mainHeaderBtns(); 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 attachEditables(); // pick up the new editable span in the header
requestAnimationFrame(setupDetailCanvas); requestAnimationFrame(setupDetailCanvas);
} }
@@ -49,34 +49,111 @@ function renderDetail() {
// ── Data loading ────────────────────────────────────────────────────────────── // ── Data loading ──────────────────────────────────────────────────────────────
async function loadConfig() { async function loadConfig() {
try { try {
const cfg = await req('GET','/api/config'); const cfg = await req('GET', '/api/config');
window._grabPx = cfg.boundary_grab_px ?? 14; window._grabPx = cfg.boundary_grab_px ?? 14;
window._confidenceThreshold = cfg.confidence_threshold ?? 0.8; window._confidenceThreshold = cfg.confidence_threshold ?? 0.8;
window._aiLogMax = cfg.ai_log_max_entries ?? 100;
_plugins = cfg.plugins || []; _plugins = cfg.plugins || [];
} catch { window._grabPx = 14; window._confidenceThreshold = 0.8; } } catch {
window._grabPx = 14;
window._confidenceThreshold = 0.8;
window._aiLogMax = 100;
}
} }
function startBatchPolling() { function connectBatchWs() {
if (_batchPollTimer) clearInterval(_batchPollTimer); if (_batchWs) {
_batchPollTimer = setInterval(async () => { _batchWs.close();
try { _batchWs = null;
const st = await req('GET', '/api/batch/status'); }
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${location.host}/ws/batch`);
_batchWs = ws;
ws.onmessage = async (ev) => {
const st = JSON.parse(ev.data);
_batchState = st; _batchState = st;
const bb = document.getElementById('main-hdr-batch'); const bb = document.getElementById('main-hdr-batch');
if (bb) bb.innerHTML = vBatchBtn(); if (bb) bb.innerHTML = vBatchBtn();
if (!st.running) { if (!st.running) {
clearInterval(_batchPollTimer); _batchPollTimer = null; ws.close();
_batchWs = null;
toast(`Batch: ${st.done} done, ${st.errors} errors`); toast(`Batch: ${st.done} done, ${st.errors} errors`);
await loadTree(); await loadTree();
} }
} catch { /* ignore poll errors */ } };
}, 2000); ws.onerror = () => {
_batchWs = null;
};
ws.onclose = () => {
_batchWs = null;
};
}
function connectAiLogWs() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${location.host}/ws/ai-log`);
_aiLogWs = ws;
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'snapshot') {
_aiLog = msg.entries || [];
} else if (msg.type === 'update') {
const entry = msg.entry;
const idx = _aiLog.findIndex((e) => e.id === entry.id);
if (idx >= 0) {
_aiLog[idx] = entry;
} else {
_aiLog.push(entry);
const max = window._aiLogMax ?? 100;
if (_aiLog.length > max) _aiLog.splice(0, _aiLog.length - max);
}
} else if (msg.type === 'entity_update') {
const etype = msg.entity_type.slice(0, -1); // "books" → "book"
walkTree((n) => {
if (n.id === msg.entity_id) Object.assign(n, msg.data);
});
if (S.selected && S.selected.type === etype && S.selected.id === msg.entity_id) {
renderDetail();
} else {
render(); // update sidebar badges
}
return; // skip AI indicator update — not a log entry
}
// Update header AI indicator
const hdr = document.getElementById('hdr-ai-indicator');
if (hdr) {
const running = _aiLog.filter((e) => e.status === 'running').length;
hdr.innerHTML = running > 0 ? vAiIndicator(running) : '';
}
// Update root detail panel if shown
if (!S.selected) renderDetail();
};
ws.onerror = () => {};
ws.onclose = () => {
// Reconnect after a short delay
setTimeout(connectAiLogWs, 3000);
};
} }
async function loadTree() { async function loadTree() {
S.tree = await req('GET','/api/tree'); S.tree = await req('GET', '/api/tree');
render(); render();
} }
// ── Init ────────────────────────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────────────────────────
Promise.all([loadConfig(), loadTree()]);
// Image popup: close when clicking the overlay background or the × button.
(function () {
const popup = document.getElementById('img-popup');
const closeBtn = document.getElementById('img-popup-close');
if (popup) {
popup.addEventListener('click', (e) => {
if (e.target === popup) popup.classList.remove('open');
});
}
if (closeBtn) {
closeBtn.addEventListener('click', () => popup && popup.classList.remove('open'));
}
})();
Promise.all([loadConfig(), loadTree()]).then(() => connectAiLogWs());

View File

@@ -21,6 +21,8 @@
* Provides: collectQueueBooks(), renderPhotoQueue(), triggerPhoto() * Provides: collectQueueBooks(), renderPhotoQueue(), triggerPhoto()
*/ */
/* exported collectQueueBooks, renderPhotoQueue, triggerPhoto */
// ── Photo Queue ────────────────────────────────────────────────────────────── // ── Photo Queue ──────────────────────────────────────────────────────────────
function collectQueueBooks(node, type) { function collectQueueBooks(node, type) {
const books = []; const books = [];
@@ -29,9 +31,9 @@ function collectQueueBooks(node, type) {
if (n.identification_status !== 'user_approved') books.push(n); if (n.identification_status !== 'user_approved') books.push(n);
return; return;
} }
if (t === 'room') n.cabinets.forEach(c => collect(c, 'cabinet')); if (t === 'room') n.cabinets.forEach((c) => collect(c, 'cabinet'));
if (t === 'cabinet') n.shelves.forEach(s => collect(s, 'shelf')); if (t === 'cabinet') n.shelves.forEach((s) => collect(s, 'shelf'));
if (t === 'shelf') n.books.forEach(b => collect(b, 'book')); if (t === 'shelf') n.books.forEach((b) => collect(b, 'book'));
} }
collect(node, type); collect(node, type);
return books; return books;
@@ -40,8 +42,12 @@ function collectQueueBooks(node, type) {
function renderPhotoQueue() { function renderPhotoQueue() {
const el = document.getElementById('photo-queue-overlay'); const el = document.getElementById('photo-queue-overlay');
if (!el) return; if (!el) return;
if (!_photoQueue) { el.style.display = 'none'; el.innerHTML = ''; return; } if (!_photoQueue) {
const {books, index, processing} = _photoQueue; el.style.display = 'none';
el.innerHTML = '';
return;
}
const { books, index, processing } = _photoQueue;
el.style.display = 'flex'; el.style.display = 'flex';
if (index >= books.length) { if (index >= books.length) {
el.innerHTML = `<div class="pq-hdr"> el.innerHTML = `<div class="pq-hdr">
@@ -79,8 +85,8 @@ function renderPhotoQueue() {
const gphoto = document.getElementById('gphoto'); const gphoto = document.getElementById('gphoto');
function triggerPhoto(type, id) { function triggerPhoto(type, id) {
S._photoTarget = {type, id}; S._photoTarget = { type, id };
if (/Android|iPhone|iPad/i.test(navigator.userAgent)) gphoto.setAttribute('capture','environment'); if (/Android|iPhone|iPad/i.test(navigator.userAgent)) gphoto.setAttribute('capture', 'environment');
else gphoto.removeAttribute('capture'); else gphoto.removeAttribute('capture');
gphoto.value = ''; gphoto.value = '';
gphoto.click(); gphoto.click();
@@ -89,7 +95,7 @@ function triggerPhoto(type, id) {
gphoto.addEventListener('change', async () => { gphoto.addEventListener('change', async () => {
const file = gphoto.files[0]; const file = gphoto.files[0];
if (!file || !S._photoTarget) return; if (!file || !S._photoTarget) return;
const {type, id} = S._photoTarget; const { type, id } = S._photoTarget;
S._photoTarget = null; S._photoTarget = null;
const fd = new FormData(); const fd = new FormData();
fd.append('image', file, file.name); // HD — no client-side compression fd.append('image', file, file.name); // HD — no client-side compression
@@ -101,8 +107,10 @@ gphoto.addEventListener('change', async () => {
}; };
try { try {
const res = await req('POST', urls[type], fd, true); const res = await req('POST', urls[type], fd, true);
const key = type==='book' ? 'image_filename' : 'photo_filename'; const key = type === 'book' ? 'image_filename' : 'photo_filename';
walkTree(n=>{ if(n.id===id) n[key]=res[key]; }); walkTree((n) => {
if (n.id === id) n[key] = res[key];
});
// Photo queue mode: process and advance without full re-render // Photo queue mode: process and advance without full re-render
if (_photoQueue && type === 'book') { if (_photoQueue && type === 'book') {
_photoQueue.processing = true; _photoQueue.processing = true;
@@ -111,8 +119,12 @@ gphoto.addEventListener('change', async () => {
if (book && book.identification_status !== 'user_approved') { if (book && book.identification_status !== 'user_approved') {
try { try {
const br = await req('POST', `/api/books/${id}/process`); const br = await req('POST', `/api/books/${id}/process`);
walkTree(n => { if (n.id === id) Object.assign(n, br); }); walkTree((n) => {
} catch { /* continue queue on process error */ } if (n.id === id) Object.assign(n, br);
});
} catch {
/* continue queue on process error */
}
} }
_photoQueue.processing = false; _photoQueue.processing = false;
_photoQueue.index++; _photoQueue.index++;
@@ -127,12 +139,24 @@ gphoto.addEventListener('change', async () => {
if (book && book.identification_status !== 'user_approved') { if (book && book.identification_status !== 'user_approved') {
try { try {
const br = await req('POST', `/api/books/${id}/process`); const br = await req('POST', `/api/books/${id}/process`);
walkTree(n => { if(n.id===id) Object.assign(n, br); }); walkTree((n) => {
if (n.id === id) Object.assign(n, br);
});
toast(`Photo saved · Identified (${br.identification_status})`); toast(`Photo saved · Identified (${br.identification_status})`);
render(); render();
} catch { toast('Photo saved'); } } catch {
} else { toast('Photo saved'); } toast('Photo saved');
} else { toast('Photo saved'); } }
} else { toast('Photo saved'); } } else {
} catch(err) { toast('Upload failed: '+err.message); } toast('Photo saved');
}
} else {
toast('Photo saved');
}
} else {
toast('Photo saved');
}
} catch (err) {
toast('Upload failed: ' + err.message);
}
}); });

View File

@@ -7,15 +7,17 @@
* S — main UI state (tree data, selection, loading flags) * S — main UI state (tree data, selection, loading flags)
* _plugins — plugin manifest populated from GET /api/config * _plugins — plugin manifest populated from GET /api/config
* _batchState — current batch-processing progress * _batchState — current batch-processing progress
* _batchPollTimer — setInterval handle for batch polling * _batchWs — active WebSocket for batch push notifications (null when idle)
* _bnd — live boundary-canvas state (written by canvas-boundary.js, * _bnd — live boundary-canvas state (written by canvas-boundary.js,
* read by detail-render.js) * read by detail-render.js)
* _photoQueue — photo queue session state (written by photo.js, * _photoQueue — photo queue session state (written by photo.js,
* read by events.js) * read by events.js)
*/ */
/* exported S */
// ── Main UI state ─────────────────────────────────────────────────────────── // ── Main UI state ───────────────────────────────────────────────────────────
let S = { const S = {
tree: null, tree: null,
expanded: new Set(), expanded: new Set(),
selected: null, // {type:'cabinet'|'shelf'|'book', id} selected: null, // {type:'cabinet'|'shelf'|'book', id}
@@ -25,17 +27,33 @@ let S = {
}; };
// ── Plugin registry ───────────────────────────────────────────────────────── // ── Plugin registry ─────────────────────────────────────────────────────────
// eslint-disable-next-line prefer-const
let _plugins = []; // populated from GET /api/config let _plugins = []; // populated from GET /api/config
// ── Batch processing state ────────────────────────────────────────────────── // ── Batch processing state ──────────────────────────────────────────────────
let _batchState = {running: false, total: 0, done: 0, errors: 0, current: ''}; const _batchState = { running: false, total: 0, done: 0, errors: 0, current: '' };
let _batchPollTimer = null; // eslint-disable-next-line prefer-const
let _batchWs = null;
// ── Boundary canvas live state ─────────────────────────────────────────────── // ── Boundary canvas live state ───────────────────────────────────────────────
// Owned by canvas-boundary.js; declared here so detail-render.js can read it // Owned by canvas-boundary.js; declared here so detail-render.js can read it
// without a circular load dependency. // without a circular load dependency.
// eslint-disable-next-line prefer-const
let _bnd = null; // {wrap,img,canvas,axis,boundaries[],pluginResults{},selectedPlugin,segments[],nodeId,nodeType} let _bnd = null; // {wrap,img,canvas,axis,boundaries[],pluginResults{},selectedPlugin,segments[],nodeId,nodeType}
// ── Photo queue session state ──────────────────────────────────────────────── // ── Photo queue session state ────────────────────────────────────────────────
// Owned by photo.js; declared here so events.js can read/write it. // Owned by photo.js; declared here so events.js can read/write it.
// eslint-disable-next-line prefer-const
let _photoQueue = null; // {books:[...], index:0, processing:false} let _photoQueue = null; // {books:[...], index:0, processing:false}
// ── AI blocks visibility ─────────────────────────────────────────────────────
// Per-book override map. If bookId is absent the default rule applies:
// show when not user_approved, hide when user_approved.
const _aiBlocksVisible = {}; // {bookId: true|false}
// ── AI request log ───────────────────────────────────────────────────────────
// Populated from /ws/ai-log on page load.
// eslint-disable-next-line prefer-const
let _aiLog = []; // AiLogEntry[] — ring buffer, oldest first
// eslint-disable-next-line prefer-const
let _aiLogWs = null; // active WebSocket for AI log push (never closed)

View File

@@ -13,17 +13,27 @@
* vBook(), getBookStats(), vAiProgressBar() * vBook(), getBookStats(), vAiProgressBar()
*/ */
/* exported pluginsByCategory, pluginsByTarget, isLoading, vPluginBtn, vBatchBtn, vAiIndicator,
candidateSugRows, vApp, mainTitle, mainHeaderBtns, _STATUS_BADGE,
getBookStats, vAiProgressBar, walkTree, removeNode, findNode */
// ── Plugin helpers ─────────────────────────────────────────────────────────── // ── Plugin helpers ───────────────────────────────────────────────────────────
function pluginsByCategory(cat) { return _plugins.filter(p => p.category === cat); } function pluginsByCategory(cat) {
function pluginsByTarget(cat, target) { return _plugins.filter(p => p.category === cat && p.target === target); } return _plugins.filter((p) => p.category === cat);
function isLoading(pluginId, entityId) { return !!S._loading[`${pluginId}:${entityId}`]; } }
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) { function vPluginBtn(plugin, entityId, entityType, extraDisabled = false) {
const loading = isLoading(plugin.id, entityId); const loading = isLoading(plugin.id, entityId);
const label = loading ? '⏳' : esc(plugin.name); const label = loading ? '⏳' : esc(plugin.name);
return `<button class="btn btn-s" style="padding:2px 7px;font-size:.78rem;min-height:0" return `<button class="btn btn-s" style="padding:2px 7px;font-size:.78rem;min-height:0"
data-a="run-plugin" data-plugin="${plugin.id}" data-id="${entityId}" data-a="run-plugin" data-plugin="${plugin.id}" data-id="${entityId}"
data-etype="${entityType}"${(loading||extraDisabled)?' disabled':''} data-etype="${entityType}"${loading || extraDisabled ? ' disabled' : ''}
title="${esc(plugin.name)}">${label}</button>`; title="${esc(plugin.name)}">${label}</button>`;
} }
@@ -34,21 +44,36 @@ function vBatchBtn() {
return `<button class="hbtn" data-a="batch-start" title="Analyze all unidentified books">🔄</button>`; return `<button class="hbtn" data-a="batch-start" title="Analyze all unidentified books">🔄</button>`;
} }
// ── AI active indicator ───────────────────────────────────────────────────────
function vAiIndicator(count) {
return `<span class="ai-indicator" title="${count} AI request${count === 1 ? '' : 's'} running"><span class="ai-dot"></span>${count}</span>`;
}
// ── Candidate suggestion rows ──────────────────────────────────────────────── // ── Candidate suggestion rows ────────────────────────────────────────────────
const SOURCE_LABELS = { const SOURCE_LABELS = {
vlm: 'VLM', ai: 'AI', openlibrary: 'OpenLib', vlm: 'VLM',
rsl: 'РГБ', rusneb: 'НЭБ', alib: 'Alib', nlr: 'НЛР', shpl: 'ШПИЛ', ai: 'AI',
openlibrary: 'OpenLib',
rsl: 'РГБ',
rusneb: 'НЭБ',
alib: 'Alib',
nlr: 'НЛР',
shpl: 'ШПИЛ',
}; };
function getSourceLabel(source) { function getSourceLabel(source) {
if (SOURCE_LABELS[source]) return SOURCE_LABELS[source]; if (SOURCE_LABELS[source]) return SOURCE_LABELS[source];
const p = _plugins.find(pl => pl.id === source); const p = _plugins.find((pl) => pl.id === source);
return p ? p.name : source; return p ? p.name : source;
} }
function parseCandidates(json) { function parseCandidates(json) {
if (!json) return []; if (!json) return [];
try { return JSON.parse(json) || []; } catch { return []; } try {
return JSON.parse(json) || [];
} catch {
return [];
}
} }
function candidateSugRows(b, field, inputId) { function candidateSugRows(b, field, inputId) {
@@ -61,7 +86,7 @@ function candidateSugRows(b, field, inputId) {
const v = (c[field] || '').trim(); const v = (c[field] || '').trim();
if (!v) continue; if (!v) continue;
const key = v.toLowerCase(); const key = v.toLowerCase();
if (!byVal.has(key)) byVal.set(key, {display: v, sources: []}); if (!byVal.has(key)) byVal.set(key, { display: v, sources: [] });
const entry = byVal.get(key); const entry = byVal.get(key);
if (!entry.sources.includes(c.source)) entry.sources.push(c.source); if (!entry.sources.includes(c.source)) entry.sources.push(c.source);
} }
@@ -69,17 +94,17 @@ function candidateSugRows(b, field, inputId) {
const aiVal = (b[`ai_${field}`] || '').trim(); const aiVal = (b[`ai_${field}`] || '').trim();
if (aiVal) { if (aiVal) {
const key = aiVal.toLowerCase(); const key = aiVal.toLowerCase();
if (!byVal.has(key)) byVal.set(key, {display: aiVal, sources: []}); if (!byVal.has(key)) byVal.set(key, { display: aiVal, sources: [] });
const entry = byVal.get(key); const entry = byVal.get(key);
if (!entry.sources.includes('ai')) entry.sources.unshift('ai'); if (!entry.sources.includes('ai')) entry.sources.unshift('ai');
} }
return [...byVal.entries()] return [...byVal.entries()]
.filter(([k]) => k !== userVal.toLowerCase()) .filter(([k]) => k !== userVal.toLowerCase())
.map(([, {display, sources}]) => { .map(([, { display, sources }]) => {
const badges = sources.map(s => const badges = sources
`<span class="src-badge src-${esc(s)}">${esc(getSourceLabel(s))}</span>` .map((s) => `<span class="src-badge src-${esc(s)}">${esc(getSourceLabel(s))}</span>`)
).join(' '); .join(' ');
const val = esc(display); const val = esc(display);
return `<div class="ai-sug"> return `<div class="ai-sug">
${badges} <em>${val}</em> ${badges} <em>${val}</em>
@@ -90,14 +115,21 @@ function candidateSugRows(b, field, inputId) {
data-a="dismiss-field" data-id="${b.id}" data-field="${field}" data-a="dismiss-field" data-id="${b.id}" data-field="${field}"
data-value="${val}" title="Dismiss">✗</button> data-value="${val}" title="Dismiss">✗</button>
</div>`; </div>`;
}).join(''); })
.join('');
} }
// ── App shell ──────────────────────────────────────────────────────────────── // ── App shell ────────────────────────────────────────────────────────────────
function vApp() { function vApp() {
return `<div class="layout"> const running = (_aiLog || []).filter((e) => e.status === 'running').length;
return `<div class="page-wrap">
<div class="hdr">
<h1 data-a="deselect" style="cursor:pointer;flex:1" title="Back to overview">📚 Bookshelf</h1>
<div id="hdr-ai-indicator">${running > 0 ? vAiIndicator(running) : ''}</div>
<div id="main-hdr-batch">${vBatchBtn()}</div>
</div>
<div class="layout">
<div class="sidebar"> <div class="sidebar">
<div class="hdr"><h1 data-a="deselect" style="cursor:pointer" title="Back to overview">📚 Bookshelf</h1></div>
<div class="sidebar-body"> <div class="sidebar-body">
${vTreeBody()} ${vTreeBody()}
<button class="add-root" data-a="add-room">+ Add Room</button> <button class="add-root" data-a="add-room">+ Add Room</button>
@@ -106,18 +138,18 @@ function vApp() {
<div class="main-panel"> <div class="main-panel">
<div class="main-hdr" id="main-hdr"> <div class="main-hdr" id="main-hdr">
<h2 id="main-title">${mainTitle()}</h2> <h2 id="main-title">${mainTitle()}</h2>
<div id="main-hdr-batch">${vBatchBtn()}</div>
<div id="main-hdr-btns">${mainHeaderBtns()}</div> <div id="main-hdr-btns">${mainHeaderBtns()}</div>
</div> </div>
<div class="main-body" id="main-body">${vDetailBody()}</div> <div class="main-body" id="main-body">${vDetailBody()}</div>
</div> </div>
</div>
</div>`; </div>`;
} }
function mainTitle() { function mainTitle() {
if (!S.selected) return '<span style="opacity:.7">Select a room, cabinet or shelf</span>'; if (!S.selected) return '📚 Bookshelf';
const n = findNode(S.selected.id); const n = findNode(S.selected.id);
const {type, id} = S.selected; const { type, id } = S.selected;
if (type === 'book') { if (type === 'book') {
return `<span>${esc(n?.title || 'Untitled book')}</span>`; return `<span>${esc(n?.title || 'Untitled book')}</span>`;
} }
@@ -127,7 +159,7 @@ function mainTitle() {
function mainHeaderBtns() { function mainHeaderBtns() {
if (!S.selected) return ''; if (!S.selected) return '';
const {type, id} = S.selected; const { type, id } = S.selected;
if (type === 'room') { if (type === 'room') {
return `<div style="display:flex;gap:2px"> return `<div style="display:flex;gap:2px">
<button class="hbtn" data-a="add-cabinet" data-id="${id}" title="Add cabinet"></button> <button class="hbtn" data-a="add-cabinet" data-id="${id}" title="Add cabinet"></button>
@@ -171,18 +203,22 @@ function vRoom(r) {
const exp = S.expanded.has(r.id); const exp = S.expanded.has(r.id);
const sel = S.selected?.id === r.id; const sel = S.selected?.id === r.id;
return `<div class="node" data-id="${r.id}" data-type="room"> return `<div class="node" data-id="${r.id}" data-type="room">
<div class="nrow nrow-room${sel?' sel':''}" data-a="select" data-type="room" data-id="${r.id}"> <div class="nrow nrow-room${sel ? ' sel' : ''}" data-a="select" data-type="room" data-id="${r.id}">
<span class="drag-h">⠿</span> <span class="drag-h">⠿</span>
<button class="tbtn ${exp?'':'col'}" data-a="toggle" data-type="room" data-id="${r.id}">▾</button> <button class="tbtn ${exp ? '' : 'col'}" data-a="toggle" data-type="room" data-id="${r.id}">▾</button>
<span class="nname" data-type="room" data-id="${r.id}">🏠 ${esc(r.name)}</span> <span class="nname" data-type="room" data-id="${r.id}">🏠 ${esc(r.name)}</span>
<div class="nacts"> <div class="nacts">
<button class="ibtn" data-a="add-cabinet" data-id="${r.id}" title="Add cabinet"></button> <button class="ibtn" data-a="add-cabinet" data-id="${r.id}" title="Add cabinet"></button>
<button class="ibtn" data-a="del-room" data-id="${r.id}" title="Delete">🗑</button> <button class="ibtn" data-a="del-room" data-id="${r.id}" title="Delete">🗑</button>
</div> </div>
</div> </div>
${exp ? `<div class="nchildren"><div class="sortable-list" data-type="cabinets" data-parent="${r.id}"> ${
exp
? `<div class="nchildren"><div class="sortable-list" data-type="cabinets" data-parent="${r.id}">
${r.cabinets.map(vCabinet).join('')} ${r.cabinets.map(vCabinet).join('')}
</div></div>` : ''} </div></div>`
: ''
}
</div>`; </div>`;
} }
@@ -190,9 +226,9 @@ function vCabinet(c) {
const exp = S.expanded.has(c.id); const exp = S.expanded.has(c.id);
const sel = S.selected?.id === c.id; const sel = S.selected?.id === c.id;
return `<div class="node" data-id="${c.id}" data-type="cabinet"> return `<div class="node" data-id="${c.id}" data-type="cabinet">
<div class="nrow nrow-cabinet${sel?' sel':''}" data-a="select" data-type="cabinet" data-id="${c.id}"> <div class="nrow nrow-cabinet${sel ? ' sel' : ''}" data-a="select" data-type="cabinet" data-id="${c.id}">
<span class="drag-h">⠿</span> <span class="drag-h">⠿</span>
<button class="tbtn ${exp?'':'col'}" data-a="toggle" data-type="cabinet" data-id="${c.id}">▾</button> <button class="tbtn ${exp ? '' : 'col'}" data-a="toggle" data-type="cabinet" data-id="${c.id}">▾</button>
${c.photo_filename ? `<img src="/images/${c.photo_filename}" style="width:26px;height:32px;object-fit:cover;border-radius:2px;flex-shrink:0" alt="">` : ''} ${c.photo_filename ? `<img src="/images/${c.photo_filename}" style="width:26px;height:32px;object-fit:cover;border-radius:2px;flex-shrink:0" alt="">` : ''}
<span class="nname" data-type="cabinet" data-id="${c.id}">📚 ${esc(c.name)}</span> <span class="nname" data-type="cabinet" data-id="${c.id}">📚 ${esc(c.name)}</span>
<div class="nacts"> <div class="nacts">
@@ -202,9 +238,13 @@ function vCabinet(c) {
${!isDesktop() ? `<button class="ibtn" data-a="del-cabinet" data-id="${c.id}" title="Delete">🗑</button>` : ''} ${!isDesktop() ? `<button class="ibtn" data-a="del-cabinet" data-id="${c.id}" title="Delete">🗑</button>` : ''}
</div> </div>
</div> </div>
${exp ? `<div class="nchildren"><div class="sortable-list" data-type="shelves" data-parent="${c.id}"> ${
exp
? `<div class="nchildren"><div class="sortable-list" data-type="shelves" data-parent="${c.id}">
${c.shelves.map(vShelf).join('')} ${c.shelves.map(vShelf).join('')}
</div></div>` : ''} </div></div>`
: ''
}
</div>`; </div>`;
} }
@@ -212,9 +252,9 @@ function vShelf(s) {
const exp = S.expanded.has(s.id); const exp = S.expanded.has(s.id);
const sel = S.selected?.id === s.id; const sel = S.selected?.id === s.id;
return `<div class="node" data-id="${s.id}" data-type="shelf"> return `<div class="node" data-id="${s.id}" data-type="shelf">
<div class="nrow nrow-shelf${sel?' sel':''}" data-a="select" data-type="shelf" data-id="${s.id}"> <div class="nrow nrow-shelf${sel ? ' sel' : ''}" data-a="select" data-type="shelf" data-id="${s.id}">
<span class="drag-h">⠿</span> <span class="drag-h">⠿</span>
<button class="tbtn ${exp?'':'col'}" data-a="toggle" data-type="shelf" data-id="${s.id}">▾</button> <button class="tbtn ${exp ? '' : 'col'}" data-a="toggle" data-type="shelf" data-id="${s.id}">▾</button>
<span class="nname" data-type="shelf" data-id="${s.id}">${esc(s.name)}</span> <span class="nname" data-type="shelf" data-id="${s.id}">${esc(s.name)}</span>
<div class="nacts"> <div class="nacts">
${!isDesktop() ? `<button class="ibtn" data-a="photo" data-type="shelf" data-id="${s.id}" title="Photo">📷</button>` : ''} ${!isDesktop() ? `<button class="ibtn" data-a="photo" data-type="shelf" data-id="${s.id}" title="Photo">📷</button>` : ''}
@@ -223,9 +263,13 @@ function vShelf(s) {
${!isDesktop() ? `<button class="ibtn" data-a="del-shelf" data-id="${s.id}" title="Delete">🗑</button>` : ''} ${!isDesktop() ? `<button class="ibtn" data-a="del-shelf" data-id="${s.id}" title="Delete">🗑</button>` : ''}
</div> </div>
</div> </div>
${exp ? `<div class="nchildren"><div class="sortable-list" data-type="books" data-parent="${s.id}"> ${
exp
? `<div class="nchildren"><div class="sortable-list" data-type="books" data-parent="${s.id}">
${s.books.map(vBook).join('')} ${s.books.map(vBook).join('')}
</div></div>` : ''} </div></div>`
: ''
}
</div>`; </div>`;
} }
@@ -240,7 +284,7 @@ function vBook(b) {
const sub = [b.author, b.year].filter(Boolean).join(' · '); const sub = [b.author, b.year].filter(Boolean).join(' · ');
const sel = S.selected?.id === b.id; const sel = S.selected?.id === b.id;
return `<div class="node" data-id="${b.id}" data-type="book"> return `<div class="node" data-id="${b.id}" data-type="book">
<div class="nrow nrow-book${sel?' sel':''}" data-a="select" data-type="book" data-id="${b.id}"> <div class="nrow nrow-book${sel ? ' sel' : ''}" data-a="select" data-type="book" data-id="${b.id}">
<span class="drag-h">⠿</span> <span class="drag-h">⠿</span>
<span class="sbadge ${sc}" title="${b.identification_status ?? 'unidentified'}">${sl}</span> <span class="sbadge ${sc}" title="${b.identification_status ?? 'unidentified'}">${sl}</span>
${b.image_filename ? `<img src="/images/${b.image_filename}" class="bthumb" alt="">` : `<div class="bthumb-ph">📖</div>`} ${b.image_filename ? `<img src="/images/${b.image_filename}" class="bthumb" alt="">` : `<div class="bthumb-ph">📖</div>`}
@@ -248,10 +292,14 @@ function vBook(b) {
<div class="bttl">${esc(b.title || '—')}</div> <div class="bttl">${esc(b.title || '—')}</div>
${sub ? `<div class="bsub">${esc(sub)}</div>` : ''} ${sub ? `<div class="bsub">${esc(sub)}</div>` : ''}
</div> </div>
${!isDesktop() ? `<div class="nacts"> ${
!isDesktop()
? `<div class="nacts">
<button class="ibtn" data-a="photo" data-type="book" data-id="${b.id}" title="Upload photo">📷</button> <button class="ibtn" data-a="photo" data-type="book" data-id="${b.id}" title="Upload photo">📷</button>
<button class="ibtn" data-a="del-book" data-id="${b.id}" title="Delete">🗑</button> <button class="ibtn" data-a="del-book" data-id="${b.id}" title="Delete">🗑</button>
</div>` : ''} </div>`
: ''
}
</div> </div>
</div>`; </div>`;
} }
@@ -260,26 +308,29 @@ function vBook(b) {
function getBookStats(node, type) { function getBookStats(node, type) {
const books = []; const books = [];
function collect(n, t) { function collect(n, t) {
if (t==='book') { books.push(n); return; } if (t === 'book') {
if (t==='room') (n.cabinets||[]).forEach(c => collect(c,'cabinet')); books.push(n);
if (t==='cabinet') (n.shelves||[]).forEach(s => collect(s,'shelf')); return;
if (t==='shelf') (n.books||[]).forEach(b => collect(b,'book')); }
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); collect(node, type);
return { return {
total: books.length, total: books.length,
approved: books.filter(b=>b.identification_status==='user_approved').length, approved: books.filter((b) => b.identification_status === 'user_approved').length,
ai: books.filter(b=>b.identification_status==='ai_identified').length, ai: books.filter((b) => b.identification_status === 'ai_identified').length,
unidentified: books.filter(b=>b.identification_status==='unidentified').length, unidentified: books.filter((b) => b.identification_status === 'unidentified').length,
}; };
} }
function vAiProgressBar(stats) { function vAiProgressBar(stats) {
const {total, approved, ai, unidentified} = stats; const { total, approved, ai, unidentified } = stats;
if (!total || approved === total) return ''; if (!total || approved === total) return '';
const pA = (approved/total*100).toFixed(1); const pA = ((approved / total) * 100).toFixed(1);
const pI = (ai/total*100).toFixed(1); const pI = ((ai / total) * 100).toFixed(1);
const pU = (unidentified/total*100).toFixed(1); const pU = ((unidentified / total) * 100).toFixed(1);
return `<div style="margin-bottom:10px;background:white;border-radius:8px;padding:10px;box-shadow:0 1px 3px rgba(0,0,0,.07)"> return `<div style="margin-bottom:10px;background:white;border-radius:8px;padding:10px;box-shadow:0 1px 3px rgba(0,0,0,.07)">
<div style="display:flex;gap:8px;font-size:.7rem;margin-bottom:5px"> <div style="display:flex;gap:8px;font-size:.7rem;margin-bottom:5px">
<span style="color:#15803d">✓ ${approved} approved</span><span style="color:#94a3b8">·</span> <span style="color:#15803d">✓ ${approved} approved</span><span style="color:#94a3b8">·</span>
@@ -297,10 +348,13 @@ function vAiProgressBar(stats) {
// ── Tree helpers ───────────────────────────────────────────────────────────── // ── Tree helpers ─────────────────────────────────────────────────────────────
function walkTree(fn) { function walkTree(fn) {
if (!S.tree) return; if (!S.tree) return;
for (const r of S.tree) { fn(r,'room'); for (const r of S.tree) {
for (const c of r.cabinets) { fn(c,'cabinet'); fn(r, 'room');
for (const s of c.shelves) { fn(s,'shelf'); for (const c of r.cabinets) {
for (const b of s.books) fn(b,'book'); fn(c, 'cabinet');
for (const s of c.shelves) {
fn(s, 'shelf');
for (const b of s.books) fn(b, 'book');
} }
} }
} }
@@ -308,14 +362,20 @@ function walkTree(fn) {
function removeNode(type, id) { function removeNode(type, id) {
if (!S.tree) return; if (!S.tree) return;
if (type==='room') S.tree = S.tree.filter(r=>r.id!==id); 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 === '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 === 'shelf')
if (type==='book') S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>s.books=s.books.filter(b=>b.id!==id)))); 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) { function findNode(id) {
let found = null; let found = null;
walkTree(n => { if (n.id===id) found=n; }); walkTree((n) => {
if (n.id === id) found = n;
});
return found; return found;
} }

157
tests/test_api.py Normal file
View File

@@ -0,0 +1,157 @@
"""API-level integration tests.
Uses a minimal FastAPI app (router only, no lifespan) so tests run against
a temporary SQLite database without needing config or plugins.
"""
import json
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
import db
import files
from api import router
# ── Fixtures ──────────────────────────────────────────────────────────────────
_app = FastAPI()
_app.include_router(router)
@pytest.fixture
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
"""TestClient backed by a fresh temporary database."""
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()
db.COUNTERS.clear()
with TestClient(_app) as c:
yield c
db.COUNTERS.clear()
def _seed(tmp_path: Path) -> dict[str, str]:
"""Create room/cabinet/shelves/books with boundaries via db helpers; return their IDs."""
with db.transaction() as c:
room = db.create_room(c)
cab = db.create_cabinet(c, room.id)
s_a = db.create_shelf(c, cab.id)
s_b = db.create_shelf(c, cab.id)
s_c = db.create_shelf(c, cab.id)
b_a = db.create_book(c, s_a.id)
b_b = db.create_book(c, s_a.id)
b_c = db.create_book(c, s_a.id)
# 3 shelves → 2 interior boundaries
db.set_cabinet_boundaries(c, cab.id, json.dumps([0.33, 0.66]))
# 3 books in shelf_a → 2 interior boundaries
db.set_shelf_boundaries(c, s_a.id, json.dumps([0.33, 0.66]))
return {
"room": room.id,
"cabinet": cab.id,
"shelf_a": s_a.id,
"shelf_b": s_b.id,
"shelf_c": s_c.id,
"book_a": b_a.id,
"book_b": b_b.id,
"book_c": b_c.id,
}
# ── Shelf deletion / boundary cleanup ─────────────────────────────────────────
def test_delete_first_shelf_removes_first_boundary(
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(db, "DB_PATH", tmp_path / "test.db")
ids = _seed(tmp_path)
resp = client.delete(f"/api/shelves/{ids['shelf_a']}")
assert resp.status_code == 200
with db.connection() as c:
cab = db.get_cabinet(c, ids["cabinet"])
assert cab is not None
bounds = json.loads(cab.shelf_boundaries or "[]")
assert bounds == [0.66] # first boundary (0.33) removed
def test_delete_middle_shelf_removes_middle_boundary(
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(db, "DB_PATH", tmp_path / "test.db")
ids = _seed(tmp_path)
resp = client.delete(f"/api/shelves/{ids['shelf_b']}")
assert resp.status_code == 200
with db.connection() as c:
cab = db.get_cabinet(c, ids["cabinet"])
assert cab is not None
bounds = json.loads(cab.shelf_boundaries or "[]")
assert bounds == [0.33] # middle boundary (0.66) removed
def test_delete_last_shelf_removes_last_boundary(
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(db, "DB_PATH", tmp_path / "test.db")
ids = _seed(tmp_path)
resp = client.delete(f"/api/shelves/{ids['shelf_c']}")
assert resp.status_code == 200
with db.connection() as c:
cab = db.get_cabinet(c, ids["cabinet"])
assert cab is not None
bounds = json.loads(cab.shelf_boundaries or "[]")
assert bounds == [0.33] # last boundary (0.66) removed
def test_delete_only_shelf_leaves_no_boundaries(
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Deleting the sole shelf (no boundaries) leaves boundaries unchanged (empty)."""
monkeypatch.setattr(db, "DB_PATH", tmp_path / "test.db")
ids = _seed(tmp_path)
# Delete two shelves first to leave only one
client.delete(f"/api/shelves/{ids['shelf_b']}")
client.delete(f"/api/shelves/{ids['shelf_c']}")
resp = client.delete(f"/api/shelves/{ids['shelf_a']}")
assert resp.status_code == 200
with db.connection() as c:
cab = db.get_cabinet(c, ids["cabinet"])
assert cab is not None
bounds = json.loads(cab.shelf_boundaries or "[]")
assert bounds == []
# ── Book deletion / boundary cleanup ──────────────────────────────────────────
def test_delete_first_book_removes_first_boundary(
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(db, "DB_PATH", tmp_path / "test.db")
ids = _seed(tmp_path)
resp = client.delete(f"/api/books/{ids['book_a']}")
assert resp.status_code == 200
with db.connection() as c:
shelf = db.get_shelf(c, ids["shelf_a"])
assert shelf is not None
bounds = json.loads(shelf.book_boundaries or "[]")
assert bounds == [0.66]
def test_delete_last_book_removes_last_boundary(
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(db, "DB_PATH", tmp_path / "test.db")
ids = _seed(tmp_path)
resp = client.delete(f"/api/books/{ids['book_c']}")
assert resp.status_code == 200
with db.connection() as c:
shelf = db.get_shelf(c, ids["shelf_a"])
assert shelf is not None
bounds = json.loads(shelf.book_boundaries or "[]")
assert bounds == [0.33]

202
tests/test_archives.py Normal file
View File

@@ -0,0 +1,202 @@
"""Network integration tests for archive searcher plugins.
Each test queries a live external service for "War and Peace" by Tolstoy,
a book universally catalogued in all supported archives.
Run with: pytest tests/ -m network
Skip with: pytest tests/ -m "not network" (default in presubmit)
"""
import re
import pytest
from models import CandidateRecord
from plugins.archives.alib import AlibPlugin
from plugins.archives.openlibrary import OpenLibraryPlugin
from plugins.archives.rsl import RSLPlugin
from plugins.archives.rusneb import RusnebPlugin
from plugins.archives.shpl import ShplPlugin
from plugins.archives.sru_catalog import SRUCatalogPlugin
from plugins.rate_limiter import RateLimiter
pytestmark = pytest.mark.network
_RL = RateLimiter()
_TIMEOUT = 15
_YEAR_PAT = re.compile(r"^\d{4}$")
def _titles(results: list[CandidateRecord]) -> list[str]:
return [r["title"] for r in results]
def _authors(results: list[CandidateRecord]) -> list[str]:
return [r["author"] for r in results]
def _years(results: list[CandidateRecord]) -> list[str]:
return [r["year"] for r in results]
def _has_title(results: list[CandidateRecord], fragment: str) -> bool:
"""Return True if any result title contains fragment (case-insensitive)."""
low = fragment.lower()
return any(low in r["title"].lower() for r in results)
def _has_author(results: list[CandidateRecord], fragment: str) -> bool:
"""Return True if any result author contains fragment (case-insensitive)."""
low = fragment.lower()
return any(low in r["author"].lower() for r in results)
def _valid_year(year: str) -> bool:
"""Return True if year is a 4-digit string or empty."""
return year == "" or bool(_YEAR_PAT.match(year))
# ── OpenLibrary ───────────────────────────────────────────────────────────────
def test_openlibrary_war_and_peace() -> None:
plugin = OpenLibraryPlugin(
plugin_id="openlibrary",
name="OpenLibrary",
rate_limiter=_RL,
rate_limit_seconds=0,
auto_queue=True,
timeout=_TIMEOUT,
config={},
)
results = plugin.search("War and Peace Tolstoy")
assert results, "OpenLibrary returned no results"
assert all(r["source"] == "openlibrary" for r in results)
assert _has_title(results, "war and peace"), f"titles={_titles(results)}"
# OpenLibrary stores authors in their original language; accept both forms.
assert _has_author(results, "tolstoy") or _has_author(results, "толст"), f"authors={_authors(results)}"
assert all(_valid_year(r["year"]) for r in results), f"years={_years(results)}"
# OpenLibrary returns isbn and publisher from its JSON API.
assert all(isinstance(r["isbn"], str) for r in results)
assert all(isinstance(r["publisher"], str) for r in results)
# ── RSL (РГБ) ─────────────────────────────────────────────────────────────────
def test_rsl_voina_i_mir() -> None:
plugin = RSLPlugin(
plugin_id="rsl",
name="РГБ",
rate_limiter=_RL,
rate_limit_seconds=0,
auto_queue=True,
timeout=_TIMEOUT,
config={},
)
results = plugin.search("Толстой Война и мир")
assert results, "RSL returned no results"
assert all(r["source"] == "rsl" for r in results)
assert _has_title(results, "война"), f"titles={_titles(results)}"
assert all(_valid_year(r["year"]) for r in results), f"years={_years(results)}"
assert all(r["isbn"] == "" for r in results)
assert all(r["publisher"] == "" for r in results)
# ── НЭБ (rusneb) ─────────────────────────────────────────────────────────────
def test_rusneb_voina_i_mir() -> None:
plugin = RusnebPlugin(
plugin_id="rusneb",
name="НЭБ",
rate_limiter=_RL,
rate_limit_seconds=0,
auto_queue=True,
timeout=_TIMEOUT,
config={},
)
results = plugin.search("Война и мир Толстой")
assert results, "НЭБ returned no results"
assert all(r["source"] == "rusneb" for r in results)
assert _has_title(results, "война"), f"titles={_titles(results)}"
assert _has_author(results, "толст"), f"authors={_authors(results)}"
assert all(_valid_year(r["year"]) for r in results), f"years={_years(results)}"
assert all(r["isbn"] == "" for r in results)
assert all(r["publisher"] == "" for r in results)
# ── Alib ─────────────────────────────────────────────────────────────────────
def test_alib_voina_i_mir() -> None:
plugin = AlibPlugin(
plugin_id="alib_web",
name="Alib (web)",
rate_limiter=_RL,
rate_limit_seconds=0,
auto_queue=False,
timeout=_TIMEOUT,
config={},
)
results = plugin.search("Война и мир Толстой")
assert results, "Alib returned no results"
assert all(r["source"] == "alib_web" for r in results)
assert _has_title(results, "война"), f"titles={_titles(results)}"
assert _has_author(results, "толст"), f"authors={_authors(results)}"
# Alib entries always include a publication year in the bibliographic text.
assert all(_YEAR_PAT.match(r["year"]) for r in results), f"years={_years(results)}"
assert all(r["isbn"] == "" for r in results)
assert all(r["publisher"] == "" for r in results)
# ── НЛР (SRU) ────────────────────────────────────────────────────────────────
# The NLR SRU endpoint (www.nlr.ru/search/query) no longer exists (HTTP 404).
@pytest.mark.xfail(reason="nlr.ru SRU endpoint no longer available (HTTP 404)", strict=False)
def test_nlr_voina_i_mir() -> None:
plugin = SRUCatalogPlugin(
plugin_id="nlr",
name="НЛР",
rate_limiter=_RL,
rate_limit_seconds=0,
auto_queue=False,
timeout=_TIMEOUT,
config={
"url": "http://www.nlr.ru/search/query",
"query_prefix": "title=",
},
)
results = plugin.search("Война и мир")
assert results, "НЛР returned no results"
assert all(r["source"] == "nlr" for r in results)
assert _has_title(results, "война"), f"titles={_titles(results)}"
assert all(_valid_year(r["year"]) for r in results), f"years={_years(results)}"
assert all(r["isbn"] == "" for r in results)
assert all(r["publisher"] == "" for r in results)
# ── ШПИЛ ─────────────────────────────────────────────────────────────────────
# The ШПИЛ IRBIS64 CGI endpoint no longer exists (HTTP 404).
@pytest.mark.xfail(reason="shpl.ru IRBIS64 CGI endpoint no longer available (HTTP 404)", strict=False)
def test_shpl_voina_i_mir() -> None:
plugin = ShplPlugin(
plugin_id="shpl",
name="ШПИЛ",
rate_limiter=_RL,
rate_limit_seconds=0,
auto_queue=False,
timeout=_TIMEOUT,
config={},
)
results = plugin.search("Война и мир")
assert results, "ШПИЛ returned no results"
assert all(r["source"] == "shpl" for r in results)
assert _has_title(results, "война"), f"titles={_titles(results)}"
assert all(_valid_year(r["year"]) for r in results), f"years={_years(results)}"
assert all(r["isbn"] == "" for r in results)
assert all(r["publisher"] == "" for r in results)

View File

@@ -26,6 +26,7 @@ from models import (
BoundaryDetectResult, BoundaryDetectResult,
BookRow, BookRow,
CandidateRecord, CandidateRecord,
IdentifyBlock,
PluginLookupResult, PluginLookupResult,
TextRecognizeResult, TextRecognizeResult,
) )
@@ -56,6 +57,7 @@ def _book(**kwargs: object) -> BookRow:
"analyzed_at": None, "analyzed_at": None,
"created_at": "2024-01-01T00:00:00", "created_at": "2024-01-01T00:00:00",
"candidates": None, "candidates": None,
"ai_blocks": None,
} }
defaults.update(kwargs) defaults.update(kwargs)
return BookRow(**defaults) # type: ignore[arg-type] return BookRow(**defaults) # type: ignore[arg-type]
@@ -75,7 +77,7 @@ def seeded_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
c.execute("INSERT INTO cabinets VALUES (?,?,?,?,?,?,?,?)", ["c1", "r1", "Cabinet", None, None, None, 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 shelves VALUES (?,?,?,?,?,?,?,?)", ["s1", "c1", "Shelf", None, None, None, 1, ts])
c.execute( c.execute(
"INSERT INTO books VALUES (?,?,0,NULL,'','','','','','','','','','','','','unidentified',0,NULL,?,NULL)", "INSERT INTO books VALUES (?,?,0,NULL,'','','','','','','','','','','','','unidentified',0,NULL,?,NULL,NULL)",
["b1", "s1", ts], ["b1", "s1", ts],
) )
c.commit() c.commit()
@@ -93,6 +95,10 @@ class _BoundaryDetectorStub:
auto_queue = False auto_queue = False
target = "books" target = "books"
@property
def model(self) -> str:
return "stub-model"
@property @property
def max_image_px(self) -> int: def max_image_px(self) -> int:
return 1600 return 1600
@@ -109,6 +115,10 @@ class _BoundaryDetectorShelvesStub:
auto_queue = False auto_queue = False
target = "shelves" target = "shelves"
@property
def model(self) -> str:
return "stub-model"
@property @property
def max_image_px(self) -> int: def max_image_px(self) -> int:
return 1600 return 1600
@@ -124,6 +134,10 @@ class _TextRecognizerStub:
name = "Stub TR" name = "Stub TR"
auto_queue = False auto_queue = False
@property
def model(self) -> str:
return "stub-model"
@property @property
def max_image_px(self) -> int: def max_image_px(self) -> int:
return 1600 return 1600
@@ -139,19 +153,29 @@ class _BookIdentifierStub:
name = "Stub BI" name = "Stub BI"
auto_queue = False auto_queue = False
@property
def model(self) -> str:
return "stub-model"
@property
def max_image_px(self) -> int:
return 1600
@property @property
def confidence_threshold(self) -> float: def confidence_threshold(self) -> float:
return 0.8 return 0.8
def identify(self, raw_text: str) -> AIIdentifyResult: @property
return { def is_vlm(self) -> bool:
"title": "Found Book", return False
"author": "Found Author",
"year": "2000", def identify(
"isbn": "", self,
"publisher": "", raw_text: str,
"confidence": 0.9, archive_results: list[CandidateRecord],
} images: list[tuple[str, str]],
) -> list[IdentifyBlock]:
return [IdentifyBlock(title="Found Book", author="Found Author", year="2000", score=0.9)]
class _ArchiveSearcherStub: class _ArchiveSearcherStub: