/*
* 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 `${label} `;
}
// ── 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 '';
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;
}