Files
bookshelf/static/js/tree-render.js
night 5d5f26c8ae 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.
2026-03-09 14:16:23 +03:00

322 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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;
}