From b94f222c965d9a441379c7a9b43e7349c20c00c7 Mon Sep 17 00:00:00 2001 From: Petr Polezhaev Date: Wed, 11 Mar 2026 12:10:54 +0300 Subject: [PATCH] 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 --- config/functions.default.yaml | 6 +- config/models.default.yaml | 32 +- config/ui.default.yaml | 4 +- docs/overview.md | 43 ++- eslint.config.js | 19 +- pyproject.toml | 1 + src/api.py | 83 +++- src/app.py | 17 + src/config.py | 3 + src/db.py | 115 +++++- src/errors.py | 15 + src/log_thread.py | 141 +++++++ src/logic/__init__.py | 106 +++++- src/logic/ai_log.py | 190 ++++++++++ src/logic/archive.py | 15 +- src/logic/batch.py | 180 +++++++-- src/logic/boundaries.py | 16 +- src/logic/identification.py | 268 ++++++++++++- src/migrate.py | 72 ++++ src/models.py | 47 ++- src/plugins/__init__.py | 16 + src/plugins/ai_compat/_client.py | 71 +++- src/plugins/ai_compat/book_identifier.py | 105 ++++-- .../ai_compat/boundary_detector_books.py | 4 + .../ai_compat/boundary_detector_shelves.py | 4 + src/plugins/ai_compat/text_recognizer.py | 4 + static/css/layout.css | 28 +- static/css/overlays.css | 7 + static/index.html | 11 +- static/js/api.js | 14 +- static/js/canvas-boundary.js | 222 +++++++---- static/js/canvas-crop.js | 152 +++++--- static/js/detail-render.js | 227 +++++++---- static/js/editing.js | 76 ++-- static/js/events.js | 356 +++++++++++++----- static/js/helpers.js | 15 +- static/js/init.js | 125 ++++-- static/js/photo.js | 68 ++-- static/js/state.js | 36 +- static/js/tree-render.js | 194 ++++++---- tests/test_logic.py | 44 ++- 41 files changed, 2566 insertions(+), 586 deletions(-) create mode 100644 src/log_thread.py create mode 100644 src/logic/ai_log.py create mode 100644 src/migrate.py diff --git a/config/functions.default.yaml b/config/functions.default.yaml index 3f35a3a..9678814 100644 --- a/config/functions.default.yaml +++ b/config/functions.default.yaml @@ -30,14 +30,16 @@ functions: rate_limit_seconds: 0 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: identify: model: ai_identify confidence_threshold: 0.8 auto_queue: false rate_limit_seconds: 0 - timeout: 30 + timeout: 60 + is_vlm: true # ── Archive searchers: query → [{source, title, author, year, isbn, publisher}, ...] archive_searchers: diff --git a/config/models.default.yaml b/config/models.default.yaml index 7c8e235..b1db6f0 100644 --- a/config/models.default.yaml +++ b/config/models.default.yaml @@ -42,9 +42,33 @@ models: credentials: openrouter model: "google/gemini-flash-1.5" prompt: | - # ${RAW_TEXT} — text read from the book spine (multi-line) - # ${OUTPUT_FORMAT} — JSON schema injected by BookIdentifierPlugin - The following text was read from a book spine: + # ${RAW_TEXT} — 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 + Text read from the book spine: ${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} diff --git a/config/ui.default.yaml b/config/ui.default.yaml index 621486f..98c011e 100644 --- a/config/ui.default.yaml +++ b/config/ui.default.yaml @@ -1,3 +1,5 @@ # UI settings. Override in ui.user.yaml. 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 diff --git a/docs/overview.md b/docs/overview.md index c7bbf44..d96e062 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -20,15 +20,18 @@ src/ config.py # Config loading and typed AppConfig models.py # Typed dataclasses / mashumaro decoders errors.py # Domain exceptions (NotFoundError, BadRequestError subtypes) + log_thread.py # Thread-safe logging context (ContextVar + event-loop bridge for executor threads) logic/ __init__.py # dispatch_plugin() orchestrator + re-exports boundaries.py # Boundary math, shelf/spine crop sources, boundary detector runner identification.py # Status computation, text recognizer, book identifier runners 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 + migrate.py # DB migration; run_migration() called at startup 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 ai_compat/ # AI plugin implementations archives/ # Archive plugin implementations @@ -71,7 +74,7 @@ Categories: | `credentials` | `base_url` + `api_key` per endpoint; no model or prompt | | `models` | `credentials` ref + `model` string + optional `extra_body` + `prompt` | | `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`: ```yaml @@ -88,9 +91,19 @@ credentials: | `boundary_detectors` (`target=shelves`) | cabinet image | `{boundaries:[…], confidence:N}` | `cabinets.ai_shelf_boundaries` | | `boundary_detectors` (`target=books`) | shelf image | `{boundaries:[…]}` | `shelves.ai_book_boundaries` | | `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` | +### 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 ``` 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 - 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) | Table | Notable columns | |-------|-----------------| | `cabinets` | `shelf_boundaries` (JSON `[…]`), `ai_shelf_boundaries` (JSON `{pluginId:[…]}`) | | `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`. @@ -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 ## 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 ``` @@ -150,8 +176,11 @@ PATCH /api/cabinets/{id}/boundaries # update shelf boundary PATCH /api/shelves/{id}/boundaries # update book boundary list GET /api/shelves/{id}/image # shelf image (override or cabinet crop) GET /api/books/{id}/spine # book spine crop +POST /api/books/{id}/identify # full identification pipeline (VLM → archives → main model) POST /api/books/{id}/process # full auto-queue pipeline (single book) 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 PATCH /api/{kind}/reorder # drag-to-reorder POST /api/cabinets/{id}/crop / POST /api/shelves/{id}/crop # permanent crop diff --git a/eslint.config.js b/eslint.config.js index ca32223..abb75ae 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,9 +19,12 @@ const appGlobals = { S: 'writable', _plugins: 'writable', _batchState: 'writable', - _batchPollTimer: 'writable', + _batchWs: 'writable', _bnd: 'writable', _photoQueue: 'writable', + _aiBlocksVisible: 'writable', + _aiLog: 'writable', + _aiLogWs: 'writable', // helpers.js esc: 'readonly', @@ -46,6 +49,7 @@ const appGlobals = { isLoading: 'readonly', vPluginBtn: 'readonly', vBatchBtn: 'readonly', + vAiIndicator: 'readonly', candidateSugRows: 'readonly', _STATUS_BADGE: 'readonly', getBookStats: 'readonly', @@ -56,6 +60,7 @@ const appGlobals = { // detail-render.js vDetailBody: 'readonly', + aiBlocksShown: 'readonly', // canvas-crop.js startCropMode: 'readonly', @@ -72,7 +77,8 @@ const appGlobals = { // init.js render: 'readonly', renderDetail: 'readonly', - startBatchPolling: 'readonly', + connectBatchWs: 'readonly', + connectAiLogWs: 'readonly', loadTree: 'readonly', // CDN (SortableJS loaded via @@ -73,7 +82,7 @@ with all action cases; accordion expand helpers. --> - diff --git a/static/js/api.js b/static/js/api.js index 5db988a..1eb6d5c 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -7,16 +7,22 @@ * Depends on: nothing */ +/* exported req */ + // ── API ────────────────────────────────────────────────────────────────────── async function req(method, url, body = null, isForm = false) { - const opts = {method}; + const opts = { method }; if (body) { - if (isForm) { opts.body = body; } - else { opts.headers = {'Content-Type':'application/json'}; opts.body = JSON.stringify(body); } + if (isForm) { + opts.body = body; + } else { + opts.headers = { 'Content-Type': 'application/json' }; + opts.body = JSON.stringify(body); + } } const r = await fetch(url, opts); if (!r.ok) { - const e = await r.json().catch(() => ({detail:'Request failed'})); + const e = await r.json().catch(() => ({ detail: 'Request failed' })); throw new Error(e.detail || 'Request failed'); } return r.json(); diff --git a/static/js/canvas-boundary.js b/static/js/canvas-boundary.js index 4181a87..88874d8 100644 --- a/static/js/canvas-boundary.js +++ b/static/js/canvas-boundary.js @@ -16,10 +16,16 @@ * setupDetailCanvas(), drawBnd(), clearSegHover() */ +/* exported parseBounds, parseBndPluginResults, setupDetailCanvas, drawBnd */ + // ── Boundary parsing helpers ───────────────────────────────────────────────── function parseBounds(json) { if (!json) return []; - try { return JSON.parse(json) || []; } catch { return []; } + try { + return JSON.parse(json) || []; + } catch { + return []; + } } function parseBndPluginResults(json) { @@ -28,39 +34,57 @@ function parseBndPluginResults(json) { const v = JSON.parse(json); if (Array.isArray(v) || !v || typeof v !== 'object') return {}; 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_STROKES = ['#3b82f6','#10b981','#f59e0b','#ef4444','#a855f7']; +const SEG_FILLS = [ + 'rgba(59,130,246,.14)', + 'rgba(16,185,129,.14)', + 'rgba(245,158,11,.14)', + 'rgba(239,68,68,.14)', + 'rgba(168,85,247,.14)', +]; +const SEG_STROKES = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#a855f7']; // ── Canvas setup ───────────────────────────────────────────────────────────── function setupDetailCanvas() { - const wrap = document.getElementById('bnd-wrap'); - const img = document.getElementById('bnd-img'); + const wrap = document.getElementById('bnd-wrap'); + const img = document.getElementById('bnd-img'); const canvas = document.getElementById('bnd-canvas'); if (!wrap || !img || !canvas || !S.selected) return; - const {type, id} = S.selected; + const { type, id } = S.selected; const node = findNode(id); if (!node || (type !== 'cabinet' && type !== 'shelf')) return; - const axis = type === 'cabinet' ? 'y' : 'x'; - const boundaries = parseBounds(type === 'cabinet' ? node.shelf_boundaries : node.book_boundaries); + const axis = type === 'cabinet' ? 'y' : 'x'; + const boundaries = parseBounds(type === 'cabinet' ? node.shelf_boundaries : node.book_boundaries); const pluginResults = parseBndPluginResults(type === 'cabinet' ? node.ai_shelf_boundaries : node.ai_book_boundaries); - const pluginIds = Object.keys(pluginResults); - const segments = type === 'cabinet' - ? node.shelves.map((s,i) => ({id:s.id, label:s.name||`Shelf ${i+1}`})) - : node.books.map((b,i) => ({id:b.id, label:b.title||`Book ${i+1}`})); + const pluginIds = Object.keys(pluginResults); + const segments = + type === 'cabinet' + ? node.shelves.map((s, i) => ({ id: s.id, label: s.name || `Shelf ${i + 1}` })) + : node.books.map((b, i) => ({ id: b.id, label: b.title || `Book ${i + 1}` })); - const hasChildren = type === 'cabinet' ? node.shelves.length > 0 : node.books.length > 0; - const prevSel = (_bnd?.nodeId === id) ? _bnd.selectedPlugin - : (hasChildren ? null : pluginIds[0] ?? null); + const hasChildren = type === 'cabinet' ? node.shelves.length > 0 : node.books.length > 0; + const prevSel = _bnd?.nodeId === id ? _bnd.selectedPlugin : hasChildren ? null : (pluginIds[0] ?? null); - _bnd = {wrap, img, canvas, axis, boundaries:[...boundaries], - pluginResults, selectedPlugin: prevSel, segments, nodeId:id, nodeType:type}; + _bnd = { + wrap, + img, + canvas, + axis, + boundaries: [...boundaries], + pluginResults, + selectedPlugin: prevSel, + segments, + nodeId: id, + nodeType: type, + }; function sizeAndDraw() { - canvas.width = img.offsetWidth; + canvas.width = img.offsetWidth; canvas.height = img.offsetHeight; drawBnd(); } @@ -69,17 +93,18 @@ function setupDetailCanvas() { canvas.addEventListener('pointerdown', bndPointerDown); canvas.addEventListener('pointermove', bndPointerMove); - canvas.addEventListener('pointerup', bndPointerUp); - canvas.addEventListener('click', bndClick); - canvas.addEventListener('mousemove', bndHover); - canvas.addEventListener('mouseleave', () => clearSegHover()); + canvas.addEventListener('pointerup', bndPointerUp); + canvas.addEventListener('click', bndClick); + canvas.addEventListener('mousemove', bndHover); + canvas.addEventListener('mouseleave', () => clearSegHover()); } // ── Draw ───────────────────────────────────────────────────────────────────── function drawBnd(dragIdx = -1, dragVal = null) { if (!_bnd || S._cropMode) return; - const {canvas, axis, boundaries, segments} = _bnd; - const W = canvas.width, H = canvas.height; + const { canvas, axis, boundaries, segments } = _bnd; + const W = canvas.width, + H = canvas.height; if (!W || !H) return; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, W, H); @@ -94,11 +119,12 @@ function drawBnd(dragIdx = -1, dragVal = null) { // Draw segments 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; ctx.fillStyle = SEG_FILLS[ci]; - if (axis === 'y') ctx.fillRect(0, a*H, W, (b-a)*H); - else ctx.fillRect(a*W, 0, (b-a)*W, H); + if (axis === 'y') ctx.fillRect(0, a * H, W, (b - a) * H); + else ctx.fillRect(a * W, 0, (b - a) * W, H); // Label const seg = segments[i]; if (seg) { @@ -106,10 +132,13 @@ function drawBnd(dragIdx = -1, dragVal = null) { ctx.fillStyle = 'rgba(0,0,0,.5)'; const lbl = seg.label.slice(0, 24); if (axis === 'y') { - ctx.fillText(lbl, 4, a*H + 14); + ctx.fillText(lbl, 4, a * H + 14); } else { - ctx.save(); ctx.translate(a*W + 12, 14); ctx.rotate(Math.PI/2); - ctx.fillText(lbl, 0, 0); ctx.restore(); + ctx.save(); + 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.lineWidth = 2; 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.beginPath(); - if (axis === 'y') { ctx.moveTo(0, val*H); ctx.lineTo(W, val*H); } - else { ctx.moveTo(val*W, 0); ctx.lineTo(val*W, H); } + if (axis === 'y') { + ctx.moveTo(0, val * H); + ctx.lineTo(W, val * H); + } else { + ctx.moveTo(val * W, 0); + ctx.lineTo(val * W, H); + } ctx.stroke(); } // Draw plugin boundary suggestions (dashed, non-interactive) - const {pluginResults, selectedPlugin} = _bnd; + const { pluginResults, selectedPlugin } = _bnd; const pluginIds = Object.keys(pluginResults); if (selectedPlugin && pluginIds.length) { ctx.setLineDash([3, 6]); ctx.lineWidth = 1.5; const drawPluginBounds = (bounds, color) => { ctx.strokeStyle = color; - for (const ab of (bounds || [])) { + for (const ab of bounds || []) { ctx.beginPath(); - if (axis === 'y') { ctx.moveTo(0, ab*H); ctx.lineTo(W, ab*H); } - else { ctx.moveTo(ab*W, 0); ctx.lineTo(ab*W, H); } + if (axis === 'y') { + ctx.moveTo(0, ab * H); + ctx.lineTo(W, ab * H); + } else { + ctx.moveTo(ab * W, 0); + ctx.lineTo(ab * W, H); + } ctx.stroke(); } }; @@ -151,46 +190,61 @@ function drawBnd(dragIdx = -1, dragVal = null) { } // ── Drag machinery ─────────────────────────────────────────────────────────── -let _dragIdx = -1, _dragging = false; +let _dragIdx = -1, + _dragging = false; function fracFromEvt(e) { const r = _bnd.canvas.getBoundingClientRect(); const x = (e.clientX - r.left) / r.width; - const y = (e.clientY - r.top) / r.height; + const y = (e.clientY - r.top) / r.height; return _bnd.axis === 'y' ? y : x; } function nearestBnd(frac) { - const {boundaries, canvas, axis} = _bnd; + const { boundaries, canvas, axis } = _bnd; const r = canvas.getBoundingClientRect(); const dim = axis === 'y' ? r.height : r.width; const thresh = (window._grabPx ?? 14) / dim; - let best = -1, bestD = thresh; - boundaries.forEach((b,i) => { const d=Math.abs(b-frac); if(d { + const d = Math.abs(b - frac); + if (d < bestD) { + bestD = d; + best = i; + } + }); return best; } function snapToAi(frac) { if (!_bnd?.selectedPlugin) return frac; - const {pluginResults, selectedPlugin} = _bnd; - const snapBounds = selectedPlugin === 'all' - ? Object.values(pluginResults).flat() - : (pluginResults[selectedPlugin] || []); + const { pluginResults, selectedPlugin } = _bnd; + const snapBounds = + selectedPlugin === 'all' ? Object.values(pluginResults).flat() : pluginResults[selectedPlugin] || []; if (!snapBounds.length) return frac; const r = _bnd.canvas.getBoundingClientRect(); const dim = _bnd.axis === 'y' ? r.height : r.width; const thresh = (window._grabPx ?? 14) / dim; - let best = frac, bestD = thresh; - snapBounds.forEach(ab => { const d = Math.abs(ab - frac); if (d < bestD) { bestD = d; best = ab; } }); + let best = frac, + bestD = thresh; + snapBounds.forEach((ab) => { + const d = Math.abs(ab - frac); + if (d < bestD) { + bestD = d; + best = ab; + } + }); return best; } function bndPointerDown(e) { if (!_bnd || S._cropMode) return; const frac = fracFromEvt(e); - const idx = nearestBnd(frac); + const idx = nearestBnd(frac); if (idx >= 0) { - _dragIdx = idx; _dragging = true; + _dragIdx = idx; + _dragging = true; _bnd.canvas.setPointerCapture(e.pointerId); e.stopPropagation(); } @@ -200,8 +254,7 @@ function bndPointerMove(e) { if (!_bnd || S._cropMode) return; const frac = fracFromEvt(e); const near = nearestBnd(frac); - _bnd.canvas.style.cursor = (near >= 0 || _dragging) - ? (_bnd.axis==='y' ? 'ns-resize' : 'ew-resize') : 'default'; + _bnd.canvas.style.cursor = near >= 0 || _dragging ? (_bnd.axis === 'y' ? 'ns-resize' : 'ew-resize') : 'default'; if (_dragging && _dragIdx >= 0) drawBnd(_dragIdx, frac); } @@ -209,22 +262,24 @@ async function bndPointerUp(e) { if (!_dragging || !_bnd || S._cropMode) return; const frac = fracFromEvt(e); _dragging = false; - const {boundaries, nodeId, nodeType} = _bnd; + const { boundaries, nodeId, nodeType } = _bnd; const full = [0, ...boundaries, 1]; - const clamped = Math.max(full[_dragIdx]+0.005, Math.min(full[_dragIdx+2]-0.005, frac)); + const clamped = Math.max(full[_dragIdx] + 0.005, Math.min(full[_dragIdx + 2] - 0.005, frac)); boundaries[_dragIdx] = Math.round(snapToAi(clamped) * 10000) / 10000; _bnd.boundaries = [...boundaries]; _dragIdx = -1; drawBnd(); - const url = nodeType==='cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`; + const url = nodeType === 'cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`; try { - await req('PATCH', url, {boundaries}); + await req('PATCH', url, { boundaries }); const node = findNode(nodeId); 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); } - } catch(err) { toast('Save failed: ' + err.message); } + } catch (err) { + toast('Save failed: ' + err.message); + } } async function bndClick(e) { @@ -232,40 +287,59 @@ async function bndClick(e) { if (!e.ctrlKey || !e.altKey) return; e.preventDefault(); const frac = snapToAi(fracFromEvt(e)); - const {boundaries, nodeId, nodeType} = _bnd; - const newBounds = [...boundaries, frac].sort((a,b)=>a-b); + const { boundaries, nodeId, nodeType } = _bnd; + const newBounds = [...boundaries, frac].sort((a, b) => a - b); _bnd.boundaries = newBounds; - const url = nodeType==='cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`; + const url = nodeType === 'cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`; try { - await req('PATCH', url, {boundaries: newBounds}); + await req('PATCH', url, { boundaries: newBounds }); if (nodeType === 'cabinet') { const s = await req('POST', `/api/cabinets/${nodeId}/shelves`, null); - S.tree.forEach(r=>r.cabinets.forEach(c=>{ if(c.id===nodeId){ - c.shelf_boundaries=JSON.stringify(newBounds); c.shelves.push({...s,books:[]}); - }})); + S.tree.forEach((r) => + r.cabinets.forEach((c) => { + if (c.id === nodeId) { + c.shelf_boundaries = JSON.stringify(newBounds); + c.shelves.push({ ...s, books: [] }); + } + }), + ); } else { const b = await req('POST', `/api/shelves/${nodeId}/books`); - S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>{ if(s.id===nodeId){ - s.book_boundaries=JSON.stringify(newBounds); s.books.push(b); - }}))); + S.tree.forEach((r) => + r.cabinets.forEach((c) => + c.shelves.forEach((s) => { + if (s.id === nodeId) { + s.book_boundaries = JSON.stringify(newBounds); + s.books.push(b); + } + }), + ), + ); } render(); - } catch(err) { toast('Error: ' + err.message); } + } catch (err) { + toast('Error: ' + err.message); + } } function bndHover(e) { if (!_bnd || S._cropMode) return; const frac = fracFromEvt(e); - const {boundaries, segments} = _bnd; + const { boundaries, segments } = _bnd; const full = [0, ...boundaries, 1]; let segIdx = -1; - for (let i = 0; i < full.length-1; i++) { if(frac>=full[i]&&frac= full[i] && frac < full[i + 1]) { + segIdx = i; + break; + } + } clearSegHover(); - if (segIdx>=0 && segments[segIdx]) { + if (segIdx >= 0 && segments[segIdx]) { document.querySelector(`.node[data-id="${segments[segIdx].id}"] .nrow`)?.classList.add('seg-hover'); } } function clearSegHover() { - document.querySelectorAll('.seg-hover').forEach(el=>el.classList.remove('seg-hover')); + document.querySelectorAll('.seg-hover').forEach((el) => el.classList.remove('seg-hover')); } diff --git a/static/js/canvas-crop.js b/static/js/canvas-crop.js index 472b7b8..3b243ab 100644 --- a/static/js/canvas-crop.js +++ b/static/js/canvas-crop.js @@ -13,28 +13,31 @@ * Provides: startCropMode(), cancelCrop(), confirmCrop() */ +/* exported startCropMode */ + // ── Crop state ─────────────────────────────────────────────────────────────── -let _cropState = null; // {x1,y1,x2,y2} fractions; null = not in crop mode -let _cropDragPart = null; // 'tl','tr','bl','br','t','b','l','r','move' | null -let _cropDragStart = null; // {fx,fy,x1,y1,x2,y2} snapshot at drag start +let _cropState = null; // {x1,y1,x2,y2} fractions; null = not in crop mode +let _cropDragPart = null; // 'tl','tr','bl','br','t','b','l','r','move' | null +let _cropDragStart = null; // {fx,fy,x1,y1,x2,y2} snapshot at drag start // ── Public entry point ─────────────────────────────────────────────────────── function startCropMode(type, id) { const canvas = document.getElementById('bnd-canvas'); - const wrap = document.getElementById('bnd-wrap'); + const wrap = document.getElementById('bnd-wrap'); if (!canvas || !wrap) return; - S._cropMode = {type, id}; - _cropState = {x1: 0.05, y1: 0.05, x2: 0.95, y2: 0.95}; + S._cropMode = { type, id }; + _cropState = { x1: 0.05, y1: 0.05, x2: 0.95, y2: 0.95 }; canvas.addEventListener('pointerdown', cropPointerDown); canvas.addEventListener('pointermove', cropPointerMove); - canvas.addEventListener('pointerup', cropPointerUp); + canvas.addEventListener('pointerup', cropPointerUp); document.getElementById('crop-bar')?.remove(); const bar = document.createElement('div'); bar.id = 'crop-bar'; bar.style.cssText = 'margin-top:10px;display:flex;gap:8px'; - bar.innerHTML = ''; + bar.innerHTML = + ''; wrap.after(bar); document.getElementById('crop-ok').addEventListener('click', confirmCrop); document.getElementById('crop-cancel').addEventListener('click', cancelCrop); @@ -47,63 +50,81 @@ function drawCropOverlay() { const canvas = document.getElementById('bnd-canvas'); if (!canvas || !_cropState) return; const ctx = canvas.getContext('2d'); - const W = canvas.width, H = canvas.height; - const {x1, y1, x2, y2} = _cropState; - const px1=x1*W, py1=y1*H, px2=x2*W, py2=y2*H; + const W = canvas.width, + H = canvas.height; + const { x1, y1, x2, y2 } = _cropState; + const px1 = x1 * W, + py1 = y1 * H, + px2 = x2 * W, + py2 = y2 * H; ctx.clearRect(0, 0, W, H); // Dark shadow outside crop rect ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(0, 0, W, H); - ctx.clearRect(px1, py1, px2-px1, py2-py1); + ctx.clearRect(px1, py1, px2 - px1, py2 - py1); // Bright border - ctx.strokeStyle = '#38bdf8'; ctx.lineWidth = 2; ctx.setLineDash([]); - ctx.strokeRect(px1, py1, px2-px1, py2-py1); + ctx.strokeStyle = '#38bdf8'; + ctx.lineWidth = 2; + ctx.setLineDash([]); + ctx.strokeRect(px1, py1, px2 - px1, py2 - py1); // Corner handles const hs = 9; ctx.fillStyle = '#38bdf8'; - [[px1,py1],[px2,py1],[px1,py2],[px2,py2]].forEach(([x,y]) => ctx.fillRect(x-hs/2, y-hs/2, hs, hs)); + [ + [px1, py1], + [px2, py1], + [px1, py2], + [px2, py2], + ].forEach(([x, y]) => ctx.fillRect(x - hs / 2, y - hs / 2, hs, hs)); } // ── Hit testing ────────────────────────────────────────────────────────────── function _cropFracFromEvt(e) { const canvas = document.getElementById('bnd-canvas'); const r = canvas.getBoundingClientRect(); - return {fx: (e.clientX-r.left)/r.width, fy: (e.clientY-r.top)/r.height}; + return { fx: (e.clientX - r.left) / r.width, fy: (e.clientY - r.top) / r.height }; } function _getCropPart(fx, fy) { if (!_cropState) return null; - const {x1, y1, x2, y2} = _cropState; + const { x1, y1, x2, y2 } = _cropState; const th = 0.05; - const inX=fx>=x1&&fx<=x2, inY=fy>=y1&&fy<=y2; - const nX1=Math.abs(fx-x1)= x1 && fx <= x2, + inY = fy >= y1 && fy <= y2; + const nX1 = Math.abs(fx - x1) < th, + nX2 = Math.abs(fx - x2) < th; + const nY1 = Math.abs(fy - y1) < th, + nY2 = Math.abs(fy - y2) < th; + if (nX1 && nY1) return 'tl'; + if (nX2 && nY1) return 'tr'; + if (nX1 && nY2) return 'bl'; + if (nX2 && nY2) return 'br'; + if (nY1 && inX) return 't'; + if (nY2 && inX) return 'b'; + if (nX1 && inY) return 'l'; + if (nX2 && inY) return 'r'; + if (inX && inY) return 'move'; return null; } function _cropPartCursor(part) { if (!part) return 'crosshair'; - if (part==='move') return 'move'; - if (part==='tl'||part==='br') return 'nwse-resize'; - if (part==='tr'||part==='bl') return 'nesw-resize'; - if (part==='t'||part==='b') return 'ns-resize'; + if (part === 'move') return 'move'; + if (part === 'tl' || part === 'br') return 'nwse-resize'; + if (part === 'tr' || part === 'bl') return 'nesw-resize'; + if (part === 't' || part === 'b') return 'ns-resize'; return 'ew-resize'; } // ── Pointer events ─────────────────────────────────────────────────────────── function cropPointerDown(e) { if (!_cropState) return; - const {fx, fy} = _cropFracFromEvt(e); + const { fx, fy } = _cropFracFromEvt(e); const part = _getCropPart(fx, fy); if (part) { _cropDragPart = part; - _cropDragStart = {fx, fy, ..._cropState}; + _cropDragStart = { fx, fy, ..._cropState }; document.getElementById('bnd-canvas').setPointerCapture(e.pointerId); } } @@ -111,19 +132,23 @@ function cropPointerDown(e) { function cropPointerMove(e) { if (!_cropState) return; const canvas = document.getElementById('bnd-canvas'); - const {fx, fy} = _cropFracFromEvt(e); + const { fx, fy } = _cropFracFromEvt(e); if (_cropDragPart && _cropDragStart) { - const dx=fx-_cropDragStart.fx, dy=fy-_cropDragStart.fy; - const s = {..._cropState}; - if (_cropDragPart==='move') { - const w=_cropDragStart.x2-_cropDragStart.x1, h=_cropDragStart.y2-_cropDragStart.y1; - s.x1=Math.max(0,Math.min(1-w,_cropDragStart.x1+dx)); s.y1=Math.max(0,Math.min(1-h,_cropDragStart.y1+dy)); - s.x2=s.x1+w; s.y2=s.y1+h; + const dx = fx - _cropDragStart.fx, + dy = fy - _cropDragStart.fy; + const s = { ..._cropState }; + if (_cropDragPart === 'move') { + const w = _cropDragStart.x2 - _cropDragStart.x1, + h = _cropDragStart.y2 - _cropDragStart.y1; + s.x1 = Math.max(0, Math.min(1 - w, _cropDragStart.x1 + dx)); + s.y1 = Math.max(0, Math.min(1 - h, _cropDragStart.y1 + dy)); + s.x2 = s.x1 + w; + s.y2 = s.y1 + h; } else { - if (_cropDragPart.includes('l')) s.x1=Math.max(0,Math.min(_cropDragStart.x2-0.05,_cropDragStart.x1+dx)); - if (_cropDragPart.includes('r')) s.x2=Math.min(1,Math.max(_cropDragStart.x1+0.05,_cropDragStart.x2+dx)); - if (_cropDragPart.includes('t')) s.y1=Math.max(0,Math.min(_cropDragStart.y2-0.05,_cropDragStart.y1+dy)); - if (_cropDragPart.includes('b')) s.y2=Math.min(1,Math.max(_cropDragStart.y1+0.05,_cropDragStart.y2+dy)); + if (_cropDragPart.includes('l')) s.x1 = Math.max(0, Math.min(_cropDragStart.x2 - 0.05, _cropDragStart.x1 + dx)); + if (_cropDragPart.includes('r')) s.x2 = Math.min(1, Math.max(_cropDragStart.x1 + 0.05, _cropDragStart.x2 + dx)); + if (_cropDragPart.includes('t')) s.y1 = Math.max(0, Math.min(_cropDragStart.y2 - 0.05, _cropDragStart.y1 + dy)); + if (_cropDragPart.includes('b')) s.y2 = Math.min(1, Math.max(_cropDragStart.y1 + 0.05, _cropDragStart.y2 + dy)); } _cropState = s; drawCropOverlay(); @@ -133,34 +158,53 @@ function cropPointerMove(e) { } } -function cropPointerUp() { _cropDragPart = null; _cropDragStart = null; } +function cropPointerUp() { + _cropDragPart = null; + _cropDragStart = null; +} // ── Confirm / cancel ───────────────────────────────────────────────────────── async function confirmCrop() { if (!_cropState || !S._cropMode) return; const img = document.getElementById('bnd-img'); if (!img) return; - const {x1, y1, x2, y2} = _cropState; - const W=img.naturalWidth, H=img.naturalHeight; - const px = {x:Math.round(x1*W), y:Math.round(y1*H), w:Math.round((x2-x1)*W), h:Math.round((y2-y1)*H)}; - if (px.w<10||px.h<10) { toast('Selection too small'); return; } - const {type, id} = S._cropMode; - const url = type==='cabinet' ? `/api/cabinets/${id}/crop` : `/api/shelves/${id}/crop`; + const { x1, y1, x2, y2 } = _cropState; + const W = img.naturalWidth, + H = img.naturalHeight; + const px = { + x: Math.round(x1 * W), + y: Math.round(y1 * H), + w: Math.round((x2 - x1) * W), + h: Math.round((y2 - y1) * H), + }; + if (px.w < 10 || px.h < 10) { + toast('Selection too small'); + return; + } + const { type, id } = S._cropMode; + const url = type === 'cabinet' ? `/api/cabinets/${id}/crop` : `/api/shelves/${id}/crop`; try { await req('POST', url, px); - toast('Cropped'); cancelCrop(); render(); - } catch(err) { toast('Crop failed: '+err.message); } + toast('Cropped'); + cancelCrop(); + render(); + } catch (err) { + toast('Crop failed: ' + err.message); + } } 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(); const canvas = document.getElementById('bnd-canvas'); if (canvas) { canvas.removeEventListener('pointerdown', cropPointerDown); canvas.removeEventListener('pointermove', cropPointerMove); - canvas.removeEventListener('pointerup', cropPointerUp); + canvas.removeEventListener('pointerup', cropPointerUp); canvas.style.cursor = ''; } - drawBnd(); // restore boundary overlay + drawBnd(); // restore boundary overlay } diff --git a/static/js/detail-render.js b/static/js/detail-render.js index 848b73c..747c19d 100644 --- a/static/js/detail-render.js +++ b/static/js/detail-render.js @@ -11,26 +11,76 @@ * vShelfDetail(), vBookDetail() */ +/* exported vDetailBody, aiBlocksShown */ + // ── Room detail ────────────────────────────────────────────────────────────── function vRoomDetail(r) { const stats = getBookStats(r, 'room'); const totalBooks = stats.total; return `
${vAiProgressBar(stats)} -

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

+

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

+
`; +} + +// ── 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 + ? `${esc(entry.model)}` + : ''; + const isBook = entry.entity_type === 'books'; + const entityLabel = isBook + ? `` + : `${esc(entry.entity_id.slice(0, 8))}`; + const thumb = isBook + ? `` + : ''; + return `
+ + ${statusLabel} + + ${esc(entry.plugin_id)} · ${entityLabel}${thumb} + + ${ts}${dur} + +
+ ${model} + ${entry.request ? `
Request: ${esc(entry.request)}
` : ''} + ${entry.response ? `
Response: ${esc(entry.response)}
` : ''} +
+
`; +} + +function vRootDetail() { + const log = (_aiLog || []).slice().reverse(); // newest first + return `
+
AI Request Log
+ ${ + log.length === 0 + ? `
No AI requests yet. Use Identify or run a plugin on a book.
` + : log.map(vAiLogEntry).join('
') + }
`; } // ── Detail body (right panel) ──────────────────────────────────────────────── function vDetailBody() { - if (!S.selected) return '
← Select a room, cabinet or shelf from the tree
'; - const {type, id} = S.selected; + if (!S.selected) return `
${vRootDetail()}
`; + const { type, id } = S.selected; const node = findNode(id); if (!node) return '
Not found
'; - if (type === 'room') return vRoomDetail(node); + if (type === 'room') return vRoomDetail(node); if (type === 'cabinet') return vCabinetDetail(node); - if (type === 'shelf') return vShelfDetail(node); - if (type === 'book') return vBookDetail(node); + if (type === 'shelf') return vShelfDetail(node); + if (type === 'book') return vBookDetail(node); return ''; } @@ -42,29 +92,34 @@ function vCabinetDetail(cab) { const bndPlugins = pluginsByTarget('boundary_detector', 'shelves'); const pluginResults = parseBndPluginResults(cab.ai_shelf_boundaries); const pluginIds = Object.keys(pluginResults); - const sel = (_bnd?.nodeId === cab.id) ? _bnd.selectedPlugin - : (cab.shelves.length > 0 ? null : pluginIds[0] ?? null); + const sel = _bnd?.nodeId === cab.id ? _bnd.selectedPlugin : cab.shelves.length > 0 ? null : (pluginIds[0] ?? null); const selOpts = [ ``, - ...pluginIds.map(pid => ``), - ...(pluginIds.length > 1 ? [``] : []), + ...pluginIds.map((pid) => ``), + ...(pluginIds.length > 1 ? [``] : []), ].join(''); return `
${vAiProgressBar(stats)} - ${hasPhoto - ? `
+ ${ + hasPhoto + ? `
` - : `
📷
Upload a cabinet photo (📷 in header) to get started
`} + : `
📷
Upload a cabinet photo (📷 in header) to get started
` + } ${hasPhoto ? `

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

` : ''} - ${hasPhoto ? `
- ${bounds.length ? `${cab.shelves.length} shelf${cab.shelves.length!==1?'s':''} · ${bounds.length} boundar${bounds.length!==1?'ies':'y'}` : ''} + ${ + hasPhoto + ? `
+ ${bounds.length ? `${cab.shelves.length} shelf${cab.shelves.length !== 1 ? 's' : ''} · ${bounds.length} boundar${bounds.length !== 1 ? 'ies' : 'y'}` : ''}
- ${bndPlugins.map(p => vPluginBtn(p, cab.id, 'cabinets')).join('')} + ${bndPlugins.map((p) => vPluginBtn(p, cab.id, 'cabinets')).join('')}
-
` : ''} +
` + : '' + }
`; } @@ -75,12 +130,11 @@ function vShelfDetail(shelf) { const bndPlugins = pluginsByTarget('boundary_detector', 'books'); const pluginResults = parseBndPluginResults(shelf.ai_book_boundaries); const pluginIds = Object.keys(pluginResults); - const sel = (_bnd?.nodeId === shelf.id) ? _bnd.selectedPlugin - : (shelf.books.length > 0 ? null : pluginIds[0] ?? null); + const sel = _bnd?.nodeId === shelf.id ? _bnd.selectedPlugin : shelf.books.length > 0 ? null : (pluginIds[0] ?? null); const selOpts = [ ``, - ...pluginIds.map(pid => ``), - ...(pluginIds.length > 1 ? [``] : []), + ...pluginIds.map((pid) => ``), + ...(pluginIds.length > 1 ? [``] : []), ].join(''); return `
${vAiProgressBar(stats)} @@ -91,72 +145,115 @@ function vShelfDetail(shelf) {

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

- ${bounds.length ? `${shelf.books.length} book${shelf.books.length!==1?'s':''} · ${bounds.length} boundary${bounds.length!==1?'ies':''}` : ''} + ${bounds.length ? `${shelf.books.length} book${shelf.books.length !== 1 ? 's' : ''} · ${bounds.length} boundary${bounds.length !== 1 ? 'ies' : ''}` : ''}
- ${bndPlugins.map(p => vPluginBtn(p, shelf.id, 'shelves')).join('')} + ${bndPlugins.map((p) => vPluginBtn(p, shelf.id, 'shelves')).join('')}
`; } +// ── 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]) => + `
${k} ${esc(v)}
`, + ) + .join(''); + const blockData = esc(JSON.stringify(block)); + return `
+
+ ${score ? `${score}` : ''} + ${sources ? `${esc(sources)}` : ''} +
+ ${rows} +
`; +} + // ── Book detail ────────────────────────────────────────────────────────────── function vBookDetail(b) { - const [sc, sl] = _STATUS_BADGE[b.identification_status] ?? _STATUS_BADGE.unidentified; - const recognizers = pluginsByCategory('text_recognizer'); - const identifiers = pluginsByCategory('book_identifier'); - const searchers = pluginsByCategory('archive_searcher'); - const hasRawText = !!(b.raw_text || '').trim(); + const [sc, sl] = _STATUS_BADGE[b.identification_status] ?? _STATUS_BADGE.unidentified; + const isLoading_ = isLoading('identify', b.id); + const blocks = parseAiBlocks(b.ai_blocks); + const shown = aiBlocksShown(b); + const spineUrl = `/api/books/${b.id}/spine?t=${Date.now()}`; + const titleUrl = b.image_filename ? `/images/${b.image_filename}` : ''; return `
Spine
-
- ${b.image_filename - ? `
Title page
-
` - : ''} +
+ +
+ ${ + titleUrl + ? `
Title page
+
+ +
` + : '' + }
-
+
${sl} ${b.identification_status ?? 'unidentified'} - ${b.analyzed_at ? `Identified ${b.analyzed_at.slice(0,10)}` : ''} + ${b.analyzed_at ? `Identified ${b.analyzed_at.slice(0, 10)}` : ''} +
-
- - -
- ${searchers.length ? `
- -
` : ''} -
- ${candidateSugRows(b, 'title', 'd-title')} - + ${ + blocks.length + ? `
+
+ AI Results (${blocks.length}) + +
+ ${shown ? blocks.map((bl) => vAiBlock(bl, b.id)).join('') : ''} +
` + : '' + } +
-
- ${candidateSugRows(b, 'author', 'd-author')} - +
-
- ${candidateSugRows(b, 'year', 'd-year')} - +
-
- ${candidateSugRows(b, 'isbn', 'd-isbn')} - +
-
- ${candidateSugRows(b, 'publisher', 'd-pub')} - +
diff --git a/static/js/editing.js b/static/js/editing.js index 43b53dc..1d46cbd 100644 --- a/static/js/editing.js +++ b/static/js/editing.js @@ -12,54 +12,84 @@ * Provides: attachEditables(), initSortables() */ +/* exported attachEditables, initSortables */ + // ── SortableJS instances (destroyed and recreated on each render) ───────────── let _sortables = []; // ── Inline name editing ────────────────────────────────────────────────────── function attachEditables() { - document.querySelectorAll('[contenteditable=true]').forEach(el => { + document.querySelectorAll('[contenteditable=true]').forEach((el) => { el.dataset.orig = el.textContent.trim(); - el.addEventListener('keydown', e => { - if (e.key==='Enter') { e.preventDefault(); el.blur(); } - if (e.key==='Escape') { el.textContent=el.dataset.orig; el.blur(); } + el.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + el.blur(); + } + if (e.key === 'Escape') { + el.textContent = el.dataset.orig; + el.blur(); + } e.stopPropagation(); }); el.addEventListener('blur', async () => { const val = el.textContent.trim(); - if (!val||val===el.dataset.orig) { if(!val) el.textContent=el.dataset.orig; return; } - const newName = val.replace(/^[🏠📚]\s*/u,'').trim(); - const {type, id} = el.dataset; - const url = {room:`/api/rooms/${id}`,cabinet:`/api/cabinets/${id}`,shelf:`/api/shelves/${id}`}[type]; + if (!val || val === el.dataset.orig) { + if (!val) el.textContent = el.dataset.orig; + return; + } + const newName = val.replace(/^[🏠📚]\s*/u, '').trim(); + const { type, id } = el.dataset; + const url = { room: `/api/rooms/${id}`, cabinet: `/api/cabinets/${id}`, shelf: `/api/shelves/${id}` }[type]; if (!url) return; try { - await req('PUT', url, {name: newName}); + await req('PUT', url, { name: newName }); 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) const sideLabel = document.querySelector(`.node[data-id="${id}"] .nname`); if (sideLabel && sideLabel !== el) { - const prefix = type==='room' ? '🏠 ' : type==='cabinet' ? '📚 ' : ''; + const prefix = type === 'room' ? '🏠 ' : type === 'cabinet' ? '📚 ' : ''; 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 ───────────────────────────────────────────────── function initSortables() { - _sortables.forEach(s=>{ try{s.destroy();}catch(_){} }); + _sortables.forEach((s) => { + try { + s.destroy(); + } catch (_) { + // ignore destroy errors on stale instances + } + }); _sortables = []; - document.querySelectorAll('.sortable-list').forEach(el => { + document.querySelectorAll('.sortable-list').forEach((el) => { const type = el.dataset.type; - _sortables.push(Sortable.create(el, { - handle:'.drag-h', animation:120, ghostClass:'drag-ghost', - onEnd: async () => { - const ids = [...el.querySelectorAll(':scope > .node')].map(n=>n.dataset.id); - try { await req('PATCH',`/api/${type}/reorder`,{ids}); } - catch(err) { toast('Reorder failed'); await loadTree(); } - }, - })); + _sortables.push( + Sortable.create(el, { + handle: '.drag-h', + animation: 120, + ghostClass: 'drag-ghost', + onEnd: async () => { + const ids = [...el.querySelectorAll(':scope > .node')].map((n) => n.dataset.id); + try { + await req('PATCH', `/api/${type}/reorder`, { ids }); + } catch (_err) { + toast('Reorder failed'); + await loadTree(); + } + }, + }), + ); }); } diff --git a/static/js/events.js b/static/js/events.js index 811d96e..fbcff58 100644 --- a/static/js/events.js +++ b/static/js/events.js @@ -12,7 +12,7 @@ * Depends on: S, _bnd, _batchState, _photoQueue (state.js); * req (api.js); toast, isDesktop (helpers.js); * walkTree, removeNode, findNode, parseBounds (tree-render.js / - * canvas-boundary.js); render, renderDetail, startBatchPolling + * canvas-boundary.js); render, renderDetail, connectBatchWs * (init.js); startCropMode (canvas-crop.js); * triggerPhoto, collectQueueBooks, renderPhotoQueue (photo.js); * drawBnd (canvas-boundary.js) @@ -22,53 +22,61 @@ // ── Accordion helpers ──────────────────────────────────────────────────────── function getSiblingIds(id, type) { if (!S.tree) return []; - if (type === 'room') return S.tree.filter(r => r.id !== id).map(r => r.id); + if (type === 'room') return S.tree.filter((r) => r.id !== id).map((r) => r.id); for (const r of S.tree) { - if (type === 'cabinet' && r.cabinets.some(c => c.id === id)) - return r.cabinets.filter(c => c.id !== id).map(c => c.id); + if (type === 'cabinet' && r.cabinets.some((c) => c.id === id)) + return r.cabinets.filter((c) => c.id !== id).map((c) => c.id); for (const c of r.cabinets) { - if (type === 'shelf' && c.shelves.some(s => s.id === id)) - return c.shelves.filter(s => s.id !== id).map(s => s.id); + if (type === 'shelf' && c.shelves.some((s) => s.id === id)) + return c.shelves.filter((s) => s.id !== id).map((s) => s.id); } } return []; } function accordionExpand(id, type) { - if (!isDesktop()) getSiblingIds(id, type).forEach(sid => S.expanded.delete(sid)); + if (!isDesktop()) getSiblingIds(id, type).forEach((sid) => S.expanded.delete(sid)); S.expanded.add(id); } // ── Event delegation ───────────────────────────────────────────────────────── -document.getElementById('app').addEventListener('click', async e => { +document.getElementById('app').addEventListener('click', async (e) => { const el = e.target.closest('[data-a]'); if (!el) return; const d = el.dataset; - try { await handle(d.a, d, e); } - catch(err) { toast('Error: '+err.message); } + try { + 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]'); if (!el) return; const d = el.dataset; - try { await handle(d.a, d, e); } - catch(err) { toast('Error: '+err.message); } + try { + await handle(d.a, d, e); + } catch (err) { + toast('Error: ' + err.message); + } }); // Photo queue overlay is outside #app so needs its own listener -document.getElementById('photo-queue-overlay').addEventListener('click', async e => { +document.getElementById('photo-queue-overlay').addEventListener('click', async (e) => { const el = e.target.closest('[data-a]'); if (!el) return; const d = el.dataset; - try { await handle(d.a, d, e); } - catch(err) { toast('Error: ' + err.message); } + try { + await handle(d.a, d, e); + } catch (err) { + toast('Error: ' + err.message); + } }); // ── Action dispatcher ──────────────────────────────────────────────────────── async function handle(action, d, e) { switch (action) { - case 'select': { // Ignore if the click hit a button or editable inside the row if (e?.target?.closest('button,[contenteditable]')) return; @@ -80,14 +88,16 @@ async function handle(action, d, e) { } break; } - S.selected = {type: d.type, id: d.id}; + S.selected = { type: d.type, id: d.id }; S._loading = {}; - render(); break; + render(); + break; } case 'deselect': { S.selected = null; - render(); break; + render(); + break; } case 'toggle': { @@ -95,168 +105,329 @@ async function handle(action, d, e) { // Mobile: expand-only (no collapse to avoid accidental mistaps) accordionExpand(d.id, d.type); } else { - if (S.expanded.has(d.id)) { S.expanded.delete(d.id); } - else { S.expanded.add(d.id); } + if (S.expanded.has(d.id)) { + S.expanded.delete(d.id); + } else { + S.expanded.add(d.id); + } } - render(); break; + render(); + break; } // Rooms case 'add-room': { - const r = await req('POST','/api/rooms'); - if (!S.tree) S.tree=[]; - S.tree.push({...r, cabinets:[]}); - S.expanded.add(r.id); render(); break; + const r = await req('POST', '/api/rooms'); + if (!S.tree) S.tree = []; + S.tree.push({ ...r, cabinets: [] }); + S.expanded.add(r.id); + render(); + break; } case 'del-room': { if (!confirm('Delete room and all contents?')) break; - await req('DELETE',`/api/rooms/${d.id}`); - removeNode('room',d.id); - if (S.selected?.id===d.id) S.selected=null; - render(); break; + await req('DELETE', `/api/rooms/${d.id}`); + removeNode('room', d.id); + if (S.selected?.id === d.id) S.selected = null; + render(); + break; } // Cabinets case 'add-cabinet': { - const c = await req('POST',`/api/rooms/${d.id}/cabinets`); - S.tree.forEach(r=>{ if(r.id===d.id) r.cabinets.push({...c,shelves:[]}); }); - S.expanded.add(d.id); render(); break; // expand parent room + const c = await req('POST', `/api/rooms/${d.id}/cabinets`); + S.tree.forEach((r) => { + if (r.id === d.id) r.cabinets.push({ ...c, shelves: [] }); + }); + S.expanded.add(d.id); + render(); + break; // expand parent room } case 'del-cabinet': { if (!confirm('Delete cabinet and all contents?')) break; - await req('DELETE',`/api/cabinets/${d.id}`); - removeNode('cabinet',d.id); - if (S.selected?.id===d.id) S.selected=null; - render(); break; + await req('DELETE', `/api/cabinets/${d.id}`); + removeNode('cabinet', d.id); + if (S.selected?.id === d.id) S.selected = null; + render(); + break; } // Shelves case 'add-shelf': { const cab = findNode(d.id); const prevCount = cab ? cab.shelves.length : 0; - const s = await req('POST',`/api/cabinets/${d.id}/shelves`); - S.tree.forEach(r=>r.cabinets.forEach(c=>{ if(c.id===d.id) c.shelves.push({...s,books:[]}); })); + const s = await req('POST', `/api/cabinets/${d.id}/shelves`); + S.tree.forEach((r) => + r.cabinets.forEach((c) => { + if (c.id === d.id) c.shelves.push({ ...s, books: [] }); + }), + ); if (prevCount > 0) { // Split last segment in half to make room for new shelf const bounds = parseBounds(cab.shelf_boundaries); - const lastStart = bounds.length ? bounds[bounds.length-1] : 0.0; - const newBound = Math.round((lastStart + 1.0) / 2 * 10000) / 10000; + const lastStart = bounds.length ? bounds[bounds.length - 1] : 0.0; + const newBound = Math.round(((lastStart + 1.0) / 2) * 10000) / 10000; const newBounds = [...bounds, newBound]; - await req('PATCH', `/api/cabinets/${d.id}/boundaries`, {boundaries: newBounds}); - S.tree.forEach(r=>r.cabinets.forEach(c=>{ if(c.id===d.id) c.shelf_boundaries=JSON.stringify(newBounds); })); + await req('PATCH', `/api/cabinets/${d.id}/boundaries`, { boundaries: newBounds }); + S.tree.forEach((r) => + r.cabinets.forEach((c) => { + if (c.id === d.id) c.shelf_boundaries = JSON.stringify(newBounds); + }), + ); } - S.expanded.add(d.id); render(); break; // expand parent cabinet + S.expanded.add(d.id); + render(); + break; // expand parent cabinet } case 'del-shelf': { if (!confirm('Delete shelf and all books?')) break; - await req('DELETE',`/api/shelves/${d.id}`); - removeNode('shelf',d.id); - if (S.selected?.id===d.id) S.selected=null; - render(); break; + await req('DELETE', `/api/shelves/${d.id}`); + removeNode('shelf', d.id); + if (S.selected?.id === d.id) S.selected = null; + render(); + break; } // Books case 'add-book': { const shelf = findNode(d.id); const prevCount = shelf ? shelf.books.length : 0; - const b = await req('POST',`/api/shelves/${d.id}/books`); - S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>{ if(s.id===d.id) s.books.push(b); }))); + const b = await req('POST', `/api/shelves/${d.id}/books`); + S.tree.forEach((r) => + r.cabinets.forEach((c) => + c.shelves.forEach((s) => { + if (s.id === d.id) s.books.push(b); + }), + ), + ); if (prevCount > 0) { // Split last segment in half to make room for new book const bounds = parseBounds(shelf.book_boundaries); - const lastStart = bounds.length ? bounds[bounds.length-1] : 0.0; - const newBound = Math.round((lastStart + 1.0) / 2 * 10000) / 10000; + const lastStart = bounds.length ? bounds[bounds.length - 1] : 0.0; + const newBound = Math.round(((lastStart + 1.0) / 2) * 10000) / 10000; const newBounds = [...bounds, newBound]; - await req('PATCH', `/api/shelves/${d.id}/boundaries`, {boundaries: newBounds}); - S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>{ if(s.id===d.id) s.book_boundaries=JSON.stringify(newBounds); }))); + await req('PATCH', `/api/shelves/${d.id}/boundaries`, { boundaries: newBounds }); + S.tree.forEach((r) => + r.cabinets.forEach((c) => + c.shelves.forEach((s) => { + if (s.id === d.id) s.book_boundaries = JSON.stringify(newBounds); + }), + ), + ); } - S.expanded.add(d.id); render(); break; // expand parent shelf + S.expanded.add(d.id); + render(); + break; // expand parent shelf } case 'del-book': { if (!confirm('Delete this book?')) break; - await req('DELETE',`/api/books/${d.id}`); - removeNode('book',d.id); - if (S.selected?.id===d.id) S.selected=null; - render(); break; + await req('DELETE', `/api/books/${d.id}`); + removeNode('book', d.id); + if (S.selected?.id === d.id) S.selected = null; + render(); + break; } case 'del-book-confirm': { if (!confirm('Delete this book?')) break; - await req('DELETE',`/api/books/${d.id}`); - removeNode('book',d.id); - S.selected=null; render(); break; + await req('DELETE', `/api/books/${d.id}`); + removeNode('book', d.id); + S.selected = null; + render(); + break; } case 'save-book': { const data = { - title: document.getElementById('d-title')?.value || '', - author: document.getElementById('d-author')?.value || '', - year: document.getElementById('d-year')?.value || '', - isbn: document.getElementById('d-isbn')?.value || '', - publisher: document.getElementById('d-pub')?.value || '', - notes: document.getElementById('d-notes')?.value || '', + title: document.getElementById('d-title')?.value || '', + author: document.getElementById('d-author')?.value || '', + year: document.getElementById('d-year')?.value || '', + isbn: document.getElementById('d-isbn')?.value || '', + publisher: document.getElementById('d-pub')?.value || '', + notes: document.getElementById('d-notes')?.value || '', }; - const res = await req('PUT',`/api/books/${d.id}`,data); - walkTree(n => { + const res = await req('PUT', `/api/books/${d.id}`, data); + walkTree((n) => { if (n.id === d.id) { Object.assign(n, data); - n.ai_title = data.title; n.ai_author = data.author; n.ai_year = data.year; - n.ai_isbn = data.isbn; n.ai_publisher = data.publisher; + n.ai_title = data.title; + n.ai_author = data.author; + n.ai_year = data.year; + n.ai_isbn = data.isbn; + n.ai_publisher = data.publisher; n.identification_status = res.identification_status ?? n.identification_status; } }); - toast('Saved'); render(); break; + toast('Saved'); + render(); + break; } case 'run-plugin': { 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 { const res = await req('POST', `/api/${d.etype}/${d.id}/plugin/${d.plugin}`); - walkTree(n => { if (n.id === d.id) Object.assign(n, res); }); - } catch(err) { toast(`${d.plugin} failed: ${err.message}`); } - delete S._loading[key]; renderDetail(); + walkTree((n) => { + if (n.id !== d.id) return; + 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; } case 'select-bnd-plugin': { - if (_bnd) { _bnd.selectedPlugin = e.target.value || null; drawBnd(); } + if (_bnd) { + _bnd.selectedPlugin = e.target.value || null; + drawBnd(); + } break; } case 'accept-field': { const inp = document.getElementById(d.input); if (inp) inp.value = d.value; - walkTree(n => { if (n.id === d.id) n[d.field] = d.value; }); - renderDetail(); break; + walkTree((n) => { + if (n.id === d.id) n[d.field] = d.value; + }); + renderDetail(); + break; } case 'dismiss-field': { - const res = await req('POST', `/api/books/${d.id}/dismiss-field`, {field: d.field, value: d.value || ''}); - walkTree(n => { + const res = await req('POST', `/api/books/${d.id}/dismiss-field`, { field: d.field, value: d.value || '' }); + walkTree((n) => { if (n.id === d.id) { n.candidates = JSON.stringify(res.candidates || []); if (!d.value) n[`ai_${d.field}`] = n[d.field] || ''; n.identification_status = res.identification_status ?? n.identification_status; } }); - renderDetail(); break; + 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': { const res = await req('POST', '/api/batch'); - if (res.already_running) { toast('Batch already running'); break; } - if (!res.started) { toast('No unidentified books'); break; } - _batchState = {running: true, total: res.total, done: 0, errors: 0, current: ''}; - startBatchPolling(); renderDetail(); break; + if (res.already_running) { + toast(res.added > 0 ? `Added ${res.added} book(s) to batch` : 'Batch already running'); + if (!_batchWs) connectBatchWs(); + 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 - case 'photo': triggerPhoto(d.type, d.id); break; + case 'photo': + triggerPhoto(d.type, d.id); + break; // Crop - case 'crop-start': startCropMode(d.type, d.id); break; + case 'crop-start': + startCropMode(d.type, d.id); + break; // Photo queue case 'photo-queue-start': { const node = findNode(d.id); if (!node) break; const books = collectQueueBooks(node, d.type); - if (!books.length) { toast('No unidentified books'); break; } - _photoQueue = {books, index: 0, processing: false}; + if (!books.length) { + toast('No unidentified books'); + break; + } + _photoQueue = { books, index: 0, processing: false }; renderPhotoQueue(); break; } @@ -278,6 +449,5 @@ async function handle(action, d, e) { renderPhotoQueue(); break; } - } } diff --git a/static/js/helpers.js b/static/js/helpers.js index f816ba9..a09e7da 100644 --- a/static/js/helpers.js +++ b/static/js/helpers.js @@ -6,16 +6,25 @@ * Provides: esc(), toast(), isDesktop() */ +/* exported esc, toast, isDesktop */ + // ── Helpers ───────────────────────────────────────────────────────────────── function esc(s) { - return String(s ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); } function toast(msg, dur = 2800) { const el = document.getElementById('toast'); - el.textContent = msg; el.classList.add('on'); + el.textContent = msg; + el.classList.add('on'); clearTimeout(toast._t); toast._t = setTimeout(() => el.classList.remove('on'), dur); } -function isDesktop() { return window.innerWidth >= 768; } +function isDesktop() { + return window.innerWidth >= 768; +} diff --git a/static/js/init.js b/static/js/init.js index fa0f1a0..83dfbd1 100644 --- a/static/js/init.js +++ b/static/js/init.js @@ -10,16 +10,18 @@ * renderDetail() does a cheaper in-place update of the right panel only, * used during plugin runs and field edits to avoid re-rendering the sidebar. * - * Depends on: S, _plugins, _batchState, _batchPollTimer (state.js); + * Depends on: S, _plugins, _batchState, _batchWs (state.js); * req, toast (api.js / helpers.js); isDesktop (helpers.js); * vApp, vDetailBody, mainTitle, mainHeaderBtns, vBatchBtn * (tree-render.js / detail-render.js); * attachEditables, initSortables (editing.js); * setupDetailCanvas (canvas-boundary.js) - * Provides: render(), renderDetail(), loadConfig(), startBatchPolling(), + * Provides: render(), renderDetail(), loadConfig(), connectBatchWs(), * loadTree() */ +/* exported render, renderDetail, connectBatchWs, connectAiLogWs, loadTree */ + // ── Full re-render ──────────────────────────────────────────────────────────── function render() { if (document.activeElement?.contentEditable === 'true') return; @@ -37,46 +39,121 @@ function renderDetail() { const body = document.getElementById('main-body'); if (body) body.innerHTML = vDetailBody(); const t = document.getElementById('main-title'); - if (t) t.innerHTML = mainTitle(); // innerHTML: mainTitle() returns an HTML span + if (t) t.innerHTML = mainTitle(); // innerHTML: mainTitle() returns an HTML string const hb = document.getElementById('main-hdr-btns'); if (hb) hb.innerHTML = mainHeaderBtns(); - const bb = document.getElementById('main-hdr-batch'); - if (bb) bb.innerHTML = vBatchBtn(); - attachEditables(); // pick up the new editable span in the header + attachEditables(); // pick up the new editable span in the header requestAnimationFrame(setupDetailCanvas); } // ── Data loading ────────────────────────────────────────────────────────────── async function loadConfig() { try { - const cfg = await req('GET','/api/config'); + const cfg = await req('GET', '/api/config'); window._grabPx = cfg.boundary_grab_px ?? 14; window._confidenceThreshold = cfg.confidence_threshold ?? 0.8; + window._aiLogMax = cfg.ai_log_max_entries ?? 100; _plugins = cfg.plugins || []; - } catch { window._grabPx = 14; window._confidenceThreshold = 0.8; } + } catch { + window._grabPx = 14; + window._confidenceThreshold = 0.8; + window._aiLogMax = 100; + } } -function startBatchPolling() { - if (_batchPollTimer) clearInterval(_batchPollTimer); - _batchPollTimer = setInterval(async () => { - try { - const st = await req('GET', '/api/batch/status'); - _batchState = st; - const bb = document.getElementById('main-hdr-batch'); - if (bb) bb.innerHTML = vBatchBtn(); - if (!st.running) { - clearInterval(_batchPollTimer); _batchPollTimer = null; - toast(`Batch: ${st.done} done, ${st.errors} errors`); - await loadTree(); +function connectBatchWs() { + if (_batchWs) { + _batchWs.close(); + _batchWs = null; + } + 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; + const bb = document.getElementById('main-hdr-batch'); + if (bb) bb.innerHTML = vBatchBtn(); + if (!st.running) { + ws.close(); + _batchWs = null; + toast(`Batch: ${st.done} done, ${st.errors} errors`); + await loadTree(); + } + }; + 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); } - } catch { /* ignore poll errors */ } - }, 2000); + } 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() { - S.tree = await req('GET','/api/tree'); + S.tree = await req('GET', '/api/tree'); render(); } // ── 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()); diff --git a/static/js/photo.js b/static/js/photo.js index d03a930..c0c3ef7 100644 --- a/static/js/photo.js +++ b/static/js/photo.js @@ -21,6 +21,8 @@ * Provides: collectQueueBooks(), renderPhotoQueue(), triggerPhoto() */ +/* exported collectQueueBooks, renderPhotoQueue, triggerPhoto */ + // ── Photo Queue ────────────────────────────────────────────────────────────── function collectQueueBooks(node, type) { const books = []; @@ -29,9 +31,9 @@ function collectQueueBooks(node, type) { if (n.identification_status !== 'user_approved') books.push(n); return; } - if (t === 'room') n.cabinets.forEach(c => collect(c, 'cabinet')); - if (t === 'cabinet') n.shelves.forEach(s => collect(s, 'shelf')); - if (t === 'shelf') n.books.forEach(b => collect(b, 'book')); + if (t === 'room') n.cabinets.forEach((c) => collect(c, 'cabinet')); + if (t === 'cabinet') n.shelves.forEach((s) => collect(s, 'shelf')); + if (t === 'shelf') n.books.forEach((b) => collect(b, 'book')); } collect(node, type); return books; @@ -40,8 +42,12 @@ function collectQueueBooks(node, type) { function renderPhotoQueue() { const el = document.getElementById('photo-queue-overlay'); if (!el) return; - if (!_photoQueue) { el.style.display = 'none'; el.innerHTML = ''; return; } - const {books, index, processing} = _photoQueue; + if (!_photoQueue) { + el.style.display = 'none'; + el.innerHTML = ''; + return; + } + const { books, index, processing } = _photoQueue; el.style.display = 'flex'; if (index >= books.length) { el.innerHTML = `
@@ -79,8 +85,8 @@ function renderPhotoQueue() { const gphoto = document.getElementById('gphoto'); function triggerPhoto(type, id) { - S._photoTarget = {type, id}; - if (/Android|iPhone|iPad/i.test(navigator.userAgent)) gphoto.setAttribute('capture','environment'); + S._photoTarget = { type, id }; + if (/Android|iPhone|iPad/i.test(navigator.userAgent)) gphoto.setAttribute('capture', 'environment'); else gphoto.removeAttribute('capture'); gphoto.value = ''; gphoto.click(); @@ -89,20 +95,22 @@ function triggerPhoto(type, id) { gphoto.addEventListener('change', async () => { const file = gphoto.files[0]; if (!file || !S._photoTarget) return; - const {type, id} = S._photoTarget; + const { type, id } = S._photoTarget; S._photoTarget = null; 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 const urls = { - room: `/api/rooms/${id}/photo`, + room: `/api/rooms/${id}/photo`, cabinet: `/api/cabinets/${id}/photo`, - shelf: `/api/shelves/${id}/photo`, - book: `/api/books/${id}/photo`, + shelf: `/api/shelves/${id}/photo`, + book: `/api/books/${id}/photo`, }; try { const res = await req('POST', urls[type], fd, true); - const key = type==='book' ? 'image_filename' : 'photo_filename'; - walkTree(n=>{ if(n.id===id) n[key]=res[key]; }); + const key = type === 'book' ? 'image_filename' : 'photo_filename'; + walkTree((n) => { + if (n.id === id) n[key] = res[key]; + }); // Photo queue mode: process and advance without full re-render if (_photoQueue && type === 'book') { _photoQueue.processing = true; @@ -111,8 +119,12 @@ gphoto.addEventListener('change', async () => { if (book && book.identification_status !== 'user_approved') { try { const br = await req('POST', `/api/books/${id}/process`); - walkTree(n => { if (n.id === id) Object.assign(n, br); }); - } catch { /* continue queue on process error */ } + walkTree((n) => { + if (n.id === id) Object.assign(n, br); + }); + } catch { + /* continue queue on process error */ + } } _photoQueue.processing = false; _photoQueue.index++; @@ -127,12 +139,24 @@ gphoto.addEventListener('change', async () => { if (book && book.identification_status !== 'user_approved') { try { const br = await req('POST', `/api/books/${id}/process`); - walkTree(n => { if(n.id===id) Object.assign(n, br); }); + walkTree((n) => { + if (n.id === id) Object.assign(n, br); + }); toast(`Photo saved · Identified (${br.identification_status})`); render(); - } catch { toast('Photo saved'); } - } else { toast('Photo saved'); } - } else { toast('Photo saved'); } - } else { toast('Photo saved'); } - } catch(err) { toast('Upload failed: '+err.message); } + } catch { + toast('Photo saved'); + } + } else { + toast('Photo saved'); + } + } else { + toast('Photo saved'); + } + } else { + toast('Photo saved'); + } + } catch (err) { + toast('Upload failed: ' + err.message); + } }); diff --git a/static/js/state.js b/static/js/state.js index 6ad1010..7dabc2f 100644 --- a/static/js/state.js +++ b/static/js/state.js @@ -7,35 +7,53 @@ * S — main UI state (tree data, selection, loading flags) * _plugins — plugin manifest populated from GET /api/config * _batchState — current batch-processing progress - * _batchPollTimer — setInterval handle for batch polling + * _batchWs — active WebSocket for batch push notifications (null when idle) * _bnd — live boundary-canvas state (written by canvas-boundary.js, * read by detail-render.js) * _photoQueue — photo queue session state (written by photo.js, * read by events.js) */ +/* exported S */ + // ── Main UI state ─────────────────────────────────────────────────────────── -let S = { +const S = { tree: null, expanded: new Set(), - selected: null, // {type:'cabinet'|'shelf'|'book', id} - _photoTarget: null, // {type, id} - _loading: {}, // {`${pluginId}:${entityId}`: true} - _cropMode: null, // {type, id} while crop UI is active + selected: null, // {type:'cabinet'|'shelf'|'book', id} + _photoTarget: null, // {type, id} + _loading: {}, // {`${pluginId}:${entityId}`: true} + _cropMode: null, // {type, id} while crop UI is active }; // ── Plugin registry ───────────────────────────────────────────────────────── -let _plugins = []; // populated from GET /api/config +// eslint-disable-next-line prefer-const +let _plugins = []; // populated from GET /api/config // ── Batch processing state ────────────────────────────────────────────────── -let _batchState = {running: false, total: 0, done: 0, errors: 0, current: ''}; -let _batchPollTimer = null; +const _batchState = { running: false, total: 0, done: 0, errors: 0, current: '' }; +// eslint-disable-next-line prefer-const +let _batchWs = null; // ── Boundary canvas live state ─────────────────────────────────────────────── // Owned by canvas-boundary.js; declared here so detail-render.js can read it // without a circular load dependency. +// eslint-disable-next-line prefer-const let _bnd = null; // {wrap,img,canvas,axis,boundaries[],pluginResults{},selectedPlugin,segments[],nodeId,nodeType} // ── Photo queue session state ──────────────────────────────────────────────── // Owned by photo.js; declared here so events.js can read/write it. +// eslint-disable-next-line prefer-const 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) diff --git a/static/js/tree-render.js b/static/js/tree-render.js index c9ef64c..3b12b17 100644 --- a/static/js/tree-render.js +++ b/static/js/tree-render.js @@ -13,17 +13,27 @@ * vBook(), getBookStats(), vAiProgressBar() */ +/* exported pluginsByCategory, pluginsByTarget, isLoading, vPluginBtn, vBatchBtn, vAiIndicator, + candidateSugRows, vApp, mainTitle, mainHeaderBtns, _STATUS_BADGE, + getBookStats, vAiProgressBar, walkTree, removeNode, findNode */ + // ── Plugin helpers ─────────────────────────────────────────────────────────── -function pluginsByCategory(cat) { return _plugins.filter(p => p.category === cat); } -function pluginsByTarget(cat, target) { return _plugins.filter(p => p.category === cat && p.target === target); } -function isLoading(pluginId, entityId) { return !!S._loading[`${pluginId}:${entityId}`]; } +function pluginsByCategory(cat) { + return _plugins.filter((p) => p.category === cat); +} +function pluginsByTarget(cat, target) { + return _plugins.filter((p) => p.category === cat && p.target === target); +} +function isLoading(pluginId, entityId) { + return !!S._loading[`${pluginId}:${entityId}`]; +} function vPluginBtn(plugin, entityId, entityType, extraDisabled = false) { const loading = isLoading(plugin.id, entityId); const label = loading ? '⏳' : esc(plugin.name); return ``; } @@ -34,21 +44,36 @@ function vBatchBtn() { return ``; } +// ── AI active indicator ─────────────────────────────────────────────────────── +function vAiIndicator(count) { + return `${count}`; +} + // ── Candidate suggestion rows ──────────────────────────────────────────────── const SOURCE_LABELS = { - vlm: 'VLM', ai: 'AI', openlibrary: 'OpenLib', - rsl: 'РГБ', rusneb: 'НЭБ', alib: 'Alib', nlr: 'НЛР', shpl: 'ШПИЛ', + vlm: 'VLM', + ai: 'AI', + openlibrary: 'OpenLib', + rsl: 'РГБ', + rusneb: 'НЭБ', + alib: 'Alib', + nlr: 'НЛР', + shpl: 'ШПИЛ', }; function getSourceLabel(source) { if (SOURCE_LABELS[source]) return SOURCE_LABELS[source]; - const p = _plugins.find(pl => pl.id === source); + const p = _plugins.find((pl) => pl.id === source); return p ? p.name : source; } function parseCandidates(json) { if (!json) return []; - try { return JSON.parse(json) || []; } catch { return []; } + try { + return JSON.parse(json) || []; + } catch { + return []; + } } function candidateSugRows(b, field, inputId) { @@ -61,7 +86,7 @@ function candidateSugRows(b, field, inputId) { const v = (c[field] || '').trim(); if (!v) continue; 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); 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(); if (aiVal) { 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); if (!entry.sources.includes('ai')) entry.sources.unshift('ai'); } return [...byVal.entries()] .filter(([k]) => k !== userVal.toLowerCase()) - .map(([, {display, sources}]) => { - const badges = sources.map(s => - `${esc(getSourceLabel(s))}` - ).join(' '); + .map(([, { display, sources }]) => { + const badges = sources + .map((s) => `${esc(getSourceLabel(s))}`) + .join(' '); const val = esc(display); return `
${badges} ${val} @@ -90,34 +115,41 @@ function candidateSugRows(b, field, inputId) { data-a="dismiss-field" data-id="${b.id}" data-field="${field}" data-value="${val}" title="Dismiss">✗
`; - }).join(''); + }) + .join(''); } // ── App shell ──────────────────────────────────────────────────────────────── function vApp() { - return `
-