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, 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() { const payload = buildScanPayload(); if (!payload.sources.length && !payload.fileSources.length) return; if (payload.sources.length && !payload.user_ids.length) return; try { const r = await fetch('/api/scan/checkpoint', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(payload) }); const d = await r.json(); const banner = document.getElementById('resumeBanner'); 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 : ''})`); banner.style.display = 'flex'; } else { banner.style.display = 'none'; } } catch(e) { /* ignore */ } } 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 || ''; // Only update stats/ETA from M365 (has meaningful totals and ETA) if (src === 'm365') { var statsEl = document.getElementById('progressStats'); if (statsEl && d.total) { statsEl.textContent = (d.index || 0) + ' / ' + d.total; } var etaEl = document.getElementById('progressEta'); if (etaEl && d.eta !== undefined) { etaEl.textContent = d.eta ? ('ETA ' + d.eta) : ''; } } }); 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; // 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 = '