/* * 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 '
Loading…
'; if (!S.tree.length) return '
📚
No rooms yet
'; return `
${S.tree.map(vRoom).join('')}
`; } function vRoom(r) { const exp = S.expanded.has(r.id); const sel = S.selected?.id === r.id; return `
🏠 ${esc(r.name)}
${ 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; }