/* * 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 ``; } // ── Batch button ───────────────────────────────────────────────────────────── function vBatchBtn() { if (_batchState.running) return `${_batchState.done}/${_batchState.total} ⏳`; return ``; } // ── 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() { return `

${mainTitle()}

${vBatchBtn()}
${mainHeaderBtns()}
${vDetailBody()}
`; } function mainTitle() { if (!S.selected) return 'Select a room, cabinet or shelf'; 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; }