/*
* 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 ``;
}
// ── Batch button ─────────────────────────────────────────────────────────────
function vBatchBtn() {
if (_batchState.running)
return `${_batchState.done}/${_batchState.total} ⏳`;
return ``;
}
// ── AI active indicator ───────────────────────────────────────────────────────
function vAiIndicator(count) {
return `${count}`;
}
// ── 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) => `${esc(getSourceLabel(s))}`)
.join(' ');
const val = esc(display);
return `
${badges} ${val}
`;
})
.join('');
}
// ── App shell ────────────────────────────────────────────────────────────────
function vApp() {
const running = (_aiLog || []).filter((e) => e.status === 'running').length;
return `
📚 Bookshelf
${running > 0 ? vAiIndicator(running) : ''}
${vBatchBtn()}
${mainTitle()}
${mainHeaderBtns()}
${vDetailBody()}
`;
}
function mainTitle() {
if (!S.selected) return '📚 Bookshelf';
const n = findNode(S.selected.id);
const { type, id } = S.selected;
if (type === 'book') {
return `${esc(n?.title || 'Untitled book')}`;
}
const name = esc(n?.name || '');
return `${name}`;
}
function mainHeaderBtns() {
if (!S.selected) return '';
const { type, id } = S.selected;
if (type === 'room') {
return `
`;
}
if (type === 'cabinet') {
const cab = findNode(id);
return `
${cab?.photo_filename ? `` : ''}
`;
}
if (type === 'shelf') {
const shelf = findNode(id);
return `
${shelf?.photo_filename ? `` : ''}
`;
}
if (type === 'book') {
return `
`;
}
return '';
}
// ── Tree body ────────────────────────────────────────────────────────────────
function vTreeBody() {
if (!S.tree) return '';
if (!S.tree.length) return '';
return `${S.tree.map(vRoom).join('')}
`;
}
function vRoom(r) {
const exp = S.expanded.has(r.id);
const sel = S.selected?.id === r.id;
return `
${
exp
? `
${r.cabinets.map(vCabinet).join('')}
`
: ''
}
`;
}
function vCabinet(c) {
const exp = S.expanded.has(c.id);
const sel = S.selected?.id === c.id;
return `
⠿
${c.photo_filename ? `

` : ''}
📚 ${esc(c.name)}
${!isDesktop() ? `` : ''}
${!isDesktop() ? `` : ''}
${!isDesktop() ? `` : ''}
${
exp
? `
${c.shelves.map(vShelf).join('')}
`
: ''
}
`;
}
function vShelf(s) {
const exp = S.expanded.has(s.id);
const sel = S.selected?.id === s.id;
return `
⠿
${esc(s.name)}
${!isDesktop() ? `` : ''}
${!isDesktop() ? `` : ''}
${!isDesktop() ? `` : ''}
${
exp
? `
${s.books.map(vBook).join('')}
`
: ''
}
`;
}
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 `
⠿
${sl}
${b.image_filename ? `

` : `
📖
`}
${esc(b.title || '—')}
${sub ? `
${esc(sub)}
` : ''}
${
!isDesktop()
? `
`
: ''
}
`;
}
// ── 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 `
✓ ${approved} approved·
AI ${ai}·
? ${unidentified} unidentified
`;
}
// ── 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;
}