Initial commit

Photo-based book cataloger with AI identification.
Room → Cabinet → Shelf → Book hierarchy; FastAPI + SQLite backend;
vanilla JS SPA; OpenAI-compatible plugin system for boundary
detection, text recognition, and archive search.
This commit is contained in:
night
2026-03-09 14:11:11 +03:00
committed by Petr Polezhaev
commit 5d5f26c8ae
64 changed files with 8605 additions and 0 deletions

9
static/css/base.css Normal file
View File

@@ -0,0 +1,9 @@
/*
* base.css
* Global CSS reset, body defaults, and single utility class used throughout
* the app. Must load before all other stylesheets.
*/
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f1f5f9;color:#1e293b;min-height:100vh}
.hidden{display:none!important}

44
static/css/forms.css Normal file
View File

@@ -0,0 +1,44 @@
/*
* forms.css
* Generic button variants, the book detail right panel, image/canvas wrapper,
* crop selection overlay, book image display boxes, and the identification form
* (card, labels, inputs, textarea, danger zone).
*/
/* ── Buttons ── */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:7px 13px;border-radius:7px;border:none;cursor:pointer;font-size:.83rem;font-weight:500}
.btn:active{opacity:.82}
.btn:disabled{opacity:.4;cursor:default}
.btn-p{background:#2563eb;color:white}
.btn-s{background:#e2e8f0;color:#475569}
.btn-g{background:#16a34a;color:white}
.btn-r{background:#ef4444;color:white}
.btn-w{width:100%;margin-bottom:7px}
.btn-row{display:flex;gap:7px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
/* ── Right panel ── */
.det-empty{display:flex;align-items:center;justify-content:center;height:100%;color:#94a3b8;font-size:.95rem}
/* ── Image + canvas overlay ── */
.img-wrap{position:relative;display:inline-block;max-width:100%;line-height:0;border-radius:7px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.15)}
.img-wrap img{display:block;max-width:100%;max-height:calc(100vh - 200px);object-fit:contain}
.img-wrap canvas{position:absolute;inset:0;width:100%;height:100%}
/* ── Crop overlay ── */
.crop-sel{position:absolute;border:2px solid #38bdf8;background:rgba(56,189,248,.12);pointer-events:none}
/* ── Book detail panel ── */
.book-panel{display:flex;flex-direction:column;gap:14px}
.book-img-box{border-radius:7px;overflow:hidden;background:#0f172a;line-height:0;box-shadow:0 1px 4px rgba(0,0,0,.2);margin-bottom:8px}
.book-img-box img{max-width:100%;max-height:260px;object-fit:contain;display:block;margin:0 auto}
.book-img-label{font-size:.7rem;color:#64748b;margin-bottom:4px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}
/* ── Form ── */
.card{background:white;border-radius:10px;padding:13px;margin-bottom:10px;box-shadow:0 1px 3px rgba(0,0,0,.07)}
.flabel{display:block;font-size:.7rem;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.04em;margin-bottom:3px}
.finput{width:100%;padding:8px 10px;border:1.5px solid #e2e8f0;border-radius:6px;font-size:.88rem;color:#1e293b;background:white;-webkit-appearance:none}
.finput:focus{outline:none;border-color:#2563eb}
textarea.finput{height:64px;resize:vertical}
.fgroup{margin-bottom:9px}
.dz{border:1.5px solid #fecaca;border-radius:8px;padding:11px;margin-top:12px}
.dz-h{color:#dc2626;font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;margin-bottom:7px}

36
static/css/layout.css Normal file
View File

@@ -0,0 +1,36 @@
/*
* layout.css
* Top-level layout: sticky header bar, two-column desktop layout
* (300px sidebar + flex main panel), mobile single-column default,
* and the contenteditable header span used for inline entity renaming.
*
* Breakpoint: ≥768px = desktop two-column; <768px = mobile accordion.
*/
/* ── Header ── */
.hdr{background:#1e3a5f;color:white;padding:10px 14px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:100;box-shadow:0 2px 6px rgba(0,0,0,.3);flex-shrink:0}
.hdr h1{flex:1;font-size:.96rem;font-weight:600}
.hbtn{background:none;border:none;color:white;min-width:34px;min-height:34px;border-radius:50%;cursor:pointer;font-size:1rem;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.hbtn:active{background:rgba(255,255,255,.2)}
/* ── Mobile layout (default) ── */
.layout{display:flex;flex-direction:column;min-height:100vh}
.sidebar{flex:1}
.main-panel{display:none}
/* ── Desktop layout ── */
@media(min-width:768px){
body{overflow:hidden}
.layout{flex-direction:row;height:100vh;overflow:hidden}
.sidebar{width:300px;display:flex;flex-direction:column;border-right:1px solid #cbd5e1;overflow:hidden;flex-shrink:0}
.sidebar .hdr{padding:9px 12px}
.sidebar-body{flex:1;overflow-y:auto;padding:8px 10px 16px}
.main-panel{flex:1;display:flex;flex-direction:column;overflow:hidden;background:#e8eef5}
.main-hdr{background:#1e3a5f;color:white;padding:9px 14px;display:flex;align-items:center;gap:8px;flex-shrink:0}
.main-hdr h2{flex:1;font-size:.9rem;font-weight:500;opacity:.9;min-width:0}
.main-body{flex:1;overflow:auto;padding:14px}
}
/* ── Detail header editable name ── */
.hdr-edit{display:block;outline:none;cursor:text;border-radius:3px;padding:1px 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.hdr-edit:focus{background:rgba(255,255,255,.15);white-space:normal;overflow:visible}

31
static/css/overlays.css Normal file
View File

@@ -0,0 +1,31 @@
/*
* overlays.css
* Fixed-position overlays that appear above all other content:
* - Toast notification (bottom-center slide-in)
* - Loading spinner and empty-state placeholder
* - Photo Queue overlay: full-screen mobile flow for photographing
* unidentified books in sequence (spine preview + camera button)
*/
/* ── Toast / loading ── */
.toast{position:fixed;bottom:16px;left:50%;transform:translateX(-50%) translateY(120px);background:#1e293b;color:white;padding:7px 15px;border-radius:6px;font-size:.82rem;transition:transform .25s;z-index:9999;pointer-events:none;white-space:nowrap}
.toast.on{transform:translateX(-50%) translateY(0)}
.loading{display:flex;align-items:center;justify-content:center;padding:30px;gap:8px;color:#64748b;font-size:.88rem}
.spinner{width:20px;height:20px;border:3px solid #e2e8f0;border-top-color:#2563eb;border-radius:50%;animation:spin .8s linear infinite;flex-shrink:0}
@keyframes spin{to{transform:rotate(360deg)}}
.empty{text-align:center;padding:36px 12px;color:#94a3b8}
.empty .ei{font-size:2.4rem;margin-bottom:7px}
/* ── Photo Queue Overlay ── */
#photo-queue-overlay{position:fixed;inset:0;background:#0f172a;z-index:200;flex-direction:column;color:white}
.pq-hdr{display:flex;align-items:center;gap:8px;padding:12px 14px;background:#1e3a5f;flex-shrink:0;box-shadow:0 2px 6px rgba(0,0,0,.3)}
.pq-hdr-title{flex:1;font-size:.9rem;font-weight:600;text-align:center}
.pq-spine-wrap{flex:1;display:flex;align-items:center;justify-content:center;padding:16px;min-height:0;flex-direction:column;gap:12px;overflow:hidden}
.pq-spine-img{max-width:100%;max-height:52vh;object-fit:contain;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.6)}
.pq-book-name{font-size:.85rem;color:#94a3b8;text-align:center;max-width:90%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pq-actions{display:flex;align-items:center;justify-content:center;gap:24px;padding:20px 16px;flex-shrink:0;background:#1e293b;border-top:1px solid rgba(255,255,255,.1)}
.pq-camera-btn{background:#2563eb;color:white;border:none;border-radius:50%;width:76px;height:76px;font-size:2rem;display:flex;align-items:center;justify-content:center;cursor:pointer;flex-shrink:0;box-shadow:0 4px 16px rgba(37,99,235,.4)}
.pq-camera-btn:active{background:#1d4ed8;transform:scale(.94)}
.pq-skip-btn{background:rgba(255,255,255,.1);color:#cbd5e1;border:none;border-radius:8px;padding:12px 18px;font-size:.85rem;cursor:pointer;min-width:70px}
.pq-skip-btn:active{background:rgba(255,255,255,.2)}
.pq-processing{position:absolute;inset:0;background:rgba(15,23,42,.88);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;font-size:.9rem}

70
static/css/tree.css Normal file
View File

@@ -0,0 +1,70 @@
/*
* tree.css
* Styles for the sidebar tree: room/cabinet/shelf/book node rows,
* selection highlight, segment-hover highlight (synced with boundary canvas),
* drag handle, expand toggle, inline name spans, per-level action icon buttons,
* book thumbnail + metadata layout, status badges, AI suggestion rows,
* source badges (per archive/plugin), and the "+ Add Room" dashed button.
*/
/* ── Tree nodes ── */
.node{margin-bottom:2px}
.nrow{display:flex;align-items:center;gap:4px;padding:10px 8px;border-radius:7px;user-select:none}
.nrow-room {background:#1e3a5f;color:white;cursor:pointer}
.nrow-cabinet{background:#234e85;color:white;cursor:pointer}
@media(min-width:768px){
.nrow{padding:6px 7px}
}
.nrow-shelf {background:#f8fafc;border:1px solid #e2e8f0;cursor:pointer}
.nrow-book {background:white;cursor:pointer}
.nrow.sel {outline:2px solid #38bdf8;outline-offset:-2px}
.nrow.seg-hover{background:#fef9c3!important}
.nrow-room.seg-hover{background:#2d5b9e!important}
.nrow-cabinet.seg-hover{background:#2d5b9e!important}
.nchildren{padding-left:16px;margin-top:2px}
/* ── Drag handle ── */
.drag-h{color:#94a3b8;cursor:grab;font-size:.9rem;flex-shrink:0;padding:1px 2px;user-select:none;touch-action:none}
.nrow-room .drag-h,.nrow-cabinet .drag-h{color:rgba(255,255,255,.4)}
.drag-ghost{opacity:.4}
/* ── Toggle ── */
.tbtn{background:none;border:none;cursor:pointer;font-size:.75rem;padding:2px;flex-shrink:0;color:inherit;transition:transform .15s;line-height:1}
.tbtn.col{transform:rotate(-90deg)}
/* ── Name (editable) ── */
.nname{flex:1;min-width:0;font-size:.84rem;font-weight:500;outline:none;cursor:inherit;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;border-radius:3px;padding:1px 3px}
.nname:focus{background:rgba(255,255,255,.18);white-space:normal;overflow:visible}
.nrow-shelf .nname,.nrow-book .nname{color:#334155;font-weight:400}
/* ── Action buttons ── */
.nacts{display:flex;align-items:center;gap:1px;flex-shrink:0}
.ibtn{background:none;border:none;cursor:pointer;font-size:1.1rem;padding:4px 6px;border-radius:4px;color:inherit;opacity:.8;line-height:1;flex-shrink:0;min-width:48px;min-height:48px;display:flex;align-items:center;justify-content:center}
.ibtn:active{background:rgba(0,0,0,.12)}
.ibtn:disabled{opacity:.3;cursor:default}
@media(min-width:768px){
.ibtn{min-width:24px;min-height:24px;font-size:.82rem;padding:2px 4px}
}
/* ── Book row (sidebar) ── */
.bthumb{width:22px;height:30px;object-fit:cover;border-radius:2px;flex-shrink:0}
.bthumb-ph{width:22px;height:30px;background:#e2e8f0;border-radius:2px;display:flex;align-items:center;justify-content:center;font-size:.65rem;color:#94a3b8;flex-shrink:0}
.binfo{flex:1;min-width:0}
.bttl{font-size:.8rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bsub{font-size:.7rem;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.sbadge{display:inline-block;font-size:.6rem;font-weight:700;padding:1px 5px;border-radius:3px;flex-shrink:0;text-transform:uppercase;letter-spacing:.03em}
.s-unid{background:#e2e8f0;color:#64748b}.s-aiid{background:#fef3c7;color:#b45309}.s-appr{background:#dcfce7;color:#15803d}
.ai-sug{display:flex;align-items:center;gap:5px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:5px;padding:3px 8px;margin-bottom:4px;font-size:.8rem}
.ai-sug em{flex:1;color:#1e40af;font-style:normal;min-width:0;overflow:hidden;text-overflow:ellipsis}
.src-badge{display:inline-block;font-size:.58rem;font-weight:700;padding:1px 4px;border-radius:3px;text-transform:uppercase;letter-spacing:.03em;white-space:nowrap;flex-shrink:0}
.src-vlm,.src-vlm_spine,.src-vlm_shelves,.src-vlm_books{background:#ede9fe;color:#7c3aed}
.src-ai,.src-ai_search{background:#fef3c7;color:#b45309}
.src-openlibrary{background:#dbeafe;color:#1d4ed8}
.src-rsl{background:#dcfce7;color:#15803d}
.src-rusneb{background:#fce7f3;color:#be185d}
.src-alib,.src-alib_web,.src-alib_telegram{background:#fff7ed;color:#c2410c}
.src-nlr{background:#f1f5f9;color:#475569}.src-shpl{background:#f1f5f9;color:#475569}
/* ── Add-root button ── */
.add-root{display:block;width:100%;padding:9px;background:#f1f5f9;border:2px dashed #94a3b8;border-radius:7px;color:#64748b;font-size:.84rem;cursor:pointer;margin-top:8px;text-align:center}
.add-root:active{background:#e2e8f0}

81
static/index.html Normal file
View File

@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="theme-color" content="#1e3a5f">
<title>Bookshelf</title>
<!-- Global reset, body font/background, and .hidden utility -->
<link rel="stylesheet" href="css/base.css">
<!-- Sticky header, two-column desktop layout, mobile single-column default -->
<link rel="stylesheet" href="css/layout.css">
<!-- Sidebar tree: node rows, drag handle, toggle, name, action buttons, book thumbnails, status/source badges -->
<link rel="stylesheet" href="css/tree.css">
<!-- Generic button variants, book detail panel, image/canvas wrapper, identification form -->
<link rel="stylesheet" href="css/forms.css">
<!-- Fixed-position overlays: toast notification, loading spinner, photo queue -->
<link rel="stylesheet" href="css/overlays.css">
</head>
<body>
<!-- Main app mount point — entire UI is rendered here by render() in js/init.js -->
<div id="app"></div>
<!-- Photo queue overlay: full-screen mobile UI for sequential book photography.
Lives outside #app so its event listener does not conflict with the tree. -->
<div id="photo-queue-overlay" style="display:none"></div>
<!-- Shared file input for all photo uploads (cabinet, shelf, book, room).
Triggered programmatically by triggerPhoto() in js/photo.js. -->
<input type="file" id="gphoto" accept="image/*" class="hidden">
<!-- Slide-in toast notification; text set by toast() in js/helpers.js -->
<div class="toast" id="toast"></div>
<!-- SortableJS: drag-and-drop reordering for rooms, cabinets, shelves, and books -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
<!-- All mutable application state (S, _plugins, _batchState, _bnd, _photoQueue).
Must load first — every subsequent module reads these globals. -->
<script src="js/state.js"></script>
<!-- Pure utilities: esc(), toast(), isDesktop(). No dependencies. -->
<script src="js/helpers.js"></script>
<!-- Fetch wrapper req(). Throws on non-2xx with server detail message. -->
<script src="js/api.js"></script>
<!-- Boundary-line canvas editor: draw, drag, snap-to-AI, Ctrl+Alt+Click to add.
Defines _bnd structure — loaded before detail-render.js which reads it. -->
<script src="js/canvas-boundary.js"></script>
<!-- Tree HTML generators: vApp(), vRoom/Cabinet/Shelf/Book(), vBatchBtn(),
plugin helpers, candidate suggestion rows, walkTree/findNode/removeNode. -->
<script src="js/tree-render.js"></script>
<!-- Detail panel HTML generators: vDetailBody(), vCabinetDetail(),
vShelfDetail(), vBookDetail(). Reads _bnd for boundary plugin selection. -->
<script src="js/detail-render.js"></script>
<!-- In-place crop tool: draggable rectangle on canvas, POSTs pixel coords.
Calls drawBnd() on cancel to restore the boundary overlay. -->
<script src="js/canvas-crop.js"></script>
<!-- Inline contenteditable name editing (blur-to-save) and SortableJS wiring. -->
<script src="js/editing.js"></script>
<!-- Photo upload for all entity types and mobile Photo Queue feature.
Registers the gphoto 'change' handler at parse time. -->
<script src="js/photo.js"></script>
<!-- Event delegation on #app and #photo-queue-overlay; central handle() dispatcher
with all action cases; accordion expand helpers. -->
<script src="js/events.js"></script>
<!-- render(), renderDetail(), loadConfig(), startBatchPolling(), loadTree(),
and the bootstrap Promise.all([loadConfig(), loadTree()]) call. -->
<script src="js/init.js"></script>
</body>
</html>

23
static/js/api.js Normal file
View File

@@ -0,0 +1,23 @@
/*
* api.js
* Single fetch wrapper used for all server communication.
* Throws an Error with the server's detail message on non-2xx responses.
*
* Provides: req(method, url, body?, isForm?)
* Depends on: nothing
*/
// ── API ──────────────────────────────────────────────────────────────────────
async function req(method, url, body = null, isForm = false) {
const opts = {method};
if (body) {
if (isForm) { opts.body = body; }
else { opts.headers = {'Content-Type':'application/json'}; opts.body = JSON.stringify(body); }
}
const r = await fetch(url, opts);
if (!r.ok) {
const e = await r.json().catch(() => ({detail:'Request failed'}));
throw new Error(e.detail || 'Request failed');
}
return r.json();
}

View File

@@ -0,0 +1,271 @@
/*
* canvas-boundary.js
* Boundary-line editor rendered on a <canvas> overlaid on cabinet/shelf images.
* Handles:
* - Parsing boundary JSON from tree nodes
* - Drawing segment fills, labels, user boundary lines, and AI suggestion
* overlays (dashed lines per plugin, or all-plugins combined)
* - Pointer drag to move existing boundary lines
* - Ctrl+Alt+Click to add a new boundary line (and create a new child entity)
* - Mouse hover to highlight the corresponding tree row (seg-hover)
* - Snap-to-AI-guide when releasing a drag near a plugin boundary
*
* Reads: S, _bnd (state.js); req, toast, render (api.js / init.js)
* Writes: _bnd (state.js)
* Provides: parseBounds(), parseBndPluginResults(), SEG_FILLS, SEG_STROKES,
* setupDetailCanvas(), drawBnd(), clearSegHover()
*/
// ── Boundary parsing helpers ─────────────────────────────────────────────────
function parseBounds(json) {
if (!json) return [];
try { return JSON.parse(json) || []; } catch { return []; }
}
function parseBndPluginResults(json) {
if (!json) return {};
try {
const v = JSON.parse(json);
if (Array.isArray(v) || !v || typeof v !== 'object') return {};
return v;
} catch { return {}; }
}
const SEG_FILLS = ['rgba(59,130,246,.14)','rgba(16,185,129,.14)','rgba(245,158,11,.14)','rgba(239,68,68,.14)','rgba(168,85,247,.14)'];
const SEG_STROKES = ['#3b82f6','#10b981','#f59e0b','#ef4444','#a855f7'];
// ── Canvas setup ─────────────────────────────────────────────────────────────
function setupDetailCanvas() {
const wrap = document.getElementById('bnd-wrap');
const img = document.getElementById('bnd-img');
const canvas = document.getElementById('bnd-canvas');
if (!wrap || !img || !canvas || !S.selected) return;
const {type, id} = S.selected;
const node = findNode(id);
if (!node || (type !== 'cabinet' && type !== 'shelf')) return;
const axis = type === 'cabinet' ? 'y' : 'x';
const boundaries = parseBounds(type === 'cabinet' ? node.shelf_boundaries : node.book_boundaries);
const pluginResults = parseBndPluginResults(type === 'cabinet' ? node.ai_shelf_boundaries : node.ai_book_boundaries);
const pluginIds = Object.keys(pluginResults);
const segments = type === 'cabinet'
? node.shelves.map((s,i) => ({id:s.id, label:s.name||`Shelf ${i+1}`}))
: node.books.map((b,i) => ({id:b.id, label:b.title||`Book ${i+1}`}));
const hasChildren = type === 'cabinet' ? node.shelves.length > 0 : node.books.length > 0;
const prevSel = (_bnd?.nodeId === id) ? _bnd.selectedPlugin
: (hasChildren ? null : pluginIds[0] ?? null);
_bnd = {wrap, img, canvas, axis, boundaries:[...boundaries],
pluginResults, selectedPlugin: prevSel, segments, nodeId:id, nodeType:type};
function sizeAndDraw() {
canvas.width = img.offsetWidth;
canvas.height = img.offsetHeight;
drawBnd();
}
if (img.complete && img.offsetWidth > 0) sizeAndDraw();
else img.addEventListener('load', sizeAndDraw);
canvas.addEventListener('pointerdown', bndPointerDown);
canvas.addEventListener('pointermove', bndPointerMove);
canvas.addEventListener('pointerup', bndPointerUp);
canvas.addEventListener('click', bndClick);
canvas.addEventListener('mousemove', bndHover);
canvas.addEventListener('mouseleave', () => clearSegHover());
}
// ── Draw ─────────────────────────────────────────────────────────────────────
function drawBnd(dragIdx = -1, dragVal = null) {
if (!_bnd || S._cropMode) return;
const {canvas, axis, boundaries, segments} = _bnd;
const W = canvas.width, H = canvas.height;
if (!W || !H) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
// Build working boundary list with optional live drag value
const full = [0, ...boundaries, 1];
if (dragIdx >= 0 && dragIdx < boundaries.length) {
const lo = full[dragIdx] + 0.005;
const hi = full[dragIdx + 2] - 0.005;
full[dragIdx + 1] = Math.max(lo, Math.min(hi, dragVal));
}
// Draw segments
for (let i = 0; i < full.length - 1; i++) {
const a = full[i], b = full[i + 1];
const ci = i % SEG_FILLS.length;
ctx.fillStyle = SEG_FILLS[ci];
if (axis === 'y') ctx.fillRect(0, a*H, W, (b-a)*H);
else ctx.fillRect(a*W, 0, (b-a)*W, H);
// Label
const seg = segments[i];
if (seg) {
ctx.font = '11px system-ui,sans-serif';
ctx.fillStyle = 'rgba(0,0,0,.5)';
const lbl = seg.label.slice(0, 24);
if (axis === 'y') {
ctx.fillText(lbl, 4, a*H + 14);
} else {
ctx.save(); ctx.translate(a*W + 12, 14); ctx.rotate(Math.PI/2);
ctx.fillText(lbl, 0, 0); ctx.restore();
}
}
}
// Draw interior user boundary lines
ctx.setLineDash([5, 3]);
ctx.lineWidth = 2;
for (let i = 0; i < boundaries.length; i++) {
const val = (dragIdx === i && dragVal !== null) ? full[i+1] : boundaries[i];
ctx.strokeStyle = '#1e3a5f';
ctx.beginPath();
if (axis === 'y') { ctx.moveTo(0, val*H); ctx.lineTo(W, val*H); }
else { ctx.moveTo(val*W, 0); ctx.lineTo(val*W, H); }
ctx.stroke();
}
// Draw plugin boundary suggestions (dashed, non-interactive)
const {pluginResults, selectedPlugin} = _bnd;
const pluginIds = Object.keys(pluginResults);
if (selectedPlugin && pluginIds.length) {
ctx.setLineDash([3, 6]);
ctx.lineWidth = 1.5;
const drawPluginBounds = (bounds, color) => {
ctx.strokeStyle = color;
for (const ab of (bounds || [])) {
ctx.beginPath();
if (axis === 'y') { ctx.moveTo(0, ab*H); ctx.lineTo(W, ab*H); }
else { ctx.moveTo(ab*W, 0); ctx.lineTo(ab*W, H); }
ctx.stroke();
}
};
if (selectedPlugin === 'all') {
pluginIds.forEach((pid, i) => drawPluginBounds(pluginResults[pid], SEG_STROKES[i % SEG_STROKES.length]));
} else if (pluginResults[selectedPlugin]) {
drawPluginBounds(pluginResults[selectedPlugin], 'rgba(234,88,12,0.8)');
}
}
ctx.setLineDash([]);
}
// ── Drag machinery ───────────────────────────────────────────────────────────
let _dragIdx = -1, _dragging = false;
function fracFromEvt(e) {
const r = _bnd.canvas.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width;
const y = (e.clientY - r.top) / r.height;
return _bnd.axis === 'y' ? y : x;
}
function nearestBnd(frac) {
const {boundaries, canvas, axis} = _bnd;
const r = canvas.getBoundingClientRect();
const dim = axis === 'y' ? r.height : r.width;
const thresh = (window._grabPx ?? 14) / dim;
let best = -1, bestD = thresh;
boundaries.forEach((b,i) => { const d=Math.abs(b-frac); if(d<bestD){bestD=d;best=i;} });
return best;
}
function snapToAi(frac) {
if (!_bnd?.selectedPlugin) return frac;
const {pluginResults, selectedPlugin} = _bnd;
const snapBounds = selectedPlugin === 'all'
? Object.values(pluginResults).flat()
: (pluginResults[selectedPlugin] || []);
if (!snapBounds.length) return frac;
const r = _bnd.canvas.getBoundingClientRect();
const dim = _bnd.axis === 'y' ? r.height : r.width;
const thresh = (window._grabPx ?? 14) / dim;
let best = frac, bestD = thresh;
snapBounds.forEach(ab => { const d = Math.abs(ab - frac); if (d < bestD) { bestD = d; best = ab; } });
return best;
}
function bndPointerDown(e) {
if (!_bnd || S._cropMode) return;
const frac = fracFromEvt(e);
const idx = nearestBnd(frac);
if (idx >= 0) {
_dragIdx = idx; _dragging = true;
_bnd.canvas.setPointerCapture(e.pointerId);
e.stopPropagation();
}
}
function bndPointerMove(e) {
if (!_bnd || S._cropMode) return;
const frac = fracFromEvt(e);
const near = nearestBnd(frac);
_bnd.canvas.style.cursor = (near >= 0 || _dragging)
? (_bnd.axis==='y' ? 'ns-resize' : 'ew-resize') : 'default';
if (_dragging && _dragIdx >= 0) drawBnd(_dragIdx, frac);
}
async function bndPointerUp(e) {
if (!_dragging || !_bnd || S._cropMode) return;
const frac = fracFromEvt(e);
_dragging = false;
const {boundaries, nodeId, nodeType} = _bnd;
const full = [0, ...boundaries, 1];
const clamped = Math.max(full[_dragIdx]+0.005, Math.min(full[_dragIdx+2]-0.005, frac));
boundaries[_dragIdx] = Math.round(snapToAi(clamped) * 10000) / 10000;
_bnd.boundaries = [...boundaries];
_dragIdx = -1;
drawBnd();
const url = nodeType==='cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`;
try {
await req('PATCH', url, {boundaries});
const node = findNode(nodeId);
if (node) {
if (nodeType==='cabinet') node.shelf_boundaries = JSON.stringify(boundaries);
else node.book_boundaries = JSON.stringify(boundaries);
}
} catch(err) { toast('Save failed: ' + err.message); }
}
async function bndClick(e) {
if (!_bnd || _dragging || S._cropMode) return;
if (!e.ctrlKey || !e.altKey) return;
e.preventDefault();
const frac = snapToAi(fracFromEvt(e));
const {boundaries, nodeId, nodeType} = _bnd;
const newBounds = [...boundaries, frac].sort((a,b)=>a-b);
_bnd.boundaries = newBounds;
const url = nodeType==='cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`;
try {
await req('PATCH', url, {boundaries: newBounds});
if (nodeType === 'cabinet') {
const s = await req('POST', `/api/cabinets/${nodeId}/shelves`, null);
S.tree.forEach(r=>r.cabinets.forEach(c=>{ if(c.id===nodeId){
c.shelf_boundaries=JSON.stringify(newBounds); c.shelves.push({...s,books:[]});
}}));
} else {
const b = await req('POST', `/api/shelves/${nodeId}/books`);
S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>{ if(s.id===nodeId){
s.book_boundaries=JSON.stringify(newBounds); s.books.push(b);
}})));
}
render();
} catch(err) { toast('Error: ' + err.message); }
}
function bndHover(e) {
if (!_bnd || S._cropMode) return;
const frac = fracFromEvt(e);
const {boundaries, segments} = _bnd;
const full = [0, ...boundaries, 1];
let segIdx = -1;
for (let i = 0; i < full.length-1; i++) { if(frac>=full[i]&&frac<full[i+1]){segIdx=i;break;} }
clearSegHover();
if (segIdx>=0 && segments[segIdx]) {
document.querySelector(`.node[data-id="${segments[segIdx].id}"] .nrow`)?.classList.add('seg-hover');
}
}
function clearSegHover() {
document.querySelectorAll('.seg-hover').forEach(el=>el.classList.remove('seg-hover'));
}

166
static/js/canvas-crop.js Normal file
View File

@@ -0,0 +1,166 @@
/*
* canvas-crop.js
* In-place crop tool for cabinet and shelf photos.
* Renders a draggable crop rectangle on the boundary canvas overlay,
* then POSTs pixel coordinates to the server to permanently crop the image.
*
* Entry point: startCropMode(type, id) — called from events.js 'crop-start'.
* Disables boundary drag events while active (checked via S._cropMode).
*
* Depends on: S (state.js); req, toast (api.js / helpers.js);
* drawBnd (canvas-boundary.js) — called in cancelCrop to restore
* the boundary overlay after the crop UI is dismissed
* Provides: startCropMode(), cancelCrop(), confirmCrop()
*/
// ── Crop state ───────────────────────────────────────────────────────────────
let _cropState = null; // {x1,y1,x2,y2} fractions; null = not in crop mode
let _cropDragPart = null; // 'tl','tr','bl','br','t','b','l','r','move' | null
let _cropDragStart = null; // {fx,fy,x1,y1,x2,y2} snapshot at drag start
// ── Public entry point ───────────────────────────────────────────────────────
function startCropMode(type, id) {
const canvas = document.getElementById('bnd-canvas');
const wrap = document.getElementById('bnd-wrap');
if (!canvas || !wrap) return;
S._cropMode = {type, id};
_cropState = {x1: 0.05, y1: 0.05, x2: 0.95, y2: 0.95};
canvas.addEventListener('pointerdown', cropPointerDown);
canvas.addEventListener('pointermove', cropPointerMove);
canvas.addEventListener('pointerup', cropPointerUp);
document.getElementById('crop-bar')?.remove();
const bar = document.createElement('div');
bar.id = 'crop-bar';
bar.style.cssText = 'margin-top:10px;display:flex;gap:8px';
bar.innerHTML = '<button class="btn btn-p" id="crop-ok">Confirm crop</button><button class="btn btn-s" id="crop-cancel">Cancel</button>';
wrap.after(bar);
document.getElementById('crop-ok').addEventListener('click', confirmCrop);
document.getElementById('crop-cancel').addEventListener('click', cancelCrop);
drawCropOverlay();
}
// ── Drawing ──────────────────────────────────────────────────────────────────
function drawCropOverlay() {
const canvas = document.getElementById('bnd-canvas');
if (!canvas || !_cropState) return;
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const {x1, y1, x2, y2} = _cropState;
const px1=x1*W, py1=y1*H, px2=x2*W, py2=y2*H;
ctx.clearRect(0, 0, W, H);
// Dark shadow outside crop rect
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(0, 0, W, H);
ctx.clearRect(px1, py1, px2-px1, py2-py1);
// Bright border
ctx.strokeStyle = '#38bdf8'; ctx.lineWidth = 2; ctx.setLineDash([]);
ctx.strokeRect(px1, py1, px2-px1, py2-py1);
// Corner handles
const hs = 9;
ctx.fillStyle = '#38bdf8';
[[px1,py1],[px2,py1],[px1,py2],[px2,py2]].forEach(([x,y]) => ctx.fillRect(x-hs/2, y-hs/2, hs, hs));
}
// ── Hit testing ──────────────────────────────────────────────────────────────
function _cropFracFromEvt(e) {
const canvas = document.getElementById('bnd-canvas');
const r = canvas.getBoundingClientRect();
return {fx: (e.clientX-r.left)/r.width, fy: (e.clientY-r.top)/r.height};
}
function _getCropPart(fx, fy) {
if (!_cropState) return null;
const {x1, y1, x2, y2} = _cropState;
const th = 0.05;
const inX=fx>=x1&&fx<=x2, inY=fy>=y1&&fy<=y2;
const nX1=Math.abs(fx-x1)<th, nX2=Math.abs(fx-x2)<th;
const nY1=Math.abs(fy-y1)<th, nY2=Math.abs(fy-y2)<th;
if (nX1&&nY1) return 'tl'; if (nX2&&nY1) return 'tr';
if (nX1&&nY2) return 'bl'; if (nX2&&nY2) return 'br';
if (nY1&&inX) return 't'; if (nY2&&inX) return 'b';
if (nX1&&inY) return 'l'; if (nX2&&inY) return 'r';
if (inX&&inY) return 'move';
return null;
}
function _cropPartCursor(part) {
if (!part) return 'crosshair';
if (part==='move') return 'move';
if (part==='tl'||part==='br') return 'nwse-resize';
if (part==='tr'||part==='bl') return 'nesw-resize';
if (part==='t'||part==='b') return 'ns-resize';
return 'ew-resize';
}
// ── Pointer events ───────────────────────────────────────────────────────────
function cropPointerDown(e) {
if (!_cropState) return;
const {fx, fy} = _cropFracFromEvt(e);
const part = _getCropPart(fx, fy);
if (part) {
_cropDragPart = part;
_cropDragStart = {fx, fy, ..._cropState};
document.getElementById('bnd-canvas').setPointerCapture(e.pointerId);
}
}
function cropPointerMove(e) {
if (!_cropState) return;
const canvas = document.getElementById('bnd-canvas');
const {fx, fy} = _cropFracFromEvt(e);
if (_cropDragPart && _cropDragStart) {
const dx=fx-_cropDragStart.fx, dy=fy-_cropDragStart.fy;
const s = {..._cropState};
if (_cropDragPart==='move') {
const w=_cropDragStart.x2-_cropDragStart.x1, h=_cropDragStart.y2-_cropDragStart.y1;
s.x1=Math.max(0,Math.min(1-w,_cropDragStart.x1+dx)); s.y1=Math.max(0,Math.min(1-h,_cropDragStart.y1+dy));
s.x2=s.x1+w; s.y2=s.y1+h;
} else {
if (_cropDragPart.includes('l')) s.x1=Math.max(0,Math.min(_cropDragStart.x2-0.05,_cropDragStart.x1+dx));
if (_cropDragPart.includes('r')) s.x2=Math.min(1,Math.max(_cropDragStart.x1+0.05,_cropDragStart.x2+dx));
if (_cropDragPart.includes('t')) s.y1=Math.max(0,Math.min(_cropDragStart.y2-0.05,_cropDragStart.y1+dy));
if (_cropDragPart.includes('b')) s.y2=Math.min(1,Math.max(_cropDragStart.y1+0.05,_cropDragStart.y2+dy));
}
_cropState = s;
drawCropOverlay();
canvas.style.cursor = _cropPartCursor(_cropDragPart);
} else {
canvas.style.cursor = _cropPartCursor(_getCropPart(fx, fy));
}
}
function cropPointerUp() { _cropDragPart = null; _cropDragStart = null; }
// ── Confirm / cancel ─────────────────────────────────────────────────────────
async function confirmCrop() {
if (!_cropState || !S._cropMode) return;
const img = document.getElementById('bnd-img');
if (!img) return;
const {x1, y1, x2, y2} = _cropState;
const W=img.naturalWidth, H=img.naturalHeight;
const px = {x:Math.round(x1*W), y:Math.round(y1*H), w:Math.round((x2-x1)*W), h:Math.round((y2-y1)*H)};
if (px.w<10||px.h<10) { toast('Selection too small'); return; }
const {type, id} = S._cropMode;
const url = type==='cabinet' ? `/api/cabinets/${id}/crop` : `/api/shelves/${id}/crop`;
try {
await req('POST', url, px);
toast('Cropped'); cancelCrop(); render();
} catch(err) { toast('Crop failed: '+err.message); }
}
function cancelCrop() {
S._cropMode = null; _cropState = null; _cropDragPart = null; _cropDragStart = null;
document.getElementById('crop-bar')?.remove();
const canvas = document.getElementById('bnd-canvas');
if (canvas) {
canvas.removeEventListener('pointerdown', cropPointerDown);
canvas.removeEventListener('pointermove', cropPointerMove);
canvas.removeEventListener('pointerup', cropPointerUp);
canvas.style.cursor = '';
}
drawBnd(); // restore boundary overlay
}

166
static/js/detail-render.js Normal file
View File

@@ -0,0 +1,166 @@
/*
* detail-render.js
* HTML-string generators for the right-side detail panel (desktop) and
* the selected-entity view (mobile). Covers all four entity types.
*
* Depends on: S, _bnd (state.js); esc (helpers.js);
* pluginsByCategory, pluginsByTarget, vPluginBtn, getBookStats,
* vAiProgressBar, candidateSugRows, _STATUS_BADGE (tree-render.js);
* parseBounds, parseBndPluginResults (canvas-boundary.js)
* Provides: vDetailBody(), vRoomDetail(), vCabinetDetail(),
* vShelfDetail(), vBookDetail()
*/
// ── Room detail ──────────────────────────────────────────────────────────────
function vRoomDetail(r) {
const stats = getBookStats(r, 'room');
const totalBooks = stats.total;
return `<div>
${vAiProgressBar(stats)}
<p style="font-size:.72rem;color:#64748b">${r.cabinets.length} cabinet${r.cabinets.length!==1?'s':''} · ${totalBooks} book${totalBooks!==1?'s':''}</p>
</div>`;
}
// ── Detail body (right panel) ────────────────────────────────────────────────
function vDetailBody() {
if (!S.selected) return '<div class="det-empty">← Select a room, cabinet or shelf from the tree</div>';
const {type, id} = S.selected;
const node = findNode(id);
if (!node) return '<div class="det-empty">Not found</div>';
if (type === 'room') return vRoomDetail(node);
if (type === 'cabinet') return vCabinetDetail(node);
if (type === 'shelf') return vShelfDetail(node);
if (type === 'book') return vBookDetail(node);
return '';
}
// ── Cabinet detail ───────────────────────────────────────────────────────────
function vCabinetDetail(cab) {
const bounds = parseBounds(cab.shelf_boundaries);
const hasPhoto = !!cab.photo_filename;
const stats = getBookStats(cab, 'cabinet');
const bndPlugins = pluginsByTarget('boundary_detector', 'shelves');
const pluginResults = parseBndPluginResults(cab.ai_shelf_boundaries);
const pluginIds = Object.keys(pluginResults);
const sel = (_bnd?.nodeId === cab.id) ? _bnd.selectedPlugin
: (cab.shelves.length > 0 ? null : pluginIds[0] ?? null);
const selOpts = [
`<option value="">None</option>`,
...pluginIds.map(pid => `<option value="${pid}"${sel===pid?' selected':''}>${pid}</option>`),
...(pluginIds.length > 1 ? [`<option value="all"${sel==='all'?' selected':''}>All</option>`] : []),
].join('');
return `<div>
${vAiProgressBar(stats)}
${hasPhoto
? `<div class="img-wrap" id="bnd-wrap" data-type="cabinet" data-id="${cab.id}">
<img id="bnd-img" src="/images/${cab.photo_filename}?t=${Date.now()}" alt="">
<canvas id="bnd-canvas"></canvas>
</div>`
: `<div class="empty"><div class="ei">📷</div><div>Upload a cabinet photo (📷 in header) to get started</div></div>`}
${hasPhoto ? `<p style="font-size:.72rem;color:#94a3b8;margin-top:8px">Drag lines · Ctrl+Alt+Click to add · Snap to AI guides</p>` : ''}
${hasPhoto ? `<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:6px">
${bounds.length ? `<span style="font-size:.72rem;color:#64748b">${cab.shelves.length} shelf${cab.shelves.length!==1?'s':''} · ${bounds.length} boundar${bounds.length!==1?'ies':'y'}</span>` : ''}
<div style="display:flex;gap:4px;align-items:center;flex-wrap:wrap;margin-left:auto">
${bndPlugins.map(p => vPluginBtn(p, cab.id, 'cabinets')).join('')}
<select data-a="select-bnd-plugin" style="font-size:.72rem;padding:2px 5px;border:1px solid #e2e8f0;border-radius:4px;background:white;color:#475569">${selOpts}</select>
</div>
</div>` : ''}
</div>`;
}
// ── Shelf detail ─────────────────────────────────────────────────────────────
function vShelfDetail(shelf) {
const bounds = parseBounds(shelf.book_boundaries);
const stats = getBookStats(shelf, 'shelf');
const bndPlugins = pluginsByTarget('boundary_detector', 'books');
const pluginResults = parseBndPluginResults(shelf.ai_book_boundaries);
const pluginIds = Object.keys(pluginResults);
const sel = (_bnd?.nodeId === shelf.id) ? _bnd.selectedPlugin
: (shelf.books.length > 0 ? null : pluginIds[0] ?? null);
const selOpts = [
`<option value="">None</option>`,
...pluginIds.map(pid => `<option value="${pid}"${sel===pid?' selected':''}>${pid}</option>`),
...(pluginIds.length > 1 ? [`<option value="all"${sel==='all'?' selected':''}>All</option>`] : []),
].join('');
return `<div>
${vAiProgressBar(stats)}
<div class="img-wrap" id="bnd-wrap" data-type="shelf" data-id="${shelf.id}">
<img id="bnd-img" src="/api/shelves/${shelf.id}/image?t=${Date.now()}" alt=""
onerror="this.parentElement.innerHTML='<div class=empty style=padding:40px><div class=ei>📷</div><div>No image available — upload a cabinet photo first</div></div>'">
<canvas id="bnd-canvas"></canvas>
</div>
<p style="font-size:.72rem;color:#94a3b8;margin-top:8px">Drag lines · Ctrl+Alt+Click to add · Snap to AI guides</p>
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:6px">
${bounds.length ? `<span style="font-size:.72rem;color:#64748b">${shelf.books.length} book${shelf.books.length!==1?'s':''} · ${bounds.length} boundary${bounds.length!==1?'ies':''}</span>` : ''}
<div style="display:flex;gap:4px;align-items:center;flex-wrap:wrap;margin-left:auto">
${bndPlugins.map(p => vPluginBtn(p, shelf.id, 'shelves')).join('')}
<select data-a="select-bnd-plugin" style="font-size:.72rem;padding:2px 5px;border:1px solid #e2e8f0;border-radius:4px;background:white;color:#475569">${selOpts}</select>
</div>
</div>
</div>`;
}
// ── Book detail ──────────────────────────────────────────────────────────────
function vBookDetail(b) {
const [sc, sl] = _STATUS_BADGE[b.identification_status] ?? _STATUS_BADGE.unidentified;
const recognizers = pluginsByCategory('text_recognizer');
const identifiers = pluginsByCategory('book_identifier');
const searchers = pluginsByCategory('archive_searcher');
const hasRawText = !!(b.raw_text || '').trim();
return `<div class="book-panel">
<div>
<div class="book-img-label">Spine</div>
<div class="book-img-box"><img src="/api/books/${b.id}/spine?t=${Date.now()}" alt=""
onerror="this.style.display='none'"></div>
${b.image_filename
? `<div class="book-img-label">Title page</div>
<div class="book-img-box"><img src="/images/${b.image_filename}" alt=""></div>`
: ''}
</div>
<div>
<div class="card">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span class="sbadge ${sc}" style="font-size:.7rem;padding:2px 7px">${sl}</span>
<span style="font-size:.72rem;color:#64748b">${b.identification_status ?? 'unidentified'}</span>
${b.analyzed_at ? `<span style="font-size:.68rem;color:#94a3b8;margin-left:auto">Identified ${b.analyzed_at.slice(0,10)}</span>` : ''}
</div>
<div class="fgroup">
<label class="flabel" style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
Recognition
${recognizers.map(p => vPluginBtn(p, b.id, 'books')).join('')}
${identifiers.map(p => vPluginBtn(p, b.id, 'books', !hasRawText)).join('')}
</label>
<textarea class="finput" id="d-raw-text" style="height:72px;font-family:monospace;font-size:.8rem" readonly>${esc(b.raw_text ?? '')}</textarea>
</div>
${searchers.length ? `<div class="fgroup">
<label class="flabel" style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
Archives
${searchers.map(p => vPluginBtn(p, b.id, 'books')).join('')}
</label>
</div>` : ''}
<div class="fgroup">
${candidateSugRows(b, 'title', 'd-title')}
<label class="flabel">Title</label>
<input class="finput" id="d-title" value="${esc(b.title ?? '')}"></div>
<div class="fgroup">
${candidateSugRows(b, 'author', 'd-author')}
<label class="flabel">Author</label>
<input class="finput" id="d-author" value="${esc(b.author ?? '')}"></div>
<div class="fgroup">
${candidateSugRows(b, 'year', 'd-year')}
<label class="flabel">Year</label>
<input class="finput" id="d-year" value="${esc(b.year ?? '')}" inputmode="numeric"></div>
<div class="fgroup">
${candidateSugRows(b, 'isbn', 'd-isbn')}
<label class="flabel">ISBN</label>
<input class="finput" id="d-isbn" value="${esc(b.isbn ?? '')}" inputmode="numeric"></div>
<div class="fgroup">
${candidateSugRows(b, 'publisher', 'd-pub')}
<label class="flabel">Publisher</label>
<input class="finput" id="d-pub" value="${esc(b.publisher ?? '')}"></div>
<div class="fgroup"><label class="flabel">Notes</label>
<textarea class="finput" id="d-notes">${esc(b.notes ?? '')}</textarea></div>
</div>
</div>
</div>`;
}

65
static/js/editing.js Normal file
View File

@@ -0,0 +1,65 @@
/*
* editing.js
* Inline contenteditable name editing for tree nodes (blur-to-save, strips
* leading emoji prefix) and SortableJS drag-and-drop reorder wiring.
*
* SortableJS is loaded as an external CDN script (must precede this file).
* _sortables is managed entirely within this module; render() in init.js
* only needs to call initSortables() to refresh after a full re-render.
*
* Depends on: S (state.js); req, toast (api.js / helpers.js);
* walkTree (tree-render.js); Sortable (CDN global)
* Provides: attachEditables(), initSortables()
*/
// ── SortableJS instances (destroyed and recreated on each render) ─────────────
let _sortables = [];
// ── Inline name editing ──────────────────────────────────────────────────────
function attachEditables() {
document.querySelectorAll('[contenteditable=true]').forEach(el => {
el.dataset.orig = el.textContent.trim();
el.addEventListener('keydown', e => {
if (e.key==='Enter') { e.preventDefault(); el.blur(); }
if (e.key==='Escape') { el.textContent=el.dataset.orig; el.blur(); }
e.stopPropagation();
});
el.addEventListener('blur', async () => {
const val = el.textContent.trim();
if (!val||val===el.dataset.orig) { if(!val) el.textContent=el.dataset.orig; return; }
const newName = val.replace(/^[🏠📚]\s*/u,'').trim();
const {type, id} = el.dataset;
const url = {room:`/api/rooms/${id}`,cabinet:`/api/cabinets/${id}`,shelf:`/api/shelves/${id}`}[type];
if (!url) return;
try {
await req('PUT', url, {name: newName});
el.dataset.orig = el.textContent.trim();
walkTree(n=>{ if(n.id===id) n.name=newName; });
// Update sidebar label if editing from header (sidebar has non-editable nname spans)
const sideLabel = document.querySelector(`.node[data-id="${id}"] .nname`);
if (sideLabel && sideLabel !== el) {
const prefix = type==='room' ? '🏠 ' : type==='cabinet' ? '📚 ' : '';
sideLabel.textContent = prefix + newName;
}
} catch(err) { el.textContent=el.dataset.orig; toast('Rename failed: '+err.message); }
});
el.addEventListener('click', e=>e.stopPropagation());
});
}
// ── SortableJS drag-and-drop ─────────────────────────────────────────────────
function initSortables() {
_sortables.forEach(s=>{ try{s.destroy();}catch(_){} });
_sortables = [];
document.querySelectorAll('.sortable-list').forEach(el => {
const type = el.dataset.type;
_sortables.push(Sortable.create(el, {
handle:'.drag-h', animation:120, ghostClass:'drag-ghost',
onEnd: async () => {
const ids = [...el.querySelectorAll(':scope > .node')].map(n=>n.dataset.id);
try { await req('PATCH',`/api/${type}/reorder`,{ids}); }
catch(err) { toast('Reorder failed'); await loadTree(); }
},
}));
});
}

283
static/js/events.js Normal file
View File

@@ -0,0 +1,283 @@
/*
* events.js
* Event delegation and the central action dispatcher.
*
* Two delegated listeners (click + change) are attached to #app.
* A third click listener is attached to #photo-queue-overlay (outside #app).
* Both delegate through handle(action, dataset, event).
*
* Accordion helpers (getSiblingIds, accordionExpand) implement mobile
* expand-only behaviour: opening one node collapses its siblings.
*
* Depends on: S, _bnd, _batchState, _photoQueue (state.js);
* req (api.js); toast, isDesktop (helpers.js);
* walkTree, removeNode, findNode, parseBounds (tree-render.js /
* canvas-boundary.js); render, renderDetail, startBatchPolling
* (init.js); startCropMode (canvas-crop.js);
* triggerPhoto, collectQueueBooks, renderPhotoQueue (photo.js);
* drawBnd (canvas-boundary.js)
* Provides: handle(), getSiblingIds(), accordionExpand()
*/
// ── Accordion helpers ────────────────────────────────────────────────────────
function getSiblingIds(id, type) {
if (!S.tree) return [];
if (type === 'room') return S.tree.filter(r => r.id !== id).map(r => r.id);
for (const r of S.tree) {
if (type === 'cabinet' && r.cabinets.some(c => c.id === id))
return r.cabinets.filter(c => c.id !== id).map(c => c.id);
for (const c of r.cabinets) {
if (type === 'shelf' && c.shelves.some(s => s.id === id))
return c.shelves.filter(s => s.id !== id).map(s => s.id);
}
}
return [];
}
function accordionExpand(id, type) {
if (!isDesktop()) getSiblingIds(id, type).forEach(sid => S.expanded.delete(sid));
S.expanded.add(id);
}
// ── Event delegation ─────────────────────────────────────────────────────────
document.getElementById('app').addEventListener('click', async e => {
const el = e.target.closest('[data-a]');
if (!el) return;
const d = el.dataset;
try { await handle(d.a, d, e); }
catch(err) { toast('Error: '+err.message); }
});
document.getElementById('app').addEventListener('change', async e => {
const el = e.target.closest('[data-a]');
if (!el) return;
const d = el.dataset;
try { await handle(d.a, d, e); }
catch(err) { toast('Error: '+err.message); }
});
// Photo queue overlay is outside #app so needs its own listener
document.getElementById('photo-queue-overlay').addEventListener('click', async e => {
const el = e.target.closest('[data-a]');
if (!el) return;
const d = el.dataset;
try { await handle(d.a, d, e); }
catch(err) { toast('Error: ' + err.message); }
});
// ── Action dispatcher ────────────────────────────────────────────────────────
async function handle(action, d, e) {
switch (action) {
case 'select': {
// Ignore if the click hit a button or editable inside the row
if (e?.target?.closest('button,[contenteditable]')) return;
if (!isDesktop()) {
// Mobile: room/cabinet/shelf → expand-only (accordion); books → nothing
if (d.type === 'room' || d.type === 'cabinet' || d.type === 'shelf') {
accordionExpand(d.id, d.type);
render();
}
break;
}
S.selected = {type: d.type, id: d.id};
S._loading = {};
render(); break;
}
case 'deselect': {
S.selected = null;
render(); break;
}
case 'toggle': {
if (!isDesktop()) {
// Mobile: expand-only (no collapse to avoid accidental mistaps)
accordionExpand(d.id, d.type);
} else {
if (S.expanded.has(d.id)) { S.expanded.delete(d.id); }
else { S.expanded.add(d.id); }
}
render(); break;
}
// Rooms
case 'add-room': {
const r = await req('POST','/api/rooms');
if (!S.tree) S.tree=[];
S.tree.push({...r, cabinets:[]});
S.expanded.add(r.id); render(); break;
}
case 'del-room': {
if (!confirm('Delete room and all contents?')) break;
await req('DELETE',`/api/rooms/${d.id}`);
removeNode('room',d.id);
if (S.selected?.id===d.id) S.selected=null;
render(); break;
}
// Cabinets
case 'add-cabinet': {
const c = await req('POST',`/api/rooms/${d.id}/cabinets`);
S.tree.forEach(r=>{ if(r.id===d.id) r.cabinets.push({...c,shelves:[]}); });
S.expanded.add(d.id); render(); break; // expand parent room
}
case 'del-cabinet': {
if (!confirm('Delete cabinet and all contents?')) break;
await req('DELETE',`/api/cabinets/${d.id}`);
removeNode('cabinet',d.id);
if (S.selected?.id===d.id) S.selected=null;
render(); break;
}
// Shelves
case 'add-shelf': {
const cab = findNode(d.id);
const prevCount = cab ? cab.shelves.length : 0;
const s = await req('POST',`/api/cabinets/${d.id}/shelves`);
S.tree.forEach(r=>r.cabinets.forEach(c=>{ if(c.id===d.id) c.shelves.push({...s,books:[]}); }));
if (prevCount > 0) {
// Split last segment in half to make room for new shelf
const bounds = parseBounds(cab.shelf_boundaries);
const lastStart = bounds.length ? bounds[bounds.length-1] : 0.0;
const newBound = Math.round((lastStart + 1.0) / 2 * 10000) / 10000;
const newBounds = [...bounds, newBound];
await req('PATCH', `/api/cabinets/${d.id}/boundaries`, {boundaries: newBounds});
S.tree.forEach(r=>r.cabinets.forEach(c=>{ if(c.id===d.id) c.shelf_boundaries=JSON.stringify(newBounds); }));
}
S.expanded.add(d.id); render(); break; // expand parent cabinet
}
case 'del-shelf': {
if (!confirm('Delete shelf and all books?')) break;
await req('DELETE',`/api/shelves/${d.id}`);
removeNode('shelf',d.id);
if (S.selected?.id===d.id) S.selected=null;
render(); break;
}
// Books
case 'add-book': {
const shelf = findNode(d.id);
const prevCount = shelf ? shelf.books.length : 0;
const b = await req('POST',`/api/shelves/${d.id}/books`);
S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>{ if(s.id===d.id) s.books.push(b); })));
if (prevCount > 0) {
// Split last segment in half to make room for new book
const bounds = parseBounds(shelf.book_boundaries);
const lastStart = bounds.length ? bounds[bounds.length-1] : 0.0;
const newBound = Math.round((lastStart + 1.0) / 2 * 10000) / 10000;
const newBounds = [...bounds, newBound];
await req('PATCH', `/api/shelves/${d.id}/boundaries`, {boundaries: newBounds});
S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>{ if(s.id===d.id) s.book_boundaries=JSON.stringify(newBounds); })));
}
S.expanded.add(d.id); render(); break; // expand parent shelf
}
case 'del-book': {
if (!confirm('Delete this book?')) break;
await req('DELETE',`/api/books/${d.id}`);
removeNode('book',d.id);
if (S.selected?.id===d.id) S.selected=null;
render(); break;
}
case 'del-book-confirm': {
if (!confirm('Delete this book?')) break;
await req('DELETE',`/api/books/${d.id}`);
removeNode('book',d.id);
S.selected=null; render(); break;
}
case 'save-book': {
const data = {
title: document.getElementById('d-title')?.value || '',
author: document.getElementById('d-author')?.value || '',
year: document.getElementById('d-year')?.value || '',
isbn: document.getElementById('d-isbn')?.value || '',
publisher: document.getElementById('d-pub')?.value || '',
notes: document.getElementById('d-notes')?.value || '',
};
const res = await req('PUT',`/api/books/${d.id}`,data);
walkTree(n => {
if (n.id === d.id) {
Object.assign(n, data);
n.ai_title = data.title; n.ai_author = data.author; n.ai_year = data.year;
n.ai_isbn = data.isbn; n.ai_publisher = data.publisher;
n.identification_status = res.identification_status ?? n.identification_status;
}
});
toast('Saved'); render(); break;
}
case 'run-plugin': {
const key = `${d.plugin}:${d.id}`;
S._loading[key] = true; renderDetail();
try {
const res = await req('POST', `/api/${d.etype}/${d.id}/plugin/${d.plugin}`);
walkTree(n => { if (n.id === d.id) Object.assign(n, res); });
} catch(err) { toast(`${d.plugin} failed: ${err.message}`); }
delete S._loading[key]; renderDetail();
break;
}
case 'select-bnd-plugin': {
if (_bnd) { _bnd.selectedPlugin = e.target.value || null; drawBnd(); }
break;
}
case 'accept-field': {
const inp = document.getElementById(d.input);
if (inp) inp.value = d.value;
walkTree(n => { if (n.id === d.id) n[d.field] = d.value; });
renderDetail(); break;
}
case 'dismiss-field': {
const res = await req('POST', `/api/books/${d.id}/dismiss-field`, {field: d.field, value: d.value || ''});
walkTree(n => {
if (n.id === d.id) {
n.candidates = JSON.stringify(res.candidates || []);
if (!d.value) n[`ai_${d.field}`] = n[d.field] || '';
n.identification_status = res.identification_status ?? n.identification_status;
}
});
renderDetail(); break;
}
case 'batch-start': {
const res = await req('POST', '/api/batch');
if (res.already_running) { toast('Batch already running'); break; }
if (!res.started) { toast('No unidentified books'); break; }
_batchState = {running: true, total: res.total, done: 0, errors: 0, current: ''};
startBatchPolling(); renderDetail(); break;
}
// Photo
case 'photo': triggerPhoto(d.type, d.id); break;
// Crop
case 'crop-start': startCropMode(d.type, d.id); break;
// Photo queue
case 'photo-queue-start': {
const node = findNode(d.id);
if (!node) break;
const books = collectQueueBooks(node, d.type);
if (!books.length) { toast('No unidentified books'); break; }
_photoQueue = {books, index: 0, processing: false};
renderPhotoQueue();
break;
}
case 'photo-queue-take': {
if (!_photoQueue) break;
const book = _photoQueue.books[_photoQueue.index];
if (!book) break;
triggerPhoto('book', book.id);
break;
}
case 'photo-queue-skip': {
if (!_photoQueue) break;
_photoQueue.index++;
renderPhotoQueue();
break;
}
case 'photo-queue-close': {
_photoQueue = null;
renderPhotoQueue();
break;
}
}
}

21
static/js/helpers.js Normal file
View File

@@ -0,0 +1,21 @@
/*
* helpers.js
* Pure utility functions with no dependencies on other application modules.
* Safe to call from any JS file.
*
* Provides: esc(), toast(), isDesktop()
*/
// ── Helpers ─────────────────────────────────────────────────────────────────
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function toast(msg, dur = 2800) {
const el = document.getElementById('toast');
el.textContent = msg; el.classList.add('on');
clearTimeout(toast._t);
toast._t = setTimeout(() => el.classList.remove('on'), dur);
}
function isDesktop() { return window.innerWidth >= 768; }

82
static/js/init.js Normal file
View File

@@ -0,0 +1,82 @@
/*
* init.js
* Application bootstrap: full render, partial detail re-render, config and
* tree loading, batch-status polling, and the initial Promise.all boot call.
*
* render() is the single source of truth for full repaints — it replaces
* #app innerHTML, re-attaches editables, reinitialises Sortable instances,
* and (on desktop) schedules the boundary canvas setup.
*
* renderDetail() does a cheaper in-place update of the right panel only,
* used during plugin runs and field edits to avoid re-rendering the sidebar.
*
* Depends on: S, _plugins, _batchState, _batchPollTimer (state.js);
* req, toast (api.js / helpers.js); isDesktop (helpers.js);
* vApp, vDetailBody, mainTitle, mainHeaderBtns, vBatchBtn
* (tree-render.js / detail-render.js);
* attachEditables, initSortables (editing.js);
* setupDetailCanvas (canvas-boundary.js)
* Provides: render(), renderDetail(), loadConfig(), startBatchPolling(),
* loadTree()
*/
// ── Full re-render ────────────────────────────────────────────────────────────
function render() {
if (document.activeElement?.contentEditable === 'true') return;
const sy = window.scrollY;
document.getElementById('app').innerHTML = vApp();
window.scrollTo(0, sy);
attachEditables();
initSortables();
if (isDesktop()) requestAnimationFrame(setupDetailCanvas);
}
// ── Right-panel partial re-render ─────────────────────────────────────────────
// Used during plugin runs and field edits to avoid re-rendering the sidebar.
function renderDetail() {
const body = document.getElementById('main-body');
if (body) body.innerHTML = vDetailBody();
const t = document.getElementById('main-title');
if (t) t.innerHTML = mainTitle(); // innerHTML: mainTitle() returns an HTML span
const hb = document.getElementById('main-hdr-btns');
if (hb) hb.innerHTML = mainHeaderBtns();
const bb = document.getElementById('main-hdr-batch');
if (bb) bb.innerHTML = vBatchBtn();
attachEditables(); // pick up the new editable span in the header
requestAnimationFrame(setupDetailCanvas);
}
// ── Data loading ──────────────────────────────────────────────────────────────
async function loadConfig() {
try {
const cfg = await req('GET','/api/config');
window._grabPx = cfg.boundary_grab_px ?? 14;
window._confidenceThreshold = cfg.confidence_threshold ?? 0.8;
_plugins = cfg.plugins || [];
} catch { window._grabPx = 14; window._confidenceThreshold = 0.8; }
}
function startBatchPolling() {
if (_batchPollTimer) clearInterval(_batchPollTimer);
_batchPollTimer = setInterval(async () => {
try {
const st = await req('GET', '/api/batch/status');
_batchState = st;
const bb = document.getElementById('main-hdr-batch');
if (bb) bb.innerHTML = vBatchBtn();
if (!st.running) {
clearInterval(_batchPollTimer); _batchPollTimer = null;
toast(`Batch: ${st.done} done, ${st.errors} errors`);
await loadTree();
}
} catch { /* ignore poll errors */ }
}, 2000);
}
async function loadTree() {
S.tree = await req('GET','/api/tree');
render();
}
// ── Init ──────────────────────────────────────────────────────────────────────
Promise.all([loadConfig(), loadTree()]);

138
static/js/photo.js Normal file
View File

@@ -0,0 +1,138 @@
/*
* photo.js
* Photo upload for all entity types and the mobile Photo Queue feature.
*
* Photo upload:
* triggerPhoto(type, id) — opens the hidden file input, sets _photoTarget.
* The 'change' handler uploads via multipart POST, updates the tree node,
* and on mobile automatically runs the full AI pipeline for books
* (POST /api/books/{id}/process).
*
* Photo Queue (mobile-only UI):
* collectQueueBooks(node, type) — collects all non-approved books in tree
* order (top-to-bottom within each shelf, left-to-right across shelves).
* renderPhotoQueue() — updates the #photo-queue-overlay DOM in-place.
* Queue flow: show spine → tap camera → upload + process → auto-advance.
* Queue is stored in _photoQueue (state.js) so events.js can control it.
*
* Depends on: S, _photoQueue (state.js); req, toast (api.js / helpers.js);
* walkTree, findNode, esc (tree-render.js / helpers.js);
* isDesktop, render (helpers.js / init.js)
* Provides: collectQueueBooks(), renderPhotoQueue(), triggerPhoto()
*/
// ── Photo Queue ──────────────────────────────────────────────────────────────
function collectQueueBooks(node, type) {
const books = [];
function collect(n, t) {
if (t === 'book') {
if (n.identification_status !== 'user_approved') books.push(n);
return;
}
if (t === 'room') n.cabinets.forEach(c => collect(c, 'cabinet'));
if (t === 'cabinet') n.shelves.forEach(s => collect(s, 'shelf'));
if (t === 'shelf') n.books.forEach(b => collect(b, 'book'));
}
collect(node, type);
return books;
}
function renderPhotoQueue() {
const el = document.getElementById('photo-queue-overlay');
if (!el) return;
if (!_photoQueue) { el.style.display = 'none'; el.innerHTML = ''; return; }
const {books, index, processing} = _photoQueue;
el.style.display = 'flex';
if (index >= books.length) {
el.innerHTML = `<div class="pq-hdr">
<button class="hbtn" data-a="photo-queue-close">✕</button>
<span class="pq-hdr-title">Photo Queue</span>
<span style="min-width:34px"></span>
</div>
<div class="pq-spine-wrap" style="text-align:center">
<div style="font-size:3rem">✓</div>
<div style="font-size:1.1rem;color:#86efac;font-weight:600">All done!</div>
<div style="font-size:.82rem;color:#94a3b8;margin-top:4px">All ${books.length} book${books.length !== 1 ? 's' : ''} photographed</div>
<button class="btn btn-p" style="margin-top:20px" data-a="photo-queue-close">Close</button>
</div>`;
return;
}
const book = books[index];
el.innerHTML = `<div class="pq-hdr">
<button class="hbtn" data-a="photo-queue-close">✕</button>
<span class="pq-hdr-title">${index + 1} / ${books.length}</span>
<span style="min-width:34px"></span>
</div>
<div class="pq-spine-wrap">
<img class="pq-spine-img" src="/api/books/${book.id}/spine?t=${Date.now()}" alt="Spine"
onerror="this.style.display='none'">
<div class="pq-book-name">${esc(book.title || '—')}</div>
</div>
<div class="pq-actions">
<button class="pq-skip-btn" data-a="photo-queue-skip">Skip</button>
<button class="pq-camera-btn" data-a="photo-queue-take">📷</button>
</div>
${processing ? '<div class="pq-processing"><div class="spinner"></div><span>Processing…</span></div>' : ''}`;
}
// ── Photo upload ─────────────────────────────────────────────────────────────
const gphoto = document.getElementById('gphoto');
function triggerPhoto(type, id) {
S._photoTarget = {type, id};
if (/Android|iPhone|iPad/i.test(navigator.userAgent)) gphoto.setAttribute('capture','environment');
else gphoto.removeAttribute('capture');
gphoto.value = '';
gphoto.click();
}
gphoto.addEventListener('change', async () => {
const file = gphoto.files[0];
if (!file || !S._photoTarget) return;
const {type, id} = S._photoTarget;
S._photoTarget = null;
const fd = new FormData();
fd.append('image', file, file.name); // HD — no client-side compression
const urls = {
room: `/api/rooms/${id}/photo`,
cabinet: `/api/cabinets/${id}/photo`,
shelf: `/api/shelves/${id}/photo`,
book: `/api/books/${id}/photo`,
};
try {
const res = await req('POST', urls[type], fd, true);
const key = type==='book' ? 'image_filename' : 'photo_filename';
walkTree(n=>{ if(n.id===id) n[key]=res[key]; });
// Photo queue mode: process and advance without full re-render
if (_photoQueue && type === 'book') {
_photoQueue.processing = true;
renderPhotoQueue();
const book = findNode(id);
if (book && book.identification_status !== 'user_approved') {
try {
const br = await req('POST', `/api/books/${id}/process`);
walkTree(n => { if (n.id === id) Object.assign(n, br); });
} catch { /* continue queue on process error */ }
}
_photoQueue.processing = false;
_photoQueue.index++;
renderPhotoQueue();
return;
}
render();
// Mobile: auto-queue AI after photo upload (books only)
if (!isDesktop()) {
if (type === 'book') {
const book = findNode(id);
if (book && book.identification_status !== 'user_approved') {
try {
const br = await req('POST', `/api/books/${id}/process`);
walkTree(n => { if(n.id===id) Object.assign(n, br); });
toast(`Photo saved · Identified (${br.identification_status})`);
render();
} catch { toast('Photo saved'); }
} else { toast('Photo saved'); }
} else { toast('Photo saved'); }
} else { toast('Photo saved'); }
} catch(err) { toast('Upload failed: '+err.message); }
});

41
static/js/state.js Normal file
View File

@@ -0,0 +1,41 @@
/*
* state.js
* All mutable application state — loaded first so every other module
* can read and write these globals without forward-reference issues.
*
* Provides:
* S — main UI state (tree data, selection, loading flags)
* _plugins — plugin manifest populated from GET /api/config
* _batchState — current batch-processing progress
* _batchPollTimer — setInterval handle for batch polling
* _bnd — live boundary-canvas state (written by canvas-boundary.js,
* read by detail-render.js)
* _photoQueue — photo queue session state (written by photo.js,
* read by events.js)
*/
// ── Main UI state ───────────────────────────────────────────────────────────
let S = {
tree: null,
expanded: new Set(),
selected: null, // {type:'cabinet'|'shelf'|'book', id}
_photoTarget: null, // {type, id}
_loading: {}, // {`${pluginId}:${entityId}`: true}
_cropMode: null, // {type, id} while crop UI is active
};
// ── Plugin registry ─────────────────────────────────────────────────────────
let _plugins = []; // populated from GET /api/config
// ── Batch processing state ──────────────────────────────────────────────────
let _batchState = {running: false, total: 0, done: 0, errors: 0, current: ''};
let _batchPollTimer = null;
// ── Boundary canvas live state ───────────────────────────────────────────────
// Owned by canvas-boundary.js; declared here so detail-render.js can read it
// without a circular load dependency.
let _bnd = null; // {wrap,img,canvas,axis,boundaries[],pluginResults{},selectedPlugin,segments[],nodeId,nodeType}
// ── Photo queue session state ────────────────────────────────────────────────
// Owned by photo.js; declared here so events.js can read/write it.
let _photoQueue = null; // {books:[...], index:0, processing:false}

321
static/js/tree-render.js Normal file
View File

@@ -0,0 +1,321 @@
/*
* tree-render.js
* HTML-string generators for the entire sidebar tree and the app shell.
* Also owns the tree-mutation helpers (walkTree, removeNode, findNode)
* and plugin query helpers (pluginsByCategory, pluginsByTarget, isLoading).
*
* Depends on: S, _plugins, _batchState (state.js); esc, isDesktop (helpers.js)
* Provides: walkTree(), removeNode(), findNode(), pluginsByCategory(),
* pluginsByTarget(), isLoading(), vPluginBtn(), vBatchBtn(),
* SOURCE_LABELS, getSourceLabel(), parseCandidates(),
* candidateSugRows(), vApp(), mainTitle(), mainHeaderBtns(),
* vTreeBody(), vRoom(), vCabinet(), vShelf(), _STATUS_BADGE,
* vBook(), getBookStats(), vAiProgressBar()
*/
// ── Plugin helpers ───────────────────────────────────────────────────────────
function pluginsByCategory(cat) { return _plugins.filter(p => p.category === cat); }
function pluginsByTarget(cat, target) { return _plugins.filter(p => p.category === cat && p.target === target); }
function isLoading(pluginId, entityId) { return !!S._loading[`${pluginId}:${entityId}`]; }
function vPluginBtn(plugin, entityId, entityType, extraDisabled = false) {
const loading = isLoading(plugin.id, entityId);
const label = loading ? '⏳' : esc(plugin.name);
return `<button class="btn btn-s" style="padding:2px 7px;font-size:.78rem;min-height:0"
data-a="run-plugin" data-plugin="${plugin.id}" data-id="${entityId}"
data-etype="${entityType}"${(loading||extraDisabled)?' disabled':''}
title="${esc(plugin.name)}">${label}</button>`;
}
// ── Batch button ─────────────────────────────────────────────────────────────
function vBatchBtn() {
if (_batchState.running)
return `<span style="font-size:.72rem;opacity:.8">${_batchState.done}/${_batchState.total} ⏳</span>`;
return `<button class="hbtn" data-a="batch-start" title="Analyze all unidentified books">🔄</button>`;
}
// ── Candidate suggestion rows ────────────────────────────────────────────────
const SOURCE_LABELS = {
vlm: 'VLM', ai: 'AI', openlibrary: 'OpenLib',
rsl: 'РГБ', rusneb: 'НЭБ', alib: 'Alib', nlr: 'НЛР', shpl: 'ШПИЛ',
};
function getSourceLabel(source) {
if (SOURCE_LABELS[source]) return SOURCE_LABELS[source];
const p = _plugins.find(pl => pl.id === source);
return p ? p.name : source;
}
function parseCandidates(json) {
if (!json) return [];
try { return JSON.parse(json) || []; } catch { return []; }
}
function candidateSugRows(b, field, inputId) {
const userVal = (b[field] || '').trim();
const candidates = parseCandidates(b.candidates);
// Group by normalised value, collecting sources
const byVal = new Map(); // lower → {display, sources[]}
for (const c of candidates) {
const v = (c[field] || '').trim();
if (!v) continue;
const key = v.toLowerCase();
if (!byVal.has(key)) byVal.set(key, {display: v, sources: []});
const entry = byVal.get(key);
if (!entry.sources.includes(c.source)) entry.sources.push(c.source);
}
// Fallback: include legacy ai_* field if not already in candidates
const aiVal = (b[`ai_${field}`] || '').trim();
if (aiVal) {
const key = aiVal.toLowerCase();
if (!byVal.has(key)) byVal.set(key, {display: aiVal, sources: []});
const entry = byVal.get(key);
if (!entry.sources.includes('ai')) entry.sources.unshift('ai');
}
return [...byVal.entries()]
.filter(([k]) => k !== userVal.toLowerCase())
.map(([, {display, sources}]) => {
const badges = sources.map(s =>
`<span class="src-badge src-${esc(s)}">${esc(getSourceLabel(s))}</span>`
).join(' ');
const val = esc(display);
return `<div class="ai-sug">
${badges} <em>${val}</em>
<button class="btn btn-g" style="padding:1px 6px;font-size:.75rem;min-height:0"
data-a="accept-field" data-id="${b.id}" data-field="${field}"
data-value="${val}" data-input="${inputId}" title="Accept">✓</button>
<button class="btn btn-r" style="padding:1px 6px;font-size:.75rem;min-height:0"
data-a="dismiss-field" data-id="${b.id}" data-field="${field}"
data-value="${val}" title="Dismiss">✗</button>
</div>`;
}).join('');
}
// ── App shell ────────────────────────────────────────────────────────────────
function vApp() {
return `<div class="layout">
<div class="sidebar">
<div class="hdr"><h1 data-a="deselect" style="cursor:pointer" title="Back to overview">📚 Bookshelf</h1></div>
<div class="sidebar-body">
${vTreeBody()}
<button class="add-root" data-a="add-room">+ Add Room</button>
</div>
</div>
<div class="main-panel">
<div class="main-hdr" id="main-hdr">
<h2 id="main-title">${mainTitle()}</h2>
<div id="main-hdr-batch">${vBatchBtn()}</div>
<div id="main-hdr-btns">${mainHeaderBtns()}</div>
</div>
<div class="main-body" id="main-body">${vDetailBody()}</div>
</div>
</div>`;
}
function mainTitle() {
if (!S.selected) return '<span style="opacity:.7">Select a room, cabinet or shelf</span>';
const n = findNode(S.selected.id);
const {type, id} = S.selected;
if (type === 'book') {
return `<span>${esc(n?.title || 'Untitled book')}</span>`;
}
const name = esc(n?.name || '');
return `<span class="hdr-edit" contenteditable="true" data-type="${type}" data-id="${id}" spellcheck="false">${name}</span>`;
}
function mainHeaderBtns() {
if (!S.selected) return '';
const {type, id} = S.selected;
if (type === 'room') {
return `<div style="display:flex;gap:2px">
<button class="hbtn" data-a="add-cabinet" data-id="${id}" title="Add cabinet"></button>
<button class="hbtn" data-a="del-room" data-id="${id}" title="Delete room">🗑</button>
</div>`;
}
if (type === 'cabinet') {
const cab = findNode(id);
return `<div style="display:flex;gap:2px">
<button class="hbtn" data-a="photo" data-type="cabinet" data-id="${id}" title="Upload photo">📷</button>
${cab?.photo_filename ? `<button class="hbtn" data-a="crop-start" data-type="cabinet" data-id="${id}" title="Crop photo">✂️</button>` : ''}
<button class="hbtn" data-a="del-cabinet" data-id="${id}" title="Delete cabinet">🗑</button>
</div>`;
}
if (type === 'shelf') {
const shelf = findNode(id);
return `<div style="display:flex;gap:2px">
<button class="hbtn" data-a="photo" data-type="shelf" data-id="${id}" title="Upload override photo">📷</button>
${shelf?.photo_filename ? `<button class="hbtn" data-a="crop-start" data-type="shelf" data-id="${id}" title="Crop override photo">✂️</button>` : ''}
<button class="hbtn" data-a="del-shelf" data-id="${id}" title="Delete shelf">🗑</button>
</div>`;
}
if (type === 'book') {
return `<div style="display:flex;gap:2px">
<button class="hbtn" data-a="save-book" data-id="${id}" title="Save">💾</button>
<button class="hbtn" data-a="photo" data-type="book" data-id="${id}" title="Upload title page">📷</button>
<button class="hbtn" data-a="del-book-confirm" data-id="${id}" title="Delete book">🗑</button>
</div>`;
}
return '';
}
// ── Tree body ────────────────────────────────────────────────────────────────
function vTreeBody() {
if (!S.tree) return '<div class="loading"><div class="spinner"></div>Loading…</div>';
if (!S.tree.length) return '<div class="empty"><div class="ei">📚</div><div>No rooms yet</div></div>';
return `<div class="sortable-list" data-type="rooms">${S.tree.map(vRoom).join('')}</div>`;
}
function vRoom(r) {
const exp = S.expanded.has(r.id);
const sel = S.selected?.id === r.id;
return `<div class="node" data-id="${r.id}" data-type="room">
<div class="nrow nrow-room${sel?' sel':''}" data-a="select" data-type="room" data-id="${r.id}">
<span class="drag-h">⠿</span>
<button class="tbtn ${exp?'':'col'}" data-a="toggle" data-type="room" data-id="${r.id}">▾</button>
<span class="nname" data-type="room" data-id="${r.id}">🏠 ${esc(r.name)}</span>
<div class="nacts">
<button class="ibtn" data-a="add-cabinet" data-id="${r.id}" title="Add cabinet"></button>
<button class="ibtn" data-a="del-room" data-id="${r.id}" title="Delete">🗑</button>
</div>
</div>
${exp ? `<div class="nchildren"><div class="sortable-list" data-type="cabinets" data-parent="${r.id}">
${r.cabinets.map(vCabinet).join('')}
</div></div>` : ''}
</div>`;
}
function vCabinet(c) {
const exp = S.expanded.has(c.id);
const sel = S.selected?.id === c.id;
return `<div class="node" data-id="${c.id}" data-type="cabinet">
<div class="nrow nrow-cabinet${sel?' sel':''}" data-a="select" data-type="cabinet" data-id="${c.id}">
<span class="drag-h">⠿</span>
<button class="tbtn ${exp?'':'col'}" data-a="toggle" data-type="cabinet" data-id="${c.id}">▾</button>
${c.photo_filename ? `<img src="/images/${c.photo_filename}" style="width:26px;height:32px;object-fit:cover;border-radius:2px;flex-shrink:0" alt="">` : ''}
<span class="nname" data-type="cabinet" data-id="${c.id}">📚 ${esc(c.name)}</span>
<div class="nacts">
${!isDesktop() ? `<button class="ibtn" data-a="photo" data-type="cabinet" data-id="${c.id}" title="Photo">📷</button>` : ''}
${!isDesktop() ? `<button class="ibtn" data-a="photo-queue-start" data-type="cabinet" data-id="${c.id}" title="Book photo queue">📸</button>` : ''}
<button class="ibtn" data-a="add-shelf" data-id="${c.id}" title="Add shelf"></button>
${!isDesktop() ? `<button class="ibtn" data-a="del-cabinet" data-id="${c.id}" title="Delete">🗑</button>` : ''}
</div>
</div>
${exp ? `<div class="nchildren"><div class="sortable-list" data-type="shelves" data-parent="${c.id}">
${c.shelves.map(vShelf).join('')}
</div></div>` : ''}
</div>`;
}
function vShelf(s) {
const exp = S.expanded.has(s.id);
const sel = S.selected?.id === s.id;
return `<div class="node" data-id="${s.id}" data-type="shelf">
<div class="nrow nrow-shelf${sel?' sel':''}" data-a="select" data-type="shelf" data-id="${s.id}">
<span class="drag-h">⠿</span>
<button class="tbtn ${exp?'':'col'}" data-a="toggle" data-type="shelf" data-id="${s.id}">▾</button>
<span class="nname" data-type="shelf" data-id="${s.id}">${esc(s.name)}</span>
<div class="nacts">
${!isDesktop() ? `<button class="ibtn" data-a="photo" data-type="shelf" data-id="${s.id}" title="Photo">📷</button>` : ''}
${!isDesktop() ? `<button class="ibtn" data-a="photo-queue-start" data-type="shelf" data-id="${s.id}" title="Book photo queue">📸</button>` : ''}
<button class="ibtn" data-a="add-book" data-id="${s.id}" title="Add book"></button>
${!isDesktop() ? `<button class="ibtn" data-a="del-shelf" data-id="${s.id}" title="Delete">🗑</button>` : ''}
</div>
</div>
${exp ? `<div class="nchildren"><div class="sortable-list" data-type="books" data-parent="${s.id}">
${s.books.map(vBook).join('')}
</div></div>` : ''}
</div>`;
}
const _STATUS_BADGE = {
unidentified: ['s-unid', '?'],
ai_identified: ['s-aiid', 'AI'],
user_approved: ['s-appr', '✓'],
};
function vBook(b) {
const [sc, sl] = _STATUS_BADGE[b.identification_status] ?? _STATUS_BADGE.unidentified;
const sub = [b.author, b.year].filter(Boolean).join(' · ');
const sel = S.selected?.id === b.id;
return `<div class="node" data-id="${b.id}" data-type="book">
<div class="nrow nrow-book${sel?' sel':''}" data-a="select" data-type="book" data-id="${b.id}">
<span class="drag-h">⠿</span>
<span class="sbadge ${sc}" title="${b.identification_status ?? 'unidentified'}">${sl}</span>
${b.image_filename ? `<img src="/images/${b.image_filename}" class="bthumb" alt="">` : `<div class="bthumb-ph">📖</div>`}
<div class="binfo">
<div class="bttl">${esc(b.title || '—')}</div>
${sub ? `<div class="bsub">${esc(sub)}</div>` : ''}
</div>
${!isDesktop() ? `<div class="nacts">
<button class="ibtn" data-a="photo" data-type="book" data-id="${b.id}" title="Upload photo">📷</button>
<button class="ibtn" data-a="del-book" data-id="${b.id}" title="Delete">🗑</button>
</div>` : ''}
</div>
</div>`;
}
// ── Book stats helper (recursive) ────────────────────────────────────────────
function getBookStats(node, type) {
const books = [];
function collect(n, t) {
if (t==='book') { books.push(n); return; }
if (t==='room') (n.cabinets||[]).forEach(c => collect(c,'cabinet'));
if (t==='cabinet') (n.shelves||[]).forEach(s => collect(s,'shelf'));
if (t==='shelf') (n.books||[]).forEach(b => collect(b,'book'));
}
collect(node, type);
return {
total: books.length,
approved: books.filter(b=>b.identification_status==='user_approved').length,
ai: books.filter(b=>b.identification_status==='ai_identified').length,
unidentified: books.filter(b=>b.identification_status==='unidentified').length,
};
}
function vAiProgressBar(stats) {
const {total, approved, ai, unidentified} = stats;
if (!total || approved === total) return '';
const pA = (approved/total*100).toFixed(1);
const pI = (ai/total*100).toFixed(1);
const pU = (unidentified/total*100).toFixed(1);
return `<div style="margin-bottom:10px;background:white;border-radius:8px;padding:10px;box-shadow:0 1px 3px rgba(0,0,0,.07)">
<div style="display:flex;gap:8px;font-size:.7rem;margin-bottom:5px">
<span style="color:#15803d">✓ ${approved} approved</span><span style="color:#94a3b8">·</span>
<span style="color:#b45309">AI ${ai}</span><span style="color:#94a3b8">·</span>
<span style="color:#64748b">? ${unidentified} unidentified</span>
</div>
<div style="height:6px;border-radius:3px;background:#e2e8f0;overflow:hidden;display:flex">
<div style="width:${pA}%;background:#15803d;flex-shrink:0"></div>
<div style="width:${pI}%;background:#f59e0b;flex-shrink:0"></div>
<div style="width:${pU}%;background:#94a3b8;flex-shrink:0"></div>
</div>
</div>`;
}
// ── Tree helpers ─────────────────────────────────────────────────────────────
function walkTree(fn) {
if (!S.tree) return;
for (const r of S.tree) { fn(r,'room');
for (const c of r.cabinets) { fn(c,'cabinet');
for (const s of c.shelves) { fn(s,'shelf');
for (const b of s.books) fn(b,'book');
}
}
}
}
function removeNode(type, id) {
if (!S.tree) return;
if (type==='room') S.tree = S.tree.filter(r=>r.id!==id);
if (type==='cabinet') S.tree.forEach(r=>r.cabinets=r.cabinets.filter(c=>c.id!==id));
if (type==='shelf') S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves=c.shelves.filter(s=>s.id!==id)));
if (type==='book') S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>s.books=s.books.filter(b=>b.id!==id))));
}
function findNode(id) {
let found = null;
walkTree(n => { if (n.id===id) found=n; });
return found;
}