feat: interface PIN, bulk disposition tagging, Google Drive delta scan, OCR memory fixes

- Interface PIN: optional session-level auth gate for the main scanner UI
  (Settings → Security → Interface PIN). Salted SHA-256 in config.json,
  rate-limited (5 attempts/5 min per IP). /view and viewer auth exempt.
  New /login page, before_request hook, GET/POST/DELETE /api/interface/pin,
  POST /api/interface/pin/verify, POST /api/interface/logout.

- Bulk disposition tagging: Select mode (filter bar "Vælg" button) reveals
  per-card checkboxes. Bulk tag bar at bottom of grid; POST /api/db/disposition/bulk.
  Disposition stats bar (total · unreviewed · retain · delete · % reviewed)
  updates after every save.

- Google Drive delta scan: uses Drive Changes API when delta is enabled.
  Per-user token stored as gdrive:{email} in delta.json. Load-then-merge
  save avoids racing with concurrent M365 token writes.

- PDF OCR OOM fix: render one page at a time with convert_from_path
  (first_page=N, last_page=N). Added _ocr_mem_ok() psutil guard (500 MB
  threshold) before each page render across scan_pdf, redact_fitz_pdf,
  redact_pdf.

- Email test message translation fix: routes/email.py returns structured
  {ok, method, recipients} instead of a hardcoded English string;
  scheduler.js builds the translated message client-side.

- Docs: CHANGELOG, README, TODO, MANUAL-EN, MANUAL-DA all updated.
  Lang files (en/da/de) extended with bulk, interface PIN, and SMTP keys.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
StyxX65andClaude Sonnet 4.6 committed 2026-04-18 18:46:45 +02:00
1 parent b2bfa40f27
commit d8083eb0c0
26 files changed
+1059 -128

No files matched your search

+139 -2
View File
@@ -24,9 +24,16 @@ function appendCard(f) {
: '/api/thumb?name=' + encodeURIComponent(f.name) + '&type=' + encodeURIComponent(f.source_type);
const card = document.createElement('div');
card.className = 'card' + (S.isListView ? ' list-view' : '');
card.className = 'card' + (S.isListView ? ' list-view' : '') + (S._selectedIds.has(f.id) ? ' card-selected-bulk' : '');
card.dataset.id = f.id;
card.onclick = () => openPreview(f);
card.onclick = (e) => { if (S._selectMode) { toggleCardSelect(f.id, e); } else { openPreview(f); } };
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'card-cb';
cb.checked = S._selectedIds.has(f.id);
cb.onclick = (e) => { e.stopPropagation(); toggleCardSelect(f.id, e); };
card.appendChild(cb);
const delBtn = window.VIEWER_MODE ? '' : `<button class="card-delete-btn" title="${t('m365_delete_confirm','Delete')}" onclick="event.stopPropagation();deleteItem(${JSON.stringify(f).replace(/"/g,'&quot;')},this.closest('.card'))">🗑</button>`;
@@ -62,6 +69,8 @@ function renderGrid(files) {
const grid = document.getElementById('grid');
grid.innerHTML = '';
files.forEach(f => appendCard(f));
_updateBulkBar();
updateDispositionStats();
}
// ── Preview panel ─────────────────────────────────────────────────────────────
@@ -367,6 +376,7 @@ async function saveDisposition() {
// Update cached value on the S.flaggedData item
const item = S.flaggedData.find(f => f.id === _dispositionItemId);
if (item) item.disposition = status;
updateDispositionStats();
// Refresh card badge if a disposition filter is active
const dispFilter = document.getElementById("filterDisposition")?.value;
if (dispFilter) applyFilters();
@@ -375,6 +385,133 @@ async function saveDisposition() {
}
}
// ── Disposition stats ─────────────────────────────────────────────────────────
function updateDispositionStats() {
const el = document.getElementById('dispStats');
if (!el) return;
const data = S.flaggedData;
if (!data.length) { el.style.display = 'none'; return; }
let unreviewed = 0, retain = 0, del = 0, other = 0;
for (const f of data) {
const d = f.disposition || 'unreviewed';
if (d === 'unreviewed') unreviewed++;
else if (d.startsWith('retain')) retain++;
else if (d.startsWith('delete') || d === 'deleted') del++;
else other++;
}
const reviewed = data.length - unreviewed;
const pct = data.length ? Math.round(reviewed / data.length * 100) : 0;
el.style.display = 'flex';
el.innerHTML =
`<span>${data.length} ${t('disp_stats_total','total')}</span>` +
`<span class="disp-stat-sep"></span>` +
`<span class="${unreviewed ? 'disp-stat-warn' : 'disp-stat-ok'}">${unreviewed} ${t('disp_stats_unreviewed','unreviewed')}</span>` +
`<span class="disp-stat-sep"></span>` +
`<span>${retain} ${t('disp_stats_retain','retain')}</span>` +
`<span class="disp-stat-sep"></span>` +
`<span>${del} ${t('disp_stats_delete','delete')}</span>` +
(other ? `<span class="disp-stat-sep"></span><span>${other} ${t('disp_stats_other','other')}</span>` : '') +
`<span class="disp-stat-sep" style="margin-left:auto"></span>` +
`<span style="font-weight:600;color:var(--accent)">${pct}% ${t('disp_stats_reviewed','reviewed')}</span>`;
}
// ── Bulk disposition tagging ──────────────────────────────────────────────────
function toggleSelectMode() {
S._selectMode = !S._selectMode;
document.body.classList.toggle('select-mode', S._selectMode);
const btn = document.getElementById('selectModeBtn');
if (btn) {
btn.style.background = S._selectMode ? 'var(--accent)' : 'none';
btn.style.color = S._selectMode ? '#fff' : 'var(--muted)';
btn.style.borderColor = S._selectMode ? 'var(--accent)' : 'var(--border)';
}
if (!S._selectMode) {
S._selectedIds.clear();
_updateBulkBar();
} else {
closePreview();
}
// Re-render so card onclick handlers respect new mode
renderGrid(S.filteredData.length ? S.filteredData : S.flaggedData);
}
function toggleCardSelect(id, ev) {
if (ev) ev.stopPropagation();
if (S._selectedIds.has(id)) S._selectedIds.delete(id);
else S._selectedIds.add(id);
const cb = document.querySelector(`.card[data-id="${CSS.escape(id)}"] .card-cb`);
if (cb) cb.checked = S._selectedIds.has(id);
const card = document.querySelector(`.card[data-id="${CSS.escape(id)}"]`);
if (card) card.classList.toggle('card-selected-bulk', S._selectedIds.has(id));
_updateBulkBar();
}
function selectAllVisible() {
const allChecked = S.filteredData.every(f => S._selectedIds.has(f.id));
if (allChecked) {
S.filteredData.forEach(f => { S._selectedIds.delete(f.id); });
} else {
S.filteredData.forEach(f => { S._selectedIds.add(f.id); });
}
renderGrid(S.filteredData.length ? S.filteredData : S.flaggedData);
_updateBulkBar();
}
function _updateBulkBar() {
const bar = document.getElementById('bulkTagBar');
const cnt = document.getElementById('bulkTagCount');
const saEl = document.getElementById('bulkSelectAll');
if (!bar) return;
const n = S._selectedIds.size;
bar.style.display = (S._selectMode && n > 0) ? 'flex' : 'none';
if (cnt) cnt.textContent = n + ' ' + t('bulk_selected', 'selected');
if (saEl) {
const allVis = S.filteredData.length > 0 && S.filteredData.every(f => S._selectedIds.has(f.id));
saEl.textContent = allVis
? t('bulk_deselect_all', 'Deselect all')
: t('bulk_select_all', 'Select all visible');
}
}
async function applyBulkDisposition() {
const status = document.getElementById('bulkDispSelect')?.value;
if (!status || S._selectedIds.size === 0) return;
const ids = [...S._selectedIds];
const btn = document.getElementById('bulkTagApplyBtn');
const statusEl = document.getElementById('bulkTagStatus');
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = '';
try {
const r = await fetch('/api/db/disposition/bulk', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({item_ids: ids, status}),
});
const d = await r.json();
if (d.error) throw new Error(d.error);
// Update in-memory items
for (const f of S.flaggedData) {
if (S._selectedIds.has(f.id)) f.disposition = status;
}
if (statusEl) {
statusEl.textContent = '✓ ' + d.saved + ' ' + t('bulk_applied', 'updated');
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 2000);
}
S._selectedIds.clear();
_updateBulkBar();
// Refresh filter if disposition filter is active
const dispFilter = document.getElementById('filterDisposition')?.value;
if (dispFilter) applyFilters();
else renderGrid(S.filteredData.length ? S.filteredData : S.flaggedData);
updateDispositionStats();
} catch(e) {
if (statusEl) statusEl.textContent = e.message;
} finally {
if (btn) btn.disabled = false;
}
}
function closePreview() {
const panel = document.getElementById('previewPanel');
panel.style.width = ''; // clear inline width so CSS .hidden { width:0 } takes effect
+10 -1
View File
@@ -334,7 +334,16 @@ async function stSmtpTest() {
body:JSON.stringify({})});
const d = await r.json();
if (d.ok) {
if (st) { st.style.color='var(--accent)'; st.textContent='\u2714 ' + (d.message || t('m365_smtp_test_ok','Connection successful')); }
let msg;
if (d.method === 'graph') {
msg = t('m365_smtp_test_ok_graph','Test email sent via Microsoft Graph to') + ' ' + (d.recipients||[]).join(', ');
} else if (d.method === 'smtp') {
msg = t('m365_smtp_test_ok_smtp','Test email sent via SMTP to') + ' ' + (d.recipients||[]).join(', ');
if (d.graph_also_failed) msg += ' ' + t('m365_smtp_graph_also_failed','(⚠ Graph also failed — Mail.Send not granted)');
} else {
msg = d.message || t('m365_smtp_test_ok','Test email sent');
}
if (st) { st.style.color='var(--accent)'; st.textContent='\u2714 ' + msg; }
} else {
if (st) { st.style.color='var(--danger)'; st.textContent='\u2717 ' + (d.error || t('m365_smtp_test_fail','Connection failed')); }
}
+1 -1
View File
@@ -243,7 +243,7 @@ function switchSettingsTab(tab) {
if (pane) pane.classList.toggle('active', t === tab);
if (btn) btn.classList.toggle('active', t === tab);
});
if (tab === 'security') { stLoadPinStatus(); if (typeof stLoadViewerPinStatus === 'function') stLoadViewerPinStatus(); }
if (tab === 'security') { stLoadPinStatus(); if (typeof stLoadViewerPinStatus === 'function') stLoadViewerPinStatus(); if (typeof stLoadInterfacePinStatus === 'function') stLoadInterfacePinStatus(); }
if (tab === 'email') stLoadSmtp();
if (tab === 'database') stLoadDbStats();
if (tab === 'scheduler') schedLoad();
+3
View File
@@ -30,4 +30,7 @@ export const S = {
_fileSources: [],
// History browser
_historyRefScanId: null, // null = live/SSE, number = viewing a past session
// Bulk disposition
_selectMode: false,
_selectedIds: new Set(),
};
+85 -3
View File
@@ -374,6 +374,85 @@ async function stClearViewerPin() {
}
}
// ── Interface PIN — Settings UI ───────────────────────────────────────────────
async function stLoadInterfacePinStatus() {
try {
const r = await fetch('/api/interface/pin');
const d = await r.json();
const statusEl = document.getElementById('stInterfacePinStatus');
const currentRow = document.getElementById('stInterfaceCurrentPinRow');
const clearBtn = document.getElementById('stInterfacePinClearBtn');
if (d.pin_set) {
if (statusEl) statusEl.textContent = '\u2714 ' + t('interface_pin_is_set', 'Interface PIN is set');
if (currentRow) currentRow.style.display = '';
if (clearBtn) clearBtn.style.display = '';
} else {
if (statusEl) statusEl.textContent = t('interface_pin_not_set_msg', 'No PIN set \u2014 interface is open to anyone on the network');
if (currentRow) currentRow.style.display = 'none';
if (clearBtn) clearBtn.style.display = 'none';
}
} catch(e) {}
}
async function stSaveInterfacePin() {
const newPin = (document.getElementById('stInterfaceNewPin')?.value || '').trim();
const currentPin = (document.getElementById('stInterfaceCurrentPin')?.value || '').trim();
const st = document.getElementById('stInterfacePinSaveStatus');
if (!newPin) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = t('m365_settings_pin_required', 'PIN is required.'); }
return;
}
if (!/^\d{4,8}$/.test(newPin)) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = t('viewer_pin_format', 'PIN must be 4\u20138 digits.'); }
return;
}
if (st) { st.style.color = 'var(--muted)'; st.textContent = t('viewer_pin_saving', 'Saving\u2026'); }
try {
const r = await fetch('/api/interface/pin', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({pin: newPin, current_pin: currentPin})
});
const d = await r.json();
if (!r.ok) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = d.error || 'Error.'; }
return;
}
if (st) { st.style.color = 'var(--accent)'; st.textContent = '\u2714 ' + t('interface_pin_saved', 'PIN saved'); }
if (document.getElementById('stInterfaceNewPin')) document.getElementById('stInterfaceNewPin').value = '';
if (document.getElementById('stInterfaceCurrentPin')) document.getElementById('stInterfaceCurrentPin').value = '';
stLoadInterfacePinStatus();
} catch(e) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = e.message; }
}
}
async function stClearInterfacePin() {
const currentPin = (document.getElementById('stInterfaceCurrentPin')?.value || '').trim();
const st = document.getElementById('stInterfacePinSaveStatus');
if (!currentPin) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = t('m365_settings_pin_required', 'PIN is required.'); }
document.getElementById('stInterfaceCurrentPin')?.focus();
return;
}
if (!confirm(t('interface_pin_clear_confirm', 'Remove the interface PIN? The scanner will be accessible to anyone on the network.'))) return;
try {
const r = await fetch('/api/interface/pin', {
method: 'DELETE', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({current_pin: currentPin})
});
const d = await r.json();
if (!r.ok) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = d.error || 'Error.'; }
return;
}
if (st) { st.style.color = 'var(--muted)'; st.textContent = t('interface_pin_cleared', 'PIN cleared'); }
stLoadInterfacePinStatus();
} catch(e) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = e.message; }
}
}
// ── Window exports ────────────────────────────────────────────────────────────
window._shareScopeTypeChanged = _shareScopeTypeChanged;
window.openShareModal = openShareModal;
@@ -382,6 +461,9 @@ window.createShareLink = createShareLink;
window.copyShareLink = copyShareLink;
window.copyTokenLink = copyTokenLink;
window.revokeToken = revokeToken;
window.stLoadViewerPinStatus = stLoadViewerPinStatus;
window.stSaveViewerPin = stSaveViewerPin;
window.stClearViewerPin = stClearViewerPin;
window.stLoadViewerPinStatus = stLoadViewerPinStatus;
window.stSaveViewerPin = stSaveViewerPin;
window.stClearViewerPin = stClearViewerPin;
window.stLoadInterfacePinStatus = stLoadInterfacePinStatus;
window.stSaveInterfacePin = stSaveInterfacePin;
window.stClearInterfacePin = stClearInterfacePin;
+24
View File
@@ -253,6 +253,28 @@
.card-delete-btn { position:absolute; top:6px; right:6px; background:rgba(0,0,0,0.45); color:#fff; border:none; border-radius:50%; width:22px; height:22px; font-size:13px; line-height:22px; text-align:center; cursor:pointer; opacity:0.35; transition:opacity .15s; padding:0; z-index:1; }
.card:hover .card-delete-btn { opacity:1; }
.card.list-view .card-delete-btn { position:static; opacity:1; background:transparent; color:var(--muted); flex-shrink:0; }
/* Per-card checkbox (select mode) */
.card-cb { position:absolute; top:6px; left:6px; width:16px; height:16px; margin:0; cursor:pointer; z-index:2;
display:none; accent-color:var(--accent); }
body.select-mode .card-cb { display:block; }
.card.card-selected-bulk { outline:2px solid var(--accent); outline-offset:2px; background:color-mix(in srgb, var(--accent) 8%, var(--surface)); }
body.select-mode .card { cursor:default; }
body.select-mode .card:hover { border-color:var(--accent); }
/* Disposition stats bar */
.disp-stats-bar { display:flex; align-items:center; gap:8px; padding:4px 16px;
background:var(--bg); border-bottom:1px solid var(--border);
font-size:11px; color:var(--muted); flex-shrink:0; flex-wrap:wrap; }
.disp-stat-sep { width:1px; height:10px; background:var(--border); flex-shrink:0; }
.disp-stat-warn { color:var(--danger); font-weight:600; }
.disp-stat-ok { color:var(--success); }
/* Bulk tag bar */
.bulk-tag-bar { display:flex; align-items:center; gap:8px; padding:6px 16px;
background:var(--surface); border-top:1px solid var(--border);
font-size:12px; color:var(--text); flex-shrink:0; flex-wrap:wrap; }
.bulk-tag-bar button { height:26px; padding:0 10px; border-radius:5px; font-size:12px; cursor:pointer; box-sizing:border-box; }
.bulk-delete-modal { max-width:460px; }
.bulk-criteria-row { display:flex; align-items:center; gap:8px; margin-bottom:8px; font-size:12px; }
.bulk-criteria-row label { flex:0 0 130px; color:var(--muted); }
@@ -607,6 +629,8 @@
body.viewer-mode .config-group { display: none !important; }
body.viewer-mode #resumeBanner { display: none !important; }
body.viewer-mode #bulkDeleteBtn { display: none !important; }
body.viewer-mode #selectModeBtn { display: none !important; }
body.viewer-mode #bulkTagBar { display: none !important; }
body.viewer-mode .card-delete-btn { display: none !important; }
body.viewer-mode #dsubDeleteBtn { display: none !important; }
body.viewer-mode #shareBtn { display: none !important; }