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
|
||||
|
||||
Reference in new issue
Block a user