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:
1 parent
b2bfa40f27
commit
d8083eb0c0
26 files changed
+1059
-128
No files matched your search
+139
-2
@@ -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,'"')},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
@@ -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')); }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user