- log_thread.py: thread-safe ContextVar bridge so executor threads can log
individual LLM calls and archive searches back to the event loop
- ai_log.py: init_thread_logging(), notify_entity_update(); WS now pushes
entity_update messages when book data changes after any plugin or batch run
- batch.py: replace batch_pending.json with batch_queue SQLite table;
run_batch_consumer() reads queue dynamically so new books can be added
while batch is running; add_to_queue() deduplicates
- migrate.py: fix _migrate_v1 (clear-on-startup bug); add _migrate_v2 for
batch_queue table
- _client.py / archive.py / identification.py: wrap each LLM API call and
archive search with log_thread start/finish entries
- api.py: POST /api/batch returns {already_running, added}; notify_entity_update
after identify pipeline
- models.default.yaml: strengthen ai_identify confidence-scoring instructions;
warn against placeholder data
- detail-render.js: book log entries show clickable ID + spine thumbnail;
book spine/title images open full-screen popup
- events.js: batch-start handles already_running+added; open-img-popup action
- init.js: entity_update WS handler; image popup close listeners
- overlays.css / index.html: full-screen image popup overlay
- eslint.config.js: add new globals; fix no-redeclare/no-unused-vars for
multi-file global architecture; all lint errors resolved
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
382 lines
17 KiB
JavaScript
382 lines
17 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()
|
||
*/
|
||
|
||
/* exported pluginsByCategory, pluginsByTarget, isLoading, vPluginBtn, vBatchBtn, vAiIndicator,
|
||
candidateSugRows, vApp, mainTitle, mainHeaderBtns, _STATUS_BADGE,
|
||
getBookStats, vAiProgressBar, walkTree, removeNode, findNode */
|
||
|
||
// ── 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>`;
|
||
}
|
||
|
||
// ── AI active indicator ───────────────────────────────────────────────────────
|
||
function vAiIndicator(count) {
|
||
return `<span class="ai-indicator" title="${count} AI request${count === 1 ? '' : 's'} running"><span class="ai-dot"></span>${count}</span>`;
|
||
}
|
||
|
||
// ── 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() {
|
||
const running = (_aiLog || []).filter((e) => e.status === 'running').length;
|
||
return `<div class="page-wrap">
|
||
<div class="hdr">
|
||
<h1 data-a="deselect" style="cursor:pointer;flex:1" title="Back to overview">📚 Bookshelf</h1>
|
||
<div id="hdr-ai-indicator">${running > 0 ? vAiIndicator(running) : ''}</div>
|
||
<div id="main-hdr-batch">${vBatchBtn()}</div>
|
||
</div>
|
||
<div class="layout">
|
||
<div class="sidebar">
|
||
<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-btns">${mainHeaderBtns()}</div>
|
||
</div>
|
||
<div class="main-body" id="main-body">${vDetailBody()}</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function mainTitle() {
|
||
if (!S.selected) return '📚 Bookshelf';
|
||
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;
|
||
}
|