/* * Manage Enrichment Workers modal. * * The dashboard "enrichment bubbles" expose hover/pause but no way to *manage* * a worker. This modal surfaces, per worker: live status + current item, * pause/resume, a matched/not-found/pending breakdown per entity type, and a * searchable/paginated browser of the items that source hasn't matched — each * with inline manual-match (reusing /api/library/search-service + * manual-match) and retry (clear-match, which re-queues the item). * * Backend: GET /api/enrichment//{status,breakdown,unmatched}, POST * .../{pause,resume}. The unmatched/breakdown routes are generic across all 11 * workers (see core/enrichment/unmatched.py). */ // Per-source accent + the CSS selector of that worker's logo already rendered // in the dashboard bubble. We reuse those exact sources at runtime // (via _emLogoSrc) so the modal shows the real logos — including AudioDB's // inline base64 — and stays in sync if the dashboard logos ever change. // imgFilter / imgRound mirror the per-logo CSS the dashboard bubbles apply, so // black-on-dark icons (Discogs/Tidal/Qobuz/Amazon) get inverted to white and // square logos (Last.fm) clip to a circle here too. const ENRICHMENT_WORKERS = [ { id: 'spotify', name: 'Spotify', color: '#1db954', logoSel: '.spotify-enrich-logo' }, { id: 'itunes', name: 'iTunes', color: '#fb5bc5', logoSel: '.itunes-enrich-logo' }, { id: 'musicbrainz', name: 'MusicBrainz', color: '#ba55d3', logoSel: '.mb-logo' }, { id: 'deezer', name: 'Deezer', color: '#a238ff', logoSel: '.deezer-logo' }, { id: 'audiodb', name: 'AudioDB', color: '#1c8cf0', logoSel: '.audiodb-logo' }, { id: 'discogs', name: 'Discogs', color: '#cfcfcf', logoSel: '.discogs-logo', imgFilter: 'brightness(0) invert(1)' }, { id: 'lastfm', name: 'Last.fm', color: '#d51007', logoSel: '.lastfm-enrich-logo', imgRound: true }, { id: 'genius', name: 'Genius', color: '#ffe600', logoSel: '.genius-enrich-logo' }, { id: 'tidal', name: 'Tidal', color: '#00cfe6', logoSel: '.tidal-enrich-logo', imgFilter: 'invert(1) brightness(1.8)', imgRound: true }, { id: 'qobuz', name: 'Qobuz', color: '#0070ef', logoSel: '.qobuz-enrich-logo', imgFilter: 'invert(1)', imgRound: true }, { id: 'amazon', name: 'Amazon Music', color: '#ff9900', logoSel: '.amazon-enrich-logo', imgFilter: 'brightness(0) invert(1)' }, ]; const _emWorkerById = Object.fromEntries(ENRICHMENT_WORKERS.map(w => [w.id, w])); // '#1db954' -> '29,185,84' for rgba(var(--em-accent-rgb), a) usage. function _emHexToRgb(hex) { const h = String(hex || '').replace('#', ''); const full = h.length === 3 ? h.split('').map(c => c + c).join('') : h; const n = parseInt(full, 16); if (isNaN(n) || full.length !== 6) return '120,120,120'; return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`; } // Resolve a worker's logo URL from the live dashboard bubble (null if absent). function _emLogoSrc(workerId) { const w = _emWorkerById[workerId]; if (!w || !w.logoSel) return null; const img = document.querySelector(w.logoSel); return img && img.src ? img.src : null; } // A circular, glowing icon chip mirroring the dashboard bubbles. Falls back to // a colored initial if the logo is missing or fails to load. function _emIconHtml(workerId, size) { const w = _emWorkerById[workerId]; const src = _emLogoSrc(workerId); const cls = `em-icon${size === 'lg' ? ' em-icon--lg' : ''}`; const initial = w.name.charAt(0).toUpperCase(); const imgStyle = [ w.imgFilter ? `filter:${w.imgFilter}` : '', w.imgRound ? 'border-radius:50%' : '', ].filter(Boolean).join(';'); const inner = src ? `` : `${initial}`; return `${inner}`; } const enrichmentManagerState = { open: false, selected: null, statuses: {}, // id -> last /status payload breakdown: null, // selected worker's breakdown entityTab: 'artist', statusFilter: 'unmatched', search: '', page: 0, pageSize: 25, unmatched: null, // { total, items } pollTimer: null, loadToken: 0, // guards against out-of-order async renders }; function _emEntityLabel(entity, plural) { const map = { artist: 'Artist', album: 'Album', track: 'Track' }; const base = map[entity] || entity; return plural ? base + 's' : base; } function _emEscape(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] )); } // Human "3 days ago" for a SQLite timestamp; '' when never attempted. function _emRelativeTime(value) { if (!value) return ''; // SQLite stores 'YYYY-MM-DD HH:MM:SS' (UTC) — normalize to ISO. const ts = Date.parse(String(value).replace(' ', 'T') + (String(value).includes('Z') ? '' : 'Z')); if (isNaN(ts)) return ''; const secs = Math.max(0, (Date.now() - ts) / 1000); if (secs < 60) return 'just now'; const mins = secs / 60; if (mins < 60) return `${Math.floor(mins)}m ago`; const hrs = mins / 60; if (hrs < 24) return `${Math.floor(hrs)}h ago`; const days = hrs / 24; if (days < 30) return `${Math.floor(days)}d ago`; const months = days / 30; if (months < 12) return `${Math.floor(months)}mo ago`; return `${Math.floor(months / 12)}y ago`; } // ── Open / close ────────────────────────────────────────────────────────── async function openEnrichmentManager() { let overlay = document.getElementById('enrichment-manager-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'enrichment-manager-overlay'; overlay.className = 'modal-overlay em-overlay hidden'; overlay.onclick = (e) => { if (e.target === overlay) closeEnrichmentManager(); }; overlay.innerHTML = ` `; document.body.appendChild(overlay); } overlay.classList.remove('hidden', 'em-closing'); // Re-trigger the entrance animation even when reusing the element. const modal = overlay.querySelector('.enrichment-manager-modal'); if (modal) { modal.classList.remove('em-in'); void modal.offsetWidth; modal.classList.add('em-in'); } document.body.classList.add('em-scroll-lock'); document.addEventListener('keydown', _emOnKeydown); enrichmentManagerState.open = true; await refreshAllEnrichmentStatuses(); renderEnrichmentRail(); // Default selection: first running worker, else first in the list. const running = ENRICHMENT_WORKERS.find( w => enrichmentManagerState.statuses[w.id]?.running ); selectEnrichmentWorker((running || ENRICHMENT_WORKERS[0]).id); if (modal) setTimeout(() => modal.focus(), 60); if (enrichmentManagerState.pollTimer) clearInterval(enrichmentManagerState.pollTimer); enrichmentManagerState.pollTimer = setInterval(_emPollSelected, 3000); } function closeEnrichmentManager() { const overlay = document.getElementById('enrichment-manager-overlay'); enrichmentManagerState.open = false; document.removeEventListener('keydown', _emOnKeydown); document.body.classList.remove('em-scroll-lock'); if (enrichmentManagerState.pollTimer) { clearInterval(enrichmentManagerState.pollTimer); enrichmentManagerState.pollTimer = null; } if (!overlay) return; // Brief fade/scale-out, then hide. overlay.classList.add('em-closing'); setTimeout(() => { overlay.classList.add('hidden'); overlay.classList.remove('em-closing'); }, 170); } // Escape closes the nested match overlay first (if open), else the manager. function _emOnKeydown(e) { if (e.key !== 'Escape') return; const match = document.getElementById('enrichment-match-overlay'); if (match) { match.remove(); return; } closeEnrichmentManager(); } // Manual refresh: re-pull every worker's status + the selected worker's data. async function refreshEnrichmentManager(btn) { if (btn) btn.classList.add('em-spinning'); await refreshAllEnrichmentStatuses(); renderEnrichmentRail(); const sel = enrichmentManagerState.selected; if (sel) await Promise.all([_emLoadBreakdown(sel), _emLoadUnmatched()]); _emRenderStats(); _emRenderUnmatchedList(); _emRenderPanelHeader(); if (btn) setTimeout(() => btn.classList.remove('em-spinning'), 400); } // ── Status loading ────────────────────────────────────────────────────────── async function refreshAllEnrichmentStatuses() { const results = await Promise.all(ENRICHMENT_WORKERS.map(async (w) => { try { const res = await fetch(`/api/enrichment/${w.id}/status`); return [w.id, res.ok ? await res.json() : null]; } catch (_e) { return [w.id, null]; } })); for (const [id, status] of results) enrichmentManagerState.statuses[id] = status; } async function _emPollSelected() { const id = enrichmentManagerState.selected; if (!id || !enrichmentManagerState.open) return; try { const res = await fetch(`/api/enrichment/${id}/status`); if (res.ok) { enrichmentManagerState.statuses[id] = await res.json(); _emUpdateHeaderLive(); // in-place — no logo reflow/flicker _emUpdateRailRow(id); } } catch (_e) { /* transient — keep last */ } } function _emStatusInfo(status) { if (!status || !status.enabled) return { cls: 'disabled', label: 'Disabled' }; if (status.rate_limited) return { cls: 'ratelimited', label: 'Rate-limited' }; if (status.paused) return { cls: 'paused', label: 'Paused' }; if (status.idle) return { cls: 'idle', label: 'Idle' }; if (status.running) return { cls: 'running', label: 'Running' }; return { cls: 'stopped', label: 'Stopped' }; } // ── Left rail ─────────────────────────────────────────────────────────────── // Overall library coverage (% of items this source has attempted) from the // status payload's progress block — a cheap at-a-glance rail signal. function _emOverallPct(status) { const p = status && status.progress; if (!p) return null; let matched = 0, total = 0; for (const k of ['artists', 'albums', 'tracks']) { if (p[k]) { matched += p[k].matched || 0; total += p[k].total || 0; } } return total ? Math.round((matched / total) * 100) : 0; } function renderEnrichmentRail() { const rail = document.getElementById('em-rail'); if (!rail) return; rail.innerHTML = ENRICHMENT_WORKERS.map(w => { const status = enrichmentManagerState.statuses[w.id]; const info = _emStatusInfo(status); const pct = _emOverallPct(status); const cov = pct == null ? '' : ` `; return ` `; }).join(''); _emHighlightRail(); } function _emHighlightRail() { ENRICHMENT_WORKERS.forEach(w => { const row = document.getElementById(`em-row-${w.id}`); if (row) row.classList.toggle('active', w.id === enrichmentManagerState.selected); }); } function _emUpdateRailRow(id) { const row = document.getElementById(`em-row-${id}`); if (!row) return; const status = enrichmentManagerState.statuses[id]; const info = _emStatusInfo(status); const pct = _emOverallPct(status); const dot = row.querySelector('.em-dot'); if (dot) { dot.className = `em-dot em-dot--${info.cls}`; dot.title = info.label; } const sub = row.querySelector('.em-worker-sub'); if (sub) sub.textContent = `${info.label}${pct == null ? '' : ` · ${pct}%`}`; const cov = row.querySelector('.em-rail-cov-fill'); if (cov && pct != null) cov.style.width = `${pct}%`; } // ── Worker selection ────────────────────────────────────────────────────────── async function selectEnrichmentWorker(id) { enrichmentManagerState.selected = id; enrichmentManagerState.breakdown = null; enrichmentManagerState.unmatched = null; enrichmentManagerState.search = ''; enrichmentManagerState.page = 0; enrichmentManagerState.statusFilter = 'unmatched'; _emHighlightRail(); // Pick a default entity tab the worker actually supports (filled after the // unmatched call returns entity_types; default to artist meanwhile). enrichmentManagerState.entityTab = 'artist'; renderEnrichmentPanel(); await Promise.all([_emLoadBreakdown(id), _emLoadUnmatched()]); renderEnrichmentPanel(); } async function _emLoadBreakdown(id) { try { const res = await fetch(`/api/enrichment/${id}/breakdown`); enrichmentManagerState.breakdown = res.ok ? (await res.json()).breakdown : null; } catch (_e) { enrichmentManagerState.breakdown = null; } } async function _emLoadUnmatched() { const id = enrichmentManagerState.selected; const token = ++enrichmentManagerState.loadToken; const { entityTab, statusFilter, search, page, pageSize } = enrichmentManagerState; const params = new URLSearchParams({ entity_type: entityTab, status: statusFilter, limit: String(pageSize), offset: String(page * pageSize), }); if (search) params.set('q', search); try { const res = await fetch(`/api/enrichment/${id}/unmatched?${params}`); const data = res.ok ? await res.json() : { total: 0, items: [] }; if (token !== enrichmentManagerState.loadToken) return; // stale enrichmentManagerState.unmatched = data; } catch (_e) { if (token === enrichmentManagerState.loadToken) { enrichmentManagerState.unmatched = { total: 0, items: [] }; } } } // ── Detail panel ────────────────────────────────────────────────────────────── function renderEnrichmentPanel() { const panel = document.getElementById('em-panel'); if (!panel) return; const id = enrichmentManagerState.selected; const worker = _emWorkerById[id]; if (!worker) { panel.innerHTML = ''; return; } // Theme the whole panel to the selected worker's accent colour. panel.style.setProperty('--em-accent', worker.color); panel.style.setProperty('--em-accent-rgb', _emHexToRgb(worker.color)); panel.innerHTML = `
Enrichment coverage
`; _emRenderPanelHeader(); _emRenderStats(); _emRenderUnmatchedControls(); _emRenderUnmatchedList(); } function _emRenderPanelHeader() { const host = document.getElementById('em-panel-header'); if (!host) return; const id = enrichmentManagerState.selected; const worker = _emWorkerById[id]; // Structure is rendered once per worker selection; the live bits below // (pill / current-item / errors / toggle) are updated in place by // _emUpdateHeaderLive on each poll so the logo never reflows or flickers. host.innerHTML = `
${_emIconHtml(id, 'lg')}
${_emEscape(worker.name)} enrichment
`; _emUpdateHeaderLive(); } function _emUpdateHeaderLive() { const id = enrichmentManagerState.selected; const status = enrichmentManagerState.statuses[id]; const info = _emStatusInfo(status); const pill = document.getElementById('em-ph-pill'); if (pill) { pill.className = `em-pill em-pill--${info.cls}`; pill.textContent = info.label; } const metric = document.getElementById('em-ph-metric'); if (metric) { const pct = _emOverallPct(status); metric.innerHTML = pct == null ? '' : `${pct}% enriched`; } const cur = document.getElementById('em-ph-current'); if (cur) { const item = status && status.current_item; cur.innerHTML = item ? `Now enriching: ${_emEscape(item.name || '')}${item.type ? ` (${_emEscape(item.type)})` : ''}` : 'No item processing'; } const budgetEl = document.getElementById('em-ph-budget'); if (budgetEl) { const b = status && status.daily_budget; budgetEl.innerHTML = (b && b.limit) ? `Budget ${b.used ?? '?'} / ${b.limit}` : ''; } const errEl = document.getElementById('em-ph-errors'); if (errEl) { const errors = (status && status.stats && status.stats.errors) || 0; errEl.innerHTML = errors ? `⚠ ${errors}` : ''; } const toggle = document.getElementById('em-ph-toggle'); if (toggle) { const isPaused = status && status.paused; toggle.disabled = !(status && status.enabled); toggle.classList.toggle('em-btn--go', !!isPaused); toggle.textContent = isPaused ? '▶ Resume' : '⏸ Pause'; } } function _emRenderStats() { const host = document.getElementById('em-stats'); if (!host) return; const bd = enrichmentManagerState.breakdown; if (!bd) { // Skeleton cards (count unknown yet — 3 covers the common case). host.innerHTML = Array.from({ length: 3 }, () => `
`).join(''); return; } const glyphs = { artist: '🎤', album: '💿', track: '🎵' }; host.innerHTML = Object.keys(bd).map(entity => { const d = bd[entity] || {}; const total = d.total || 0; const matched = d.matched || 0; const notFound = d.not_found || 0; const pending = d.pending || 0; const pct = total ? Math.round((matched / total) * 100) : 0; const seg = (n) => (total ? (n / total) * 100 : 0); return `
${glyphs[entity] || '•'}${_emEntityLabel(entity, true)} ${pct}%
${matched.toLocaleString()} matched ${notFound.toLocaleString()} missed ${pending.toLocaleString()} pending
`; }).join(''); // Animate the segments in from 0 on the next frame (CSS transition does the rest). requestAnimationFrame(() => { host.querySelectorAll('.em-seg-fill').forEach(el => { el.style.width = `${el.dataset.pct || 0}%`; }); }); } function _emRenderUnmatchedControls() { const host = document.getElementById('em-unmatched-controls'); if (!host) return; const data = enrichmentManagerState.unmatched; const supported = (data && data.entity_types) || ['artist']; const total = data ? (data.total || 0) : null; const tabs = supported.map(e => ` `).join(''); host.innerHTML = `
${tabs}
`; } function _emRenderUnmatchedList() { const host = document.getElementById('em-unmatched-list'); if (!host) return; const data = enrichmentManagerState.unmatched; if (!data) { host.innerHTML = Array.from({ length: 6 }, () => `
`).join(''); return; } // Keep the count badge in sync without re-rendering the controls (would // steal focus from the search box mid-type). const countEl = document.querySelector('#em-unmatched-controls .em-count'); if (countEl) countEl.textContent = (data.total || 0).toLocaleString(); if (!data.items.length) { const allMatched = enrichmentManagerState.statusFilter === 'unmatched'; host.innerHTML = `
${allMatched ? '🎉' : '🔍'}
${allMatched ? 'Every item is matched for this source.' : 'Nothing matches this filter.'}
`; } else { const id = enrichmentManagerState.selected; const entity = enrichmentManagerState.entityTab; host.innerHTML = data.items.map(item => { const img = item.image_url ? `` : '
'; const rel = _emRelativeTime(item.last_attempted); const last = rel ? `tried ${rel}` : 'never tried'; const statusBadge = item.status === 'not_found' ? 'not found' : 'pending'; const safeName = _emEscape(item.name || 'Unknown'); return `
${img}
${safeName}
${statusBadge} ${last}
`; }).join(''); } _emRenderPager(); } function _emRenderPager() { const host = document.getElementById('em-pager'); if (!host) return; const data = enrichmentManagerState.unmatched; if (!data) { host.innerHTML = ''; return; } const { page, pageSize } = enrichmentManagerState; const total = data.total || 0; const from = total ? page * pageSize + 1 : 0; const to = Math.min((page + 1) * pageSize, total); const hasPrev = page > 0; const hasNext = to < total; host.innerHTML = ` ${from}–${to} of ${total.toLocaleString()} `; } // ── Controls ────────────────────────────────────────────────────────────────── async function setEnrichmentEntityTab(entity) { enrichmentManagerState.entityTab = entity; enrichmentManagerState.page = 0; _emRenderUnmatchedControls(); document.getElementById('em-unmatched-list').innerHTML = '
'; await _emLoadUnmatched(); _emRenderUnmatchedList(); } async function setEnrichmentStatusFilter(value) { enrichmentManagerState.statusFilter = value; enrichmentManagerState.page = 0; await _emLoadUnmatched(); _emRenderUnmatchedList(); } let _emSearchDebounce = null; function onEnrichmentSearchInput(value) { enrichmentManagerState.search = value; enrichmentManagerState.page = 0; if (_emSearchDebounce) clearTimeout(_emSearchDebounce); _emSearchDebounce = setTimeout(async () => { await _emLoadUnmatched(); _emRenderUnmatchedList(); }, 300); } async function changeEnrichmentPage(delta) { enrichmentManagerState.page = Math.max(0, enrichmentManagerState.page + delta); await _emLoadUnmatched(); _emRenderUnmatchedList(); } async function toggleEnrichmentWorker(id) { const status = enrichmentManagerState.statuses[id]; const action = status?.paused ? 'resume' : 'pause'; try { const res = await fetch(`/api/enrichment/${id}/${action}`, { method: 'POST' }); const data = await res.json().catch(() => ({})); if (!res.ok) { showToast(data.error || `Could not ${action} worker`, 'error'); return; } showToast(`${_emWorkerById[id].name} ${action === 'pause' ? 'paused' : 'resumed'}`, 'success'); await _emPollSelected(); } catch (_e) { showToast(`Error trying to ${action} worker`, 'error'); } } async function retryEnrichmentItem(service, entityType, entityId, btn) { if (btn) { btn.disabled = true; btn.textContent = '…'; } try { const res = await fetch('/api/library/clear-match', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entity_type: entityType, entity_id: entityId, service }), }); const data = await res.json().catch(() => ({})); if (data.success) { showToast('Re-queued for enrichment', 'success'); await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]); _emRenderStats(); _emRenderUnmatchedList(); } else { showToast(data.error || 'Failed to re-queue', 'error'); if (btn) { btn.disabled = false; btn.textContent = 'Retry'; } } } catch (_e) { showToast('Error re-queuing item', 'error'); if (btn) { btn.disabled = false; btn.textContent = 'Retry'; } } } // ── Inline manual match (decoupled from the library artist-detail page) ─────── function openEnrichmentMatch(service, entityType, entityId, anchorBtn) { const defaultQuery = anchorBtn ? (anchorBtn.closest('.em-row')?.querySelector('.em-row-name')?.textContent || '') : ''; const existing = document.getElementById('enrichment-match-overlay'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'enrichment-match-overlay'; overlay.className = 'modal-overlay'; overlay.style.zIndex = '10010'; // above the manager modal overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; overlay.innerHTML = `

Match ${_emEntityLabel(entityType)} on ${_emEscape(_emWorkerById[service]?.name || service)}

Search to find a match.
`; document.body.appendChild(overlay); const input = overlay.querySelector('.enhanced-match-search-input'); const results = overlay.querySelector('#enrichment-match-results'); overlay.querySelector('.enhanced-bulk-modal-close').onclick = () => overlay.remove(); const run = () => _emRunMatchSearch(service, entityType, entityId, input.value, results, overlay); overlay.querySelector('.em-match-go').onclick = run; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); }); if (defaultQuery.trim()) run(); setTimeout(() => input.focus(), 50); } async function _emRunMatchSearch(service, entityType, entityId, query, container, overlay) { if (!query.trim()) { container.innerHTML = '
Enter a search term
'; return; } container.innerHTML = '
'; try { const res = await fetch('/api/library/search-service', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service, entity_type: entityType, query: query.trim() }), }); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Search failed'); const list = data.results || []; if (!list.length) { container.innerHTML = '
No results. Try a different search.
'; return; } container.innerHTML = ''; list.forEach(r => { const row = document.createElement('div'); row.className = 'enhanced-match-result-row'; const imgHtml = r.image ? `` : '
🎵
'; const providerLabel = r.provider && r.provider !== service ? ` (${_emEscape(r.provider)})` : ''; row.innerHTML = ` ${imgHtml}
${_emEscape(r.name || 'Unknown')}
${r.extra ? `
${_emEscape(r.extra)}
` : ''}
ID: ${_emEscape(r.id)}${providerLabel}
`; const btn = document.createElement('button'); btn.className = 'enhanced-meta-save-btn'; btn.textContent = 'Match'; btn.onclick = () => _emApplyMatch(entityType, entityId, r.provider || service, r.id, overlay); row.appendChild(btn); container.appendChild(row); }); } catch (e) { container.innerHTML = `
Search error: ${_emEscape(e.message)}
`; } } async function _emApplyMatch(entityType, entityId, service, serviceId, overlay) { try { const res = await fetch('/api/library/manual-match', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entity_type: entityType, entity_id: entityId, service, service_id: serviceId }), }); const data = await res.json(); if (data.success) { showToast('Matched ✓', 'success'); if (overlay) overlay.remove(); // Refresh the manager's stats + list for the *selected* worker. const sel = enrichmentManagerState.selected; await Promise.all([_emLoadBreakdown(sel), _emLoadUnmatched()]); _emRenderStats(); _emRenderUnmatchedList(); } else { showToast(data.error || 'Failed to match', 'error'); } } catch (_e) { showToast('Error applying match', 'error'); } } // Expose for inline onclick handlers. window.openEnrichmentManager = openEnrichmentManager; window.closeEnrichmentManager = closeEnrichmentManager; window.refreshEnrichmentManager = refreshEnrichmentManager; window.selectEnrichmentWorker = selectEnrichmentWorker; window.setEnrichmentEntityTab = setEnrichmentEntityTab; window.setEnrichmentStatusFilter = setEnrichmentStatusFilter; window.onEnrichmentSearchInput = onEnrichmentSearchInput; window.changeEnrichmentPage = changeEnrichmentPage; window.toggleEnrichmentWorker = toggleEnrichmentWorker; window.retryEnrichmentItem = retryEnrichmentItem; window.openEnrichmentMatch = openEnrichmentMatch;