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:
2026-03-09 14:17:13 +03:00
commit 084d1aebd5
64 changed files with 8605 additions and 0 deletions

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;
}