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.
322 lines
16 KiB
JavaScript
322 lines
16 KiB
JavaScript
/*
|
||
* 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;
|
||
}
|