Full DMS: folders + ACLs, versioning, trash, bulk ops, preview, smart folders

Rebuild the dashboard as a Drive-style document management system on top of
the existing CaveauAI hybrid RAG pipeline.

Backend:
- 5 migrations (versions, trash soft-delete, saved searches, categories, audit)
- DMS helpers (folder ACL walker, disk storage, audit, version snapshot,
  XLSX/PPTX/HTML/CSV/MD extractors)
- New APIs: folders, document-versions, trash, bulk, preview, saved-searches,
  categories, diagnostics
- Extended APIs: documents (folder_id, soft-delete, ACL filter, sort),
  upload (9 file types, version-collision detection with replace/new/keep-both,
  disk persistence), chat-stream (folder scoping + graph related-documents)
- 30-day trash purge cron with Qdrant + disk + graph cleanup

Frontend:
- Drive-style two-pane browser with folder tree, drag-drop, bulk-action bar,
  right-click context menu, multi-select
- New pages: folders (tree + per-folder ACL editor), trash (restore/purge)
- Extended pages: upload (folder picker, version-collision modal, 9 file
  type chips), document (Preview/Versions/Permissions tabs with PDF.js +
  mammoth.js + audio), index (DMS KPIs + activity feed), settings (live
  diagnostics ping MariaDB/Qdrant/LiteLLM/FalkorDB/disk), chat (folder
  scope chips + related-authorities chips)
- New CSS (dms.css) + JS bundle (dms.js) exposing window.DBN_DMS
- Sidebar nav adds Folders + Trash items

All routes return HTTP 200 in local smoke test; all 32 files lint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 22:24:56 +02:00
parent b84827ecea
commit 2e2b0b45fa
30 changed files with 5438 additions and 335 deletions
+60
View File
@@ -40,6 +40,13 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
</div>
</section>
<section class="dms-kpis" id="dmsExtraKpis" aria-label="DMS overview">
<div class="dms-kpi"><p class="dms-kpi__label">Storage used</p><p class="dms-kpi__value" id="dmsStorage">—</p><p class="dms-kpi__hint">across all documents</p></div>
<div class="dms-kpi"><p class="dms-kpi__label">Folders</p><p class="dms-kpi__value" id="dmsFolders">—</p><p class="dms-kpi__hint">organising your library</p></div>
<div class="dms-kpi"><p class="dms-kpi__label">In trash</p><p class="dms-kpi__value" id="dmsTrash">—</p><p class="dms-kpi__hint">auto-purges after 30d</p></div>
<div class="dms-kpi"><p class="dms-kpi__label">Smart folders</p><p class="dms-kpi__value" id="dmsSmart">—</p><p class="dms-kpi__hint">saved views</p></div>
</section>
<section class="dash-card">
<div class="dash-card__head">
<h2 id="recentTitle"></h2>
@@ -50,6 +57,11 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
<div id="dashRecent" class="dash-loading"></div>
</section>
<section class="dash-card">
<div class="dash-card__head"><h2>Recent activity</h2></div>
<div id="dmsActivity" class="dms-activity"><div class="dms-loading"></div></div>
</section>
<script>
(function () {
'use strict';
@@ -148,6 +160,54 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
recent.className = 'dash-error';
recent.textContent = (I18N.error_loading || 'Could not load: ') + err.message;
});
// DMS overview tiles
(async function loadDmsKpis() {
try {
const tree = await fetch(api + '/folders.php?action=list_tree', { credentials: 'same-origin' }).then(r => r.json());
const allDocs = await fetch(api + '/documents.php?action=list&limit=500', { credentials: 'same-origin' }).then(r => r.json());
const storage = (allDocs.documents || []).reduce((s, d) => s + (d.file_size_bytes || 0), 0);
document.getElementById('dmsStorage').textContent =
storage < 1024*1024 ? Math.round(storage/1024) + ' KB'
: storage < 1024*1024*1024 ? (storage/1024/1024).toFixed(1) + ' MB'
: (storage/1024/1024/1024).toFixed(2) + ' GB';
const countFolders = (nodes) => (nodes || []).reduce((n, x) => n + 1 + countFolders(x.children), 0);
document.getElementById('dmsFolders').textContent = countFolders(tree.tree || []);
document.getElementById('dmsTrash').textContent = tree.trash_count || 0;
} catch (_) { /* ignored */ }
try {
const ss = await fetch(api + '/saved-searches.php?action=list', { credentials: 'same-origin' }).then(r => r.json());
document.getElementById('dmsSmart').textContent = (ss.items || []).length;
} catch (_) { /* ignored */ }
})();
// Activity feed — gracefully degrades if endpoint absent
(async function loadActivity() {
const wrap = document.getElementById('dmsActivity');
try {
// We don't have a dedicated activity endpoint; fall back to /documents?action=list sorted by updated.
const data = await fetch(api + '/documents.php?action=list&limit=15&sort=updated_at&dir=desc', { credentials: 'same-origin' }).then(r => r.json());
const items = data.documents || [];
if (!items.length) {
wrap.innerHTML = '<div class="dms-list__empty"><strong>No activity yet</strong><span>Upload a document to get started.</span></div>';
return;
}
const safe = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
const rel = s => { if (!s) return '—'; const d = new Date(s.replace(' ', 'T') + 'Z'); const diff = (Date.now()-d)/1000;
if (diff<60) return 'just now'; if (diff<3600) return Math.floor(diff/60)+'m'; if (diff<86400) return Math.floor(diff/3600)+'h';
if (diff<86400*7) return Math.floor(diff/86400)+'d'; return d.toLocaleDateString(loc); };
const iconForStatus = { ready:'✓', pending:'⏳', processing:'⚙', error:'⚠' };
wrap.innerHTML = items.slice(0, 15).map(d =>
'<div class="dms-activity__row">' +
'<span class="dms-activity__icon">' + (iconForStatus[d.status] || '📄') + '</span>' +
'<div><a href="/dashboard/document.php?id=' + d.id + '">' + safe(d.title) + '</a>' +
(d.category ? ' <span class="dms-chip dms-chip--cat">' + safe(d.category) + '</span>' : '') + '</div>' +
'<span class="dms-activity__time">' + rel(d.updated_at || d.created_at) + '</span></div>'
).join('');
} catch (e) {
wrap.innerHTML = '<div class="dms-list__empty"><strong>Could not load</strong><span>' + e.message + '</span></div>';
}
})();
})();
</script>