// ── Scan history browser ────────────────────────────────────────────────────── // Lets the user load and browse results from any past scan session without // running a new scan. Sessions are groups of concurrent M365 + Google + File // scans (same 300-second window used by get_session_items on the server). import { S } from './state.js'; const _SRC_LABELS = { email: 'Outlook', onedrive: 'OneDrive', sharepoint: 'SharePoint', teams: 'Teams', gmail: 'Gmail', gdrive: 'Google Drive', local: 'Lokal', smb: 'SMB', }; let _sessions = null; // cached list; null = stale let _latestRefScanId = null; // ref_scan_id of the newest session // ── Session cache ───────────────────────────────────────────────────────────── async function _fetchSessions() { try { const r = await fetch('/api/db/sessions'); _sessions = await r.json(); } catch(e) { _sessions = []; } _latestRefScanId = _sessions.length ? _sessions[0].ref_scan_id : null; return _sessions; } function invalidateHistoryCache() { _sessions = null; _latestRefScanId = null; } // ── Load a session into the results grid ────────────────────────────────────── async function loadHistorySession(refScanId) { // refScanId: null → latest session, positive int → specific session let resolvedRef = refScanId; if (resolvedRef === null) { const sessions = _sessions !== null ? _sessions : await _fetchSessions(); // Bail if a scan started while we were fetching sessions if (S._m365ScanRunning || S._googleScanRunning || S._fileScanRunning) return; if (!sessions.length) { // No scans in DB — nothing to show window.loadLastScanSummary?.(); return; } resolvedRef = sessions[0].ref_scan_id; } try { const r = await fetch('/api/db/flagged?ref=' + resolvedRef); const items = await r.json(); // Bail if a scan started while we were fetching flagged items if (S._m365ScanRunning || S._googleScanRunning || S._fileScanRunning) return; closeHistoryPicker(); if (!Array.isArray(items) || items.length === 0) { S._historyRefScanId = null; _setHistoryBanner(false); window.loadLastScanSummary?.(); return; } S._historyRefScanId = resolvedRef; S.flaggedData = items; S.filteredData = []; const grid = document.getElementById('grid'); const emptyState = document.getElementById('emptyState'); const lastScan = document.getElementById('lastScanSummary'); if (emptyState) emptyState.style.display = 'none'; if (lastScan) lastScan.style.display = 'none'; if (grid) { grid.innerHTML = ''; grid.style.display = 'grid'; } window.renderGrid(items); try { window.markOverdueCards(); } catch(_) {} try { window.loadTrend(); } catch(_) {} _setHistoryBanner(true, resolvedRef); // ── Re-scan diff: append items from previous session no longer present ──── const allSessions = _sessions !== null ? _sessions : await _fetchSessions(); const idx = allSessions.findIndex(s => s.ref_scan_id === resolvedRef); if (idx !== -1 && idx + 1 < allSessions.length) { const prevRef = allSessions[idx + 1].ref_scan_id; try { const pr = await fetch('/api/db/flagged?ref=' + prevRef); const prevItems = await pr.json(); if (Array.isArray(prevItems) && prevItems.length) { const currentIds = new Set(items.map(f => f.id)); const resolved = prevItems.filter(f => !currentIds.has(f.id)); if (resolved.length) { const divider = document.createElement('div'); divider.className = 'resolved-divider'; divider.textContent = resolved.length + ' ' + t('history_resolved_label', 'items no longer present'); document.getElementById('grid')?.appendChild(divider); resolved.forEach(f => { f._resolved = true; window.appendCard(f); }); _setHistoryBanner(true, resolvedRef, resolved.length); } } } catch(e) { console.warn('[history] diff failed:', e); } } } catch(e) { console.error('[history] failed to load session:', e); } } // ── Banner ──────────────────────────────────────────────────────────────────── function _setHistoryBanner(visible, resolvedRef, resolvedCount) { const banner = document.getElementById('historyBanner'); const bannerTxt = document.getElementById('historyBannerText'); const latestBtn = document.getElementById('historyLatestBtn'); if (!banner) return; if (!visible) { banner.style.display = 'none'; return; } const sess = (_sessions || []).find(s => s.ref_scan_id === resolvedRef); let label = ''; if (sess) { const date = new Date(sess.started_at * 1000).toLocaleDateString(undefined, {day: 'numeric', month: 'short', year: 'numeric'}); const time = new Date(sess.started_at * 1000).toLocaleTimeString(undefined, {hour: '2-digit', minute: '2-digit'}); const srcStr = (sess.sources || []).map(s => _SRC_LABELS[s] || s).join(' · '); label = date + ' ' + time + (srcStr ? ' · ' + srcStr : '') + ' · ' + sess.flagged_count + ' ' + t('history_items', 'items'); if (resolvedCount) label += ' · ' + resolvedCount + ' ' + t('history_resolved_badge', 'resolved'); } else { label = S.flaggedData.length + ' ' + t('history_items', 'items'); } if (bannerTxt) bannerTxt.textContent = label; if (latestBtn) latestBtn.style.display = (resolvedRef !== _latestRefScanId) ? '' : 'none'; banner.style.display = 'flex'; } function exitHistoryMode() { S._historyRefScanId = null; const banner = document.getElementById('historyBanner'); if (banner) banner.style.display = 'none'; closeHistoryPicker(); } // ── Session picker dropdown ─────────────────────────────────────────────────── async function openHistoryPicker() { const drop = document.getElementById('historyDropdown'); if (!drop) return; // Toggle if (drop.style.display !== 'none') { drop.style.display = 'none'; return; } drop.innerHTML = '
' + t('lbl_loading', 'Loading\u2026') + '
'; drop.style.display = ''; const sessions = _sessions !== null ? _sessions : await _fetchSessions(); if (!sessions.length) { drop.innerHTML = '
' + t('history_picker_empty', 'No past scans') + '
'; return; } drop.innerHTML = ''; sessions.forEach((sess, i) => { const date = new Date(sess.started_at * 1000).toLocaleDateString(undefined, {day: 'numeric', month: 'short', year: 'numeric'}); const time = new Date(sess.started_at * 1000).toLocaleTimeString(undefined, {hour: '2-digit', minute: '2-digit'}); const srcStr = (sess.sources || []).map(s => _SRC_LABELS[s] || s).join(' · '); const isActive = sess.ref_scan_id === S._historyRefScanId; const row = document.createElement('div'); row.style.cssText = 'padding:8px 12px;cursor:pointer' + (i < sessions.length - 1 ? ';border-bottom:1px solid var(--border)' : '') + (isActive ? ';background:var(--bg)' : ''); row.innerHTML = '
' + '' + date + '' + '' + time + '' + (sess.delta ? '' + t('history_delta_badge', 'Delta') + '' : '') + (i === 0 ? '' + t('history_latest_badge', 'Latest') + '' : '') + '
' + '
' + srcStr + '  \u00b7  ' + sess.flagged_count + ' ' + t('history_items', 'items') + '
'; row.addEventListener('mouseenter', () => { if (!isActive) row.style.background = 'var(--surface)'; }); row.addEventListener('mouseleave', () => { row.style.background = isActive ? 'var(--bg)' : ''; }); row.addEventListener('click', () => loadHistorySession(sess.ref_scan_id)); drop.appendChild(row); }); } function closeHistoryPicker() { const drop = document.getElementById('historyDropdown'); if (drop) drop.style.display = 'none'; } // Close picker when clicking outside its container document.addEventListener('click', e => { const wrap = document.getElementById('historyPickerBtn')?.closest('[data-history-wrap]'); if (wrap && !wrap.contains(e.target)) closeHistoryPicker(); }, true); // ── Window exports ──────────────────────────────────────────────────────────── window.loadHistorySession = loadHistorySession; window.openHistoryPicker = openHistoryPicker; window.closeHistoryPicker = closeHistoryPicker; window.exitHistoryMode = exitHistoryMode; window.invalidateHistoryCache = invalidateHistoryCache;