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
+164
View File
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
$dashboardPage = 'folders';
$dashboardTitle = dbnToolsT('dash_title_folders', dbnToolsCurrentLanguage()) ?: 'Folders';
$dashboardLead = dbnToolsT('dash_lead_folders', dbnToolsCurrentLanguage()) ?: 'Organise documents and set per-folder access.';
require_once __DIR__ . '/../includes/layout_dashboard.php';
?>
<section class="dms-shell">
<aside class="dms-tree" id="dmsTree" aria-label="Folder tree">
<div class="dms-loading"></div>
</aside>
<div class="dms-main">
<div class="dms-toolbar">
<div class="dms-toolbar__crumbs" id="folderCrumbs"><span>Pick a folder on the left</span></div>
<div class="dms-toolbar__actions">
<button class="dash-btn dash-btn--primary" id="folderNewBtn" type="button">+ New folder</button>
</div>
</div>
<div id="folderDetail">
<div class="dms-list__empty">
<strong>Select a folder</strong>
<span>Use the tree on the left, or create a new folder.</span>
</div>
</div>
</div>
</section>
<script>
(function () {
'use strict';
const DMS = window.DBN_DMS;
const $tree = document.getElementById('dmsTree');
const $detail = document.getElementById('folderDetail');
const $crumbs = document.getElementById('folderCrumbs');
let selectedFolderId = null;
async function loadTree() {
await DMS.Tree.load();
DMS.Tree.render($tree, { activeFolderId: selectedFolderId });
}
async function showDetail(folderId) {
if (folderId === 'all' || folderId === 'unassigned' || folderId === 'trash') {
$detail.innerHTML = '<div class="dms-list__empty"><strong>System folder</strong><span>Cannot configure access for system folders.</span></div>';
return;
}
selectedFolderId = folderId;
$detail.innerHTML = '<div class="dms-loading"></div>';
try {
const [perms, crumbsData] = await Promise.all([
DMS.api('/folders.php?action=list_permissions&folder_id=' + folderId),
DMS.api('/folders.php?action=get_breadcrumb&folder_id=' + folderId),
]);
$crumbs.innerHTML = (crumbsData.breadcrumb || []).map((b, i, a) =>
(i ? '<span class="dms-toolbar__crumb-sep"></span>' : '')
+ (i === a.length - 1
? '<span class="dms-toolbar__crumb--current">' + DMS.safe(b.name) + '</span>'
: '<span>' + DMS.safe(b.name) + '</span>')
).join('');
const rows = (perms.permissions || []).map(p => {
const who = p.user_id
? '👤 ' + DMS.safe(p.user_email || ('user #' + p.user_id))
: '🛡 role ≥ ' + DMS.safe(p.min_role);
return '<div class="dms-perm-row">' +
'<div>' + who + '</div>' +
'<div>' + (p.can_read ? '✓' : '—') + ' read</div>' +
'<div>' + (p.can_write ? '✓' : '—') + ' write</div>' +
'<div>' + (p.can_manage ? '✓' : '—') + ' manage</div>' +
'<div><button class="dms-list__more" data-pid="' + p.id + '" type="button" aria-label="Remove">✕</button></div>' +
'</div>';
}).join('');
$detail.innerHTML =
'<div class="dms-toolbar"><div class="dms-toolbar__crumbs"><strong>Access control</strong></div>' +
'<div class="dms-toolbar__actions">' +
'<button class="dash-btn" type="button" id="folderRename">Rename</button>' +
'<button class="dash-btn dash-btn--danger" type="button" id="folderDelete">Delete</button>' +
'<button class="dash-btn dash-btn--primary" type="button" id="folderAddAcl">+ Add rule</button>' +
'</div></div>' +
'<div class="dms-list">' +
(rows ||
'<div class="dms-list__empty"><strong>Open to all in tenant</strong>' +
'<span>No rules set — add one to restrict access.</span></div>') +
(rows ? '' : '') +
'</div>';
$detail.querySelector('#folderRename').addEventListener('click', () => {
DMS.folderModal.open({ folderId, name: crumbsData.breadcrumb.slice(-1)[0]?.name || '' });
});
$detail.querySelector('#folderDelete').addEventListener('click', async () => {
if (!confirm('Soft-delete this folder? Documents inside will move to Trash.')) return;
try {
await DMS.api('/folders.php?action=delete', { method: 'POST', body: { folder_id: folderId } });
selectedFolderId = null;
$detail.innerHTML = '<div class="dms-list__empty"><strong>Deleted</strong></div>';
await loadTree();
} catch (e) { alert(e.message); }
});
$detail.querySelector('#folderAddAcl').addEventListener('click', () => openAclModal(folderId));
$detail.querySelectorAll('[data-pid]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Remove this access rule?')) return;
try {
await DMS.api('/folders.php?action=remove_permission', { method: 'POST', body: { permission_id: Number(btn.dataset.pid) } });
showDetail(folderId);
} catch (e) { alert(e.message); }
});
});
} catch (e) {
$detail.innerHTML = '<div class="dms-list__empty"><strong>Error</strong><span>' + DMS.safe(e.message) + '</span></div>';
}
}
function openAclModal(folderId) {
DMS.openModal({
title: 'Add access rule',
body:
'<div class="dms-field"><label>Grantee type</label>' +
'<select id="aclType"><option value="role">Role-based</option><option value="user">Specific user (by id)</option></select></div>' +
'<div class="dms-field" id="aclRoleField"><label>Minimum role</label>' +
'<select id="aclRole"><option>viewer</option><option>editor</option><option>admin</option><option>owner</option></select></div>' +
'<div class="dms-field" id="aclUserField" style="display:none"><label>User ID</label><input type="number" id="aclUser"></div>' +
'<div class="dms-field dms-field--inline"><label>Permissions</label>' +
'<label><input type="checkbox" id="aclRead" checked> read</label>' +
'<label><input type="checkbox" id="aclWrite"> write</label>' +
'<label><input type="checkbox" id="aclManage"> manage</label></div>',
actions: [
{ label: 'Cancel', cls: 'dash-btn', onclick: DMS.closeModal },
{ label: 'Add', cls: 'dash-btn dash-btn--primary', onclick: async () => {
const type = document.getElementById('aclType').value;
const body = { folder_id: folderId,
can_read: document.getElementById('aclRead').checked,
can_write: document.getElementById('aclWrite').checked,
can_manage: document.getElementById('aclManage').checked,
};
if (type === 'role') body.min_role = document.getElementById('aclRole').value;
else body.user_id = Number(document.getElementById('aclUser').value || 0);
try {
await DMS.api('/folders.php?action=set_permission', { method: 'POST', body });
DMS.closeModal();
showDetail(folderId);
} catch (e) { alert(e.message); }
}},
],
});
document.getElementById('aclType').addEventListener('change', e => {
document.getElementById('aclRoleField').style.display = e.target.value === 'role' ? '' : 'none';
document.getElementById('aclUserField').style.display = e.target.value === 'user' ? '' : 'none';
});
}
document.addEventListener('dms:folder-changed', e => showDetail(e.detail.folderId));
document.addEventListener('dms:reload-required', loadTree);
document.addEventListener('dms:reload-tree', loadTree);
document.getElementById('folderNewBtn').addEventListener('click', () => DMS.folderModal.open({}));
loadTree();
})();
</script>
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>