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>
This commit is contained in:
2026-03-11 12:10:54 +03:00
parent fd32be729f
commit b94f222c96
41 changed files with 2566 additions and 586 deletions

View File

@@ -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 = '<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);
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)<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';
const inX = fx >= x1 && fx <= x2,
inY = fy >= y1 && fy <= y2;
const nX1 = Math.abs(fx - x1) < th,
nX2 = Math.abs(fx - x2) < th;
const nY1 = Math.abs(fy - y1) < th,
nY2 = Math.abs(fy - y2) < th;
if (nX1 && nY1) return 'tl';
if (nX2 && nY1) return 'tr';
if (nX1 && nY2) return 'bl';
if (nX2 && nY2) return 'br';
if (nY1 && inX) return 't';
if (nY2 && inX) return 'b';
if (nX1 && inY) return 'l';
if (nX2 && inY) return 'r';
if (inX && inY) return 'move';
return null;
}
function _cropPartCursor(part) {
if (!part) return 'crosshair';
if (part==='move') return 'move';
if (part==='tl'||part==='br') return 'nwse-resize';
if (part==='tr'||part==='bl') return 'nesw-resize';
if (part==='t'||part==='b') return 'ns-resize';
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
}