802 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { S } from './state.js';
// ── DB Export / Import (#11) ──────────────────────────────────────────────────
async function exportDB() {
// In pywebview app, use native save dialog; in browser, use blob download
if (window.pywebview && window.pywebview.api && window.pywebview.api.save_db_export) {
try {
const r = await window.pywebview.api.save_db_export();
if (r && r.ok) { log(t('m365_db_exported','Database exported') + ': ' + r.path); }
else if (r && r.error && r.error !== 'cancelled') { alert(t('m365_db_export_error','Export failed') + ': ' + r.error); }
} catch(e) { alert(t('m365_db_export_error','Export failed') + ': ' + e.message); }
return;
}
// Browser fallback
try {
const res = await fetch('/api/db/export');
if (!res.ok) {
const d = await res.json().catch(() => ({}));
alert(t('m365_db_export_error','Export failed') + ': ' + (d.error || res.statusText));
return;
}
const blob = await res.blob();
const cd = res.headers.get('Content-Disposition') || '';
const m = cd.match(/filename="([^"]+)"/);
const name = m ? m[1] : 'gdpr_export.zip';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name; a.click();
URL.revokeObjectURL(url);
log(t('m365_db_exported','Database exported') + ': ' + name);
} catch(e) {
alert(t('m365_db_export_error','Export failed') + ': ' + e.message);
}
}
function openImportDBModal() {
const fi = document.getElementById('importDbFile');
if (fi) fi.value = '';
const mode = document.getElementById('importDbMode');
if (mode) mode.value = 'merge';
document.getElementById('importDbReplaceWarn').style.display = 'none';
document.getElementById('importDbStatus').textContent = '';
document.getElementById('importDbBackdrop').classList.add('open');
}
function closeImportDBModal() {
document.getElementById('importDbBackdrop').classList.remove('open');
}
// Show/hide the replace warning when mode changes
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('importDbMode')?.addEventListener('change', function() {
document.getElementById('importDbReplaceWarn').style.display =
this.value === 'replace' ? 'block' : 'none';
});
});
async function doImportDB() {
const fi = document.getElementById('importDbFile');
const mode = document.getElementById('importDbMode')?.value || 'merge';
const stat = document.getElementById('importDbStatus');
const btn = document.getElementById('importDbBtn');
if (!fi?.files?.length) {
stat.textContent = t('m365_db_import_no_file','Please select a ZIP file first.');
stat.style.color = 'var(--danger)';
return;
}
if (mode === 'replace') {
if (!confirm(t('m365_db_import_replace_confirm',
'Replace mode will erase ALL existing scan data and restore from the archive.\n\nMake sure you have a manual backup of ~/.gdpr_scanner.db.\n\nProceed?'))) return;
}
btn.disabled = true;
stat.style.color = 'var(--muted)';
stat.textContent = t('m365_db_importing','Importing…');
const fd = new FormData();
fd.append('file', fi.files[0]);
fd.append('mode', mode);
if (mode === 'replace') fd.append('confirm', 'yes');
try {
const r = await fetch('/api/db/import', { method: 'POST', body: fd });
const d = await r.json();
if (!r.ok || d.error) {
stat.style.color = 'var(--danger)';
stat.textContent = '✖ ' + (d.error || r.statusText);
} else {
const counts = Object.entries(d.imported || {}).map(([k,v]) => `${k}: ${v}`).join(', ');
stat.style.color = 'var(--accent)';
stat.textContent = '✔ ' + t('m365_db_imported','Imported') + (counts ? ' (' + counts + ')' : '');
log(t('m365_db_imported','Imported') + ' [' + mode + '] ' + fi.files[0].name);
}
} catch(e) {
stat.style.color = 'var(--danger)';
stat.textContent = '✖ ' + e.message;
} finally {
btn.disabled = false;
}
}
// ── Scan ─────────────────────────────────────────────────────────────────────
function buildScanPayload() {
// Collect checked M365 sources from dynamic panel
const sources = [];
document.querySelectorAll('#sourcesPanel input[data-source-type="m365"]:checked').forEach(function(cb) {
sources.push(cb.dataset.sourceId);
});
// Collect checked file sources (local/smb) — handled separately in startScan()
// but included here so profiles and checkpoint checks are aware of them
const fileSources = [];
document.querySelectorAll('#sourcesPanel input[data-source-type="file"]:checked').forEach(function(cb) {
fileSources.push(cb.dataset.sourceId);
});
// Collect checked Google sources
const googleSources = [];
document.querySelectorAll('#sourcesPanel input[data-source-type="google"]:checked').forEach(function(cb) {
googleSources.push(cb.dataset.sourceId);
});
const user_ids = getSelectedUsers();
// Merge all source types into a single array for profiles
const allSources = sources.concat(fileSources).concat(googleSources);
const options = {
older_than_days: parseInt(document.getElementById('olderThan').value) || 0,
email_body: document.getElementById('optEmailBody').checked,
attachments: document.getElementById('optAttachments').checked,
max_attach_mb: parseInt(document.getElementById('optMaxAttachMB').value) || 20,
max_emails: parseInt(document.getElementById('optMaxEmails').value) || 200,
delta: document.getElementById('optDelta') ? document.getElementById('optDelta').checked : false,
scan_photos: document.getElementById('optScanPhotos') ? document.getElementById('optScanPhotos').checked : false,
skip_gps_images: document.getElementById('optSkipGps') ? document.getElementById('optSkipGps').checked : false,
min_cpr_count: document.getElementById('optMinCpr') ? (parseInt(document.getElementById('optMinCpr').value) || 1) : 1,
ocr_lang: document.getElementById('optOcrLang')?.value || 'dan+eng',
cpr_only: document.getElementById('optCprOnly') ? document.getElementById('optCprOnly').checked : false,
scan_emails: document.getElementById('optScanEmails') ? document.getElementById('optScanEmails').checked : false,
scan_phones: document.getElementById('optScanPhones') ? document.getElementById('optScanPhones').checked : false,
retention_enabled: document.getElementById('optRetention') ? document.getElementById('optRetention').checked : false,
retention_years: parseInt(document.getElementById('optRetentionYears')?.value) || 5,
fiscal_year_end: document.getElementById('optFiscalYearEnd')?.value || '',
};
return { sources, fileSources, allSources, googleSources, user_ids, options };
}
async function checkCheckpoint(onNoCheckpoint) {
const payload = buildScanPayload();
const banner = document.getElementById('resumeBanner');
const hasSources = payload.sources.length > 0 || payload.fileSources.length > 0 || payload.googleSources.length > 0;
if (!hasSources) {
if (banner) banner.style.display = 'none';
onNoCheckpoint?.(); return;
}
// M365 sources without users — scan button will handle the alert
if (payload.sources.length && !payload.user_ids.length && !payload.googleSources.length) {
if (banner) banner.style.display = 'none';
onNoCheckpoint?.(); return;
}
// Collect Google user emails for server-side checkpoint key computation
const googleUserEmails = payload.googleSources.length > 0
? (S._allUsers || []).filter(u => u.selected !== false && (u.platform === 'google' || u.platform === 'both')).map(u => u.email || u.id).filter(Boolean)
: [];
try {
const r = await fetch('/api/scan/checkpoint', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({...payload, googleUserEmails})
});
const d = await r.json();
if (d.exists) {
const ts = d.started_at ? new Date(d.started_at * 1000).toLocaleString([], {dateStyle:'short', timeStyle:'short'}) : '';
document.getElementById('resumeBannerText').textContent =
t('m365_resume_banner', `Previous scan interrupted (${d.scanned_count} scanned, ${d.flagged_count} found${ts ? ' — ' + ts : ''})`);
if (banner) banner.style.display = 'flex';
} else {
if (banner) banner.style.display = 'none';
onNoCheckpoint?.();
}
} catch(e) { onNoCheckpoint?.(); }
}
async function clearCheckpointAndScan() {
await fetch('/api/scan/clear_checkpoint', {method:'POST'});
document.getElementById('resumeBanner').style.display = 'none';
startScan(false);
}
async function checkDeltaStatus() {
const cb = document.getElementById('optDelta');
if (!cb) return;
try {
const r = await fetch('/api/delta/status');
const d = await r.json();
const row = document.getElementById('deltaStatusRow');
const txt = document.getElementById('deltaStatusText');
if (d.exists) {
const src = d.count === 1 ? '1 source' : `${d.count} sources`;
txt.textContent = t('m365_delta_tokens_saved', `Tokens saved for ${src}`);
row.style.display = 'flex';
row.style.alignItems = 'center';
} else {
row.style.display = 'none';
}
} catch(e) { /* ignore */ }
}
async function clearDeltaTokens() {
await fetch('/api/delta/clear', {method:'POST'});
document.getElementById('deltaStatusRow').style.display = 'none';
log(t('m365_delta_cleared', 'Delta tokens cleared — next scan will be a full scan.'));
}
// ── SMTP / Email report modal ─────────────────────────────────────────────────
function openSmtpModal(focusSend) {
document.getElementById('smtpBackdrop').classList.add('open');
document.getElementById('smtpStatus').textContent = '';
loadSmtpConfig();
if (focusSend) {
setTimeout(() => document.getElementById('smtpRecipients').focus(), 120);
}
}
function closeSmtpModal() {
document.getElementById('smtpBackdrop').classList.remove('open');
}
async function loadSmtpConfig() {
try {
const r = await fetch('/api/smtp/config');
const d = await r.json();
if (d.host) document.getElementById('smtpHost').value = d.host;
if (d.port) document.getElementById('smtpPort').value = d.port;
if (d.username) document.getElementById('smtpUser').value = d.username;
if (d.from_addr) document.getElementById('smtpFrom').value = d.from_addr;
if (d.recipients) document.getElementById('smtpRecipients').value = Array.isArray(d.recipients) ? d.recipients.join(', ') : d.recipients;
if (d.password_saved) document.getElementById('smtpPass').placeholder = '(password saved)';
if (d.use_tls !== undefined) document.getElementById('smtpTLS').checked = d.use_tls;
if (d.use_ssl !== undefined) document.getElementById('smtpSSL').checked = d.use_ssl;
} catch(e) { /* ignore */ }
}
async function saveSmtpConfig() {
const cfg = _smtpFields();
const r = await fetch('/api/smtp/config', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(cfg)
});
const d = await r.json();
const el = document.getElementById('smtpStatus');
if (d.status === 'saved') {
el.style.color = 'var(--success)';
el.textContent = t('m365_smtp_saved', 'Settings saved.');
if (cfg.password) document.getElementById('smtpPass').placeholder = '(password saved)';
} else {
el.style.color = 'var(--danger)';
el.textContent = d.error || 'Error saving';
}
}
async function sendReport() {
const cfg = _smtpFields();
const recipStr = document.getElementById('smtpRecipients').value.trim();
if (!recipStr) {
document.getElementById('smtpStatus').style.color = 'var(--danger)';
document.getElementById('smtpStatus').textContent = t('m365_smtp_no_recipients', 'Enter at least one recipient.');
document.getElementById('smtpRecipients').focus();
return;
}
const recipients = recipStr.split(/[,;]/).map(s => s.trim()).filter(Boolean);
const statusEl = document.getElementById('smtpStatus');
statusEl.style.color = 'var(--muted)';
statusEl.textContent = t('m365_smtp_sending', 'Sending…');
const r = await fetch('/api/send_report', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({recipients, smtp: cfg})
});
const d = await r.json();
if (d.status === 'sent') {
statusEl.style.color = 'var(--success)';
statusEl.textContent = t('m365_smtp_sent', 'Sent to ' + recipients.join(', '));
log('Report emailed to ' + recipients.join(', '), 'ok');
} else {
statusEl.style.color = 'var(--danger)';
statusEl.textContent = d.error || 'Send failed';
log('Email send failed: ' + (d.error || ''), 'err');
}
}
function _smtpFields() {
return {
host: document.getElementById('smtpHost').value.trim(),
port: parseInt(document.getElementById('smtpPort').value) || 587,
username: document.getElementById('smtpUser').value.trim(),
password: document.getElementById('smtpPass').value,
from_addr: document.getElementById('smtpFrom').value.trim(),
use_tls: document.getElementById('smtpTLS').checked,
use_ssl: document.getElementById('smtpSSL').checked,
recipients: document.getElementById('smtpRecipients').value,
};
}
// ── Shared SSE event listeners (#21) ─────────────────────────────────────────
// Extracted so both startScan() and _autoConnectSSEIfRunning() share identical
// handlers — fixes the bug where replayed events from a scheduled scan were
// silently ignored because the page-load SSE only had scheduler_* listeners.
function _attachScanListeners(source) {
source.addEventListener('scan_phase', function(e) {
var d = JSON.parse(e.data);
console.log('[SSE] scan_phase:', d.phase);
// Ensure a progress segment exists before rendering phase text.
// scan_phase can arrive before scan_progress (or before scan_start on replay
// if scan_start has been pushed out of the 500-event SSE buffer).
if (!S._m365ScanRunning && !S._googleScanRunning && !S._fileScanRunning) {
var ph = (d.phase || '').toLowerCase();
var phaseSrc = /google|gmail|gdrive/.test(ph) ? 'google'
: /^files\s*[—\-]/.test(ph) ? 'file'
: 'm365';
if (phaseSrc === 'google') { S._googleScanRunning = true; }
else if (phaseSrc === 'file') { S._fileScanRunning = true; }
else { S._m365ScanRunning = true; }
document.getElementById('scanBtn').disabled = true;
document.getElementById('stopBtn').style.display = 'inline-block';
_renderProgressSegments();
}
_setProgressPhase(d.phase);
log(d.phase);
});
source.addEventListener('scan_progress', function(e) {
var d = JSON.parse(e.data);
var src = d.source || 'm365';
var pct = d.pct !== undefined ? d.pct
: (d.total > 0 ? Math.round((d.index || d.completed || 0) / d.total * 100) : 0);
S._srcPct[src] = pct;
// If reconnecting mid-scan the running flag may not be set yet — ensure segment exists
if (src === 'm365' && !S._m365ScanRunning) { S._m365ScanRunning = true; document.getElementById('scanBtn').disabled = true; document.getElementById('stopBtn').style.display = 'inline-block'; _renderProgressSegments(); }
if (src === 'google' && !S._googleScanRunning) { S._googleScanRunning = true; document.getElementById('scanBtn').disabled = true; document.getElementById('stopBtn').style.display = 'inline-block'; _renderProgressSegments(); }
if (src === 'file' && !S._fileScanRunning) { S._fileScanRunning = true; document.getElementById('scanBtn').disabled = true; document.getElementById('stopBtn').style.display = 'inline-block'; _renderProgressSegments(); }
var fill = document.getElementById('progressFill_' + src);
if (fill) fill.style.width = pct + '%';
document.getElementById('progressFile').textContent = d.file || '';
var statsEl = document.getElementById('progressStats');
var etaEl = document.getElementById('progressEta');
if (src === 'm365') {
// M365 sends index + total + ETA — show exact counter
if (statsEl && d.total) statsEl.textContent = (d.index || 0) + ' / ' + d.total;
if (etaEl && d.eta !== undefined) etaEl.textContent = d.eta ? ('ETA ' + d.eta) : '';
} else if (!S._m365ScanRunning) {
// Google / file: no total known upfront — show running count once M365 is done
if (statsEl && d.scanned !== undefined) statsEl.textContent = d.scanned + ' scanned';
if (etaEl) etaEl.textContent = '';
}
});
source.addEventListener('scan_file', function(e) {
var d = JSON.parse(e.data);
setLogLive(d.file || '');
});
source.addEventListener('scan_file_flagged', function(e) {
var card = JSON.parse(e.data);
console.log('[SSE] scan_file_flagged:', card.name || card.id);
if (!S.flaggedData.find(function(x){ return x.id === card.id; })) {
S.flaggedData.push(card);
S.totalCPR += (card.cpr_count || 0);
document.getElementById('filterBar').style.display = 'flex';
document.getElementById('grid').style.display = S.isListView ? 'block' : 'grid';
applyFilters();
}
});
source.addEventListener('scan_error', function(e) {
var d = JSON.parse(e.data);
log((d.file ? d.file + ': ' : '') + d.error, 'err');
});
source.addEventListener('scan_cancelled', function() {
if (S._userStartedScan) {
S._userStartedScan = false;
if (S.es) { S.es.close(); S.es = null; }
}
document.getElementById('scanBtn').disabled = false;
document.getElementById('stopBtn').style.display = 'none';
_clearProgressBar();
setLogLive('');
log('Scan stopped.', 'warn');
});
source.addEventListener('scan_done', function(e) {
var d = JSON.parse(e.data);
console.log('[SSE] scan_done:', d);
S._srcPct.m365 = 100;
S._m365ScanRunning = false;
_renderProgressSegments();
var _anyRunning = S._googleScanRunning || S._fileScanRunning;
// Clear M365 counter/ETA so Google/file progress can take over the display
if (_anyRunning) {
var _se = document.getElementById('progressStats');
var _ee = document.getElementById('progressEta');
if (_se) _se.textContent = '';
if (_ee) _ee.textContent = '';
}
// Only close SSE once all concurrent scans have finished.
// Closing early would drop google_scan_done / file_scan_done events and
// leave the UI stuck in scanning state.
if (S._userStartedScan && !_anyRunning) {
S._userStartedScan = false;
if (S.es) { S.es.close(); S.es = null; }
}
if (!_anyRunning) setLogLive('');
document.getElementById('scanBtn').disabled = _anyRunning;
document.getElementById('stopBtn').style.display = _anyRunning ? 'inline-block' : 'none';
if (!_anyRunning) _clearProgressBar();
document.getElementById('statsSection').style.display = 'block';
document.getElementById('statScanned').textContent = d.total_scanned;
document.getElementById('statFlagged').textContent = d.flagged_count;
document.getElementById('statCPR').textContent = S.totalCPR;
document.getElementById('statsPill').style.display = 'block';
updateStats();
if (S.flaggedData.length) {
document.getElementById('filterBar').style.display = 'flex';
document.getElementById('grid').style.display = S.isListView ? 'block' : 'grid';
applyFilters();
} else {
document.getElementById('emptyState').style.display = 'flex';
document.getElementById('emptyState').innerHTML = '<div class="empty-icon">\u2705</div><div class="empty-text">' + t('m365_no_cpr_found','No CPR numbers found.') + '</div>';
}
var deltaNote = d.delta ? ' (\u0394 delta \u2014 ' + (d.delta_sources||0) + ' source(s) indexed)' : '';
log('Scan complete \u2014 ' + d.flagged_count + ' flagged of ' + d.total_scanned + deltaNote, 'ok');
if (d.delta) checkDeltaStatus();
markOverdueCards();
loadTrend();
window.invalidateHistoryCache?.();
});
source.addEventListener('google_scan_done', function(e) {
var d = JSON.parse(e.data);
console.log('[SSE] google_scan_done:', d);
S._srcPct.google = 100;
S._googleScanRunning = false;
_renderProgressSegments();
if (!S._m365ScanRunning && !S._fileScanRunning) {
if (S._userStartedScan) {
S._userStartedScan = false;
if (S.es) { S.es.close(); S.es = null; }
}
setLogLive('');
document.getElementById('scanBtn').disabled = false;
document.getElementById('stopBtn').style.display = 'none';
_clearProgressBar();
document.getElementById('statsSection').style.display = 'block';
document.getElementById('statsPill').style.display = 'block';
updateStats();
if (S.flaggedData.length) {
document.getElementById('filterBar').style.display = 'flex';
document.getElementById('grid').style.display = S.isListView ? 'block' : 'grid';
applyFilters();
}
}
log('Google scan complete \u2014 ' + d.flagged_count + ' flagged of ' + d.total_scanned, 'ok');
markOverdueCards();
loadTrend();
window.invalidateHistoryCache?.();
});
source.addEventListener('file_scan_done', function(e) {
var d = JSON.parse(e.data);
console.log('[SSE] file_scan_done:', d);
S._srcPct.file = 100;
S._fileScanRunning = false;
_renderProgressSegments();
if (!S._m365ScanRunning && !S._googleScanRunning) {
if (S._userStartedScan) {
S._userStartedScan = false;
if (S.es) { S.es.close(); S.es = null; }
}
setLogLive('');
document.getElementById('scanBtn').disabled = false;
document.getElementById('stopBtn').style.display = 'none';
_clearProgressBar();
document.getElementById('statsSection').style.display = 'block';
document.getElementById('statsPill').style.display = 'block';
updateStats();
if (S.flaggedData.length) {
document.getElementById('filterBar').style.display = 'flex';
document.getElementById('grid').style.display = S.isListView ? 'block' : 'grid';
applyFilters();
}
}
log('Bestandsscan fuldf\u00f8rt \u2014 ' + d.flagged_count + ' flagget af ' + d.total_scanned, 'ok');
markOverdueCards();
loadTrend();
window.invalidateHistoryCache?.();
});
// sse_replay_done marks end of buffer replay — log a note so the user knows
// earlier events above were replayed from an already-running scan.
// Also retry loadHistorySession if it bailed during replay: scan_phase events
// from a completed scan's replay temporarily set running flags to true, causing
// the watchdog's loadHistorySession call to bail before scan_done clears them.
source.addEventListener('sse_replay_done', function() {
log(t('m365_sse_replay_note', 'Live log resumed \u2014 earlier entries replayed from running scan.'));
if (!S._m365ScanRunning && !S._googleScanRunning && !S._fileScanRunning && !S._historyRefScanId) {
window.loadHistorySession?.(null);
}
});
}
function _attachSchedulerListeners(source) {
source.addEventListener('scheduler_started', function(e) {
var d = JSON.parse(e.data);
console.log('[SSE] scheduler_started received:', d);
log('\uD83D\uDD50 ' + t('m365_sched_title','Scheduled scan') + ': ' + (d.job_name||'') + '\u2026');
// Show progress UI so scan_phase / scan_progress events are visible
document.getElementById('scanBtn').disabled = true;
document.getElementById('stopBtn').style.display = 'inline-block';
S._srcPct = { m365: 0, google: 0, file: 0 }; S._m365ScanRunning = true; _renderProgressSegments();
_setProgressPhase((d.job_name||'') + '\u2026');
document.getElementById('progressFile').textContent = '';
});
source.addEventListener('scan_start', function(e) {
// Scheduled scans also emit scan_start — show progress UI in case
// scheduler_started was missed (e.g. browser reconnected mid-scan)
console.log('[SSE] scan_start received');
document.getElementById('scanBtn').disabled = true;
document.getElementById('stopBtn').style.display = 'inline-block';
// Ensure at least the M365 segment is rendered (scan_start is M365-only)
if (!S._m365ScanRunning) { S._m365ScanRunning = true; _renderProgressSegments(); }
});
source.addEventListener('scheduler_done', function(e) {
var d = JSON.parse(e.data);
console.log('[SSE] scheduler_done received:', d);
document.getElementById('scanBtn').disabled = false;
document.getElementById('stopBtn').style.display = 'none';
_clearProgressBar();
log('\u2713 ' + t('m365_sched_title','Scheduled scan') + ' ' + (d.job_name||'') + ' \u2014 ' + (d.flagged||0) + ' flagged', 'ok');
markOverdueCards();
loadTrend();
});
source.addEventListener('scheduler_error', function(e) {
var d = JSON.parse(e.data);
console.log('[SSE] scheduler_error received:', d);
document.getElementById('scanBtn').disabled = false;
document.getElementById('stopBtn').style.display = 'none';
_clearProgressBar();
log('\u26A0 ' + t('m365_sched_title','Scheduled scan') + ' failed: ' + (d.error||''), 'err');
});
}
function startScan(resume) {
const { sources, fileSources, googleSources, user_ids, options } = buildScanPayload();
if (!sources.length && !fileSources.length && !googleSources.length) { alert(t('m365_no_sources','No sources selected — nothing to scan.')); return; }
if (sources.length && !user_ids.length && !googleSources.length) { alert('Select at least one account to scan.'); return; }
// When resuming, keep existing cards; otherwise clear everything
if (!resume) {
S.flaggedData = []; S.filteredData = []; S.totalCPR = 0;
document.getElementById('grid').innerHTML = '';
document.getElementById('grid').style.display = 'none';
document.getElementById('emptyState').style.display = 'none';
const _lss = document.getElementById('lastScanSummary'); if (_lss) _lss.style.display = 'none';
document.getElementById('statsSection').style.display = 'none';
document.getElementById('statsPill').style.display = 'none';
}
// Exit history mode — live SSE takes over
window.exitHistoryMode?.();
document.getElementById('resumeBanner').style.display = 'none';
document.getElementById('logPanel').innerHTML = '<div class="log-line log-live" id="logLive" style="display:none"></div>';
try { sessionStorage.removeItem(_LOG_SESSION_KEY); } catch(e) {}
S._m365ScanRunning = sources.length > 0;
S._googleScanRunning = googleSources.length > 0;
S._fileScanRunning = fileSources.length > 0;
S._srcPct = { m365: 0, google: 0, file: 0 };
S._progressCurrentUser = '';
_renderProgressSegments();
document.getElementById('scanBtn').disabled = true;
document.getElementById('stopBtn').style.display = 'inline-block';
// progress segments rendered by _renderProgressSegments() called above
document.getElementById('progressFile').textContent = '';
_setProgressPhase(t('scan_preparing', 'Preparing…'));
const dateLabel = options.older_than_days > 0 ? ', ' + t('m365_log_older_than', 'older than') + ' ' + document.getElementById('olderThanDate').value : '';
const modeLabel = resume ? t('m365_log_resuming', 'Resuming scan:') : t('m365_log_starting_scan', 'Starting scan:');
var googleCount = googleSources.length > 0 ? S._allUsers.filter(function(u) {
return u.selected !== false && (u.platform === 'google' || u.platform === 'both');
}).length : 0;
var totalAccounts = (sources.length > 0 ? user_ids.length : 0) + (googleSources.length > 0 && sources.length === 0 ? googleCount : 0);
var allSourceLabels = sources.concat(googleSources);
log(modeLabel + ' ' + allSourceLabels.join(', ') + ' — ' + (totalAccounts || googleCount) + ' ' + t('m365_log_accounts', 'account(s)') + dateLabel + '…');
// Always close and reopen SSE — ensures a fresh queue is registered
// before the scan fires events (prevents missed events on the server side)
if (S.es) { S.es.close(); S.es = null; }
S._userStartedScan = true;
_ensureSSE();
// Revert to idle if every scan type that was supposed to start got rejected.
// Called after each 409 so we don't leave the UI stuck in "running" state
// while the previous scan's thread finishes winding down.
function _onScanConflict(label) {
log(label + ' ' + t('scan_already_running_err', 'already running — previous scan still stopping. Please wait and try again.'), 'err');
if (label === 'm365') S._m365ScanRunning = false;
if (label === 'file') S._fileScanRunning = false;
if (label === 'google') S._googleScanRunning = false;
if (!S._m365ScanRunning && !S._googleScanRunning && !S._fileScanRunning) {
document.getElementById('scanBtn').disabled = false;
document.getElementById('stopBtn').style.display = 'none';
if (S.es) { S.es.close(); S.es = null; }
S._userStartedScan = false;
}
}
setTimeout(() => {
// Fire M365 scan if any M365 sources are selected
if (sources.length > 0) {
fetch('/api/scan/start', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({sources, user_ids, options, resume: !!resume,
profile_id: S._activeProfileId || null})
}).then(r => {
if (r.status === 409) { _onScanConflict('m365'); }
}).catch(e => { log('Scan start failed: ' + e, 'err'); });
}
// Fire file scans for each checked file source (local/smb)
const checkedFileIds = [];
document.querySelectorAll('#sourcesPanel input[data-source-type="file"]:checked').forEach(function(cb) {
checkedFileIds.push(cb.dataset.sourceId);
});
checkedFileIds.forEach(function(id) {
const source = S._fileSources.find(function(s) { return s.id === id; });
if (!source) return;
fetch('/api/file_scan/start', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(Object.assign({}, source, {
scan_photos: options.scan_photos || false,
skip_gps_images: options.skip_gps_images || false,
min_cpr_count: options.min_cpr_count || 1,
scan_emails: options.scan_emails || false,
scan_phones: options.scan_phones || false,
cpr_only: options.cpr_only || false,
ocr_lang: options.ocr_lang || 'dan+eng',
}))
}).then(r => {
if (r.status === 409) { _onScanConflict('file'); }
}).catch(e => { log('File scan error: ' + e, 'err'); });
});
// Fire Google Workspace scan if any Google sources are selected
const checkedGoogleIds = [];
document.querySelectorAll('#sourcesPanel input[data-source-type="google"]:checked').forEach(function(cb) {
checkedGoogleIds.push(cb.dataset.sourceId);
});
if (checkedGoogleIds.length > 0) {
// Collect selected Google user emails from the account list
var selectedGoogleEmails = S._allUsers
.filter(function(u) { return u.selected !== false && (u.platform === 'google' || u.platform === 'both'); })
.map(function(u) { return u.platform === 'both' ? u.googleEmail : u.email; })
.filter(Boolean);
fetch('/api/google/scan/start', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({
sources: checkedGoogleIds,
user_emails: selectedGoogleEmails,
options: options
})
}).then(r => {
if (r.status === 409) { _onScanConflict('google'); }
}).catch(e => { log('Google scan error: ' + e, 'err'); });
}
// All scan types fired above — no fallback error needed
}, 300);
}
function stopScan() {
fetch('/api/scan/stop', {method:'POST'});
}
// ── Trend sparkline (#7) ──────────────────────────────────────────────────────
function drawSparkline(data) {
const canvas = document.getElementById('sparkCanvas');
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const W = canvas.offsetWidth || 220;
const H = 60;
canvas.width = W * dpr;
canvas.height = H * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const flagged = data.map(d => d.flagged_count);
const overdue = data.map(d => d.overdue_count);
const maxVal = Math.max(...flagged, 1) * 1.2;
const n = data.length;
const xPos = i => (i / (n - 1)) * (W - 8) + 4;
const yPos = v => H - 4 - (v / maxVal) * (H - 10);
const isDark = document.body.getAttribute('data-theme') !== 'light';
const cBlue = '#378ADD';
const cAmber = '#BA7517';
const cFill = isDark ? 'rgba(55,138,221,0.12)' : 'rgba(55,138,221,0.08)';
// Fill under flagged line
ctx.beginPath();
ctx.moveTo(xPos(0), yPos(flagged[0]));
for (let i = 1; i < n; i++) ctx.lineTo(xPos(i), yPos(flagged[i]));
ctx.lineTo(xPos(n - 1), H);
ctx.lineTo(xPos(0), H);
ctx.closePath();
ctx.fillStyle = cFill;
ctx.fill();
// Flagged line
ctx.beginPath();
ctx.moveTo(xPos(0), yPos(flagged[0]));
for (let i = 1; i < n; i++) ctx.lineTo(xPos(i), yPos(flagged[i]));
ctx.strokeStyle = cBlue; ctx.lineWidth = 1.5; ctx.lineJoin = 'round';
ctx.stroke();
// Overdue dashed line
ctx.beginPath();
ctx.moveTo(xPos(0), yPos(overdue[0]));
for (let i = 1; i < n; i++) ctx.lineTo(xPos(i), yPos(overdue[i]));
ctx.strokeStyle = cAmber; ctx.lineWidth = 1;
ctx.setLineDash([3, 3]); ctx.stroke(); ctx.setLineDash([]);
// Dot on latest point
ctx.beginPath();
ctx.arc(xPos(n - 1), yPos(flagged[n - 1]), 3, 0, Math.PI * 2);
ctx.fillStyle = cBlue; ctx.fill();
// Labels: first, middle, last date (MM-DD only)
const lblEl = document.getElementById('sparkLabels');
if (lblEl) {
const fmt = d => d.scan_date.slice(5);
lblEl.innerHTML = `<span>${fmt(data[0])}</span><span>${fmt(data[Math.floor(n/2)])}</span><span>${fmt(data[n-1])}</span>`;
}
// Trend change label
const last = flagged[n - 1], prev = flagged[n - 2] || last;
const diff = last - prev;
const pct = prev ? Math.round(Math.abs(diff / prev) * 100) : 0;
const arrow = diff < 0 ? '↓' : diff > 0 ? '↑' : '→';
const color = diff < 0 ? 'var(--success)' : diff > 0 ? 'var(--danger)' : 'var(--muted)';
const chEl = document.getElementById('trendChange');
if (chEl) chEl.innerHTML = `<span style="color:${color}">${arrow} ${pct}%</span>`;
// Hover tooltip
canvas.onmousemove = e => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const idx = Math.round(((mx - 4) / (W - 8)) * (n - 1));
if (idx < 0 || idx >= n) return;
const d = data[idx];
const tip = document.getElementById('sparkTip');
if (!tip) return;
tip.style.display = 'block';
tip.textContent = `${d.scan_date} ${d.flagged_count} / ${d.overdue_count} overdue`;
tip.style.left = Math.min(mx, W - tip.offsetWidth - 4) + 'px';
};
canvas.onmouseleave = () => {
const tip = document.getElementById('sparkTip');
if (tip) tip.style.display = 'none';
};
}
async function loadTrend() {
try {
const r = await fetch('/api/db/trend?n=10');
if (!r.ok) return;
const data = await r.json();
if (!Array.isArray(data) || data.length < 2) return;
document.getElementById('trendPanel').style.display = 'block';
// Defer draw until canvas has layout width
setTimeout(() => drawSparkline(data), 60);
} catch(e) { /* DB not available */ }
}
function updateStats() {
document.getElementById('pillFlagged').textContent = S.flaggedData.length;
document.getElementById('pillScanned').textContent =
parseInt(document.getElementById('progressStats').textContent.split('/')[1] || '0') || 0;
}
// ── Window exports (HTML handlers + cross-module calls) ─────────────────────
window.exportDB = exportDB;
window.openImportDBModal = openImportDBModal;
window.closeImportDBModal = closeImportDBModal;
window.doImportDB = doImportDB;
window.buildScanPayload = buildScanPayload;
window.checkCheckpoint = checkCheckpoint;
window.clearCheckpointAndScan = clearCheckpointAndScan;
window.checkDeltaStatus = checkDeltaStatus;
window.clearDeltaTokens = clearDeltaTokens;
window.openSmtpModal = openSmtpModal;
window.closeSmtpModal = closeSmtpModal;
window.loadSmtpConfig = loadSmtpConfig;
window.saveSmtpConfig = saveSmtpConfig;
window.sendReport = sendReport;
window._smtpFields = _smtpFields;
window._attachScanListeners = _attachScanListeners;
window._attachSchedulerListeners = _attachSchedulerListeners;
window.startScan = startScan;
window.stopScan = stopScan;
window.drawSparkline = drawSparkline;
window.loadTrend = loadTrend;
window.updateStats = updateStats;