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:
+177
-172
@@ -6,27 +6,48 @@ $dashboardTitle = dbnToolsT('dash_title_docs', dbnToolsCurrentLanguage());
|
||||
$dashboardLead = dbnToolsT('dash_lead_docs', dbnToolsCurrentLanguage());
|
||||
require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
?>
|
||||
<section class="dash-card">
|
||||
<div class="dash-filters">
|
||||
<input type="search" id="docFilterQ" placeholder="<?= htmlspecialchars(dbnToolsT('dash_filter_q_ph', $uiLang)) ?>" autocomplete="off">
|
||||
<select id="docFilterStatus">
|
||||
<option value=""><?= htmlspecialchars(dbnToolsT('dash_filter_all_status', $uiLang)) ?></option>
|
||||
<option value="ready" id="optReady"></option>
|
||||
<option value="pending" id="optPending"></option>
|
||||
<option value="processing" id="optProcessing"></option>
|
||||
<option value="error" id="optError"></option>
|
||||
</select>
|
||||
<button id="docBulkDelete" class="dash-btn dash-btn--danger" disabled><?= htmlspecialchars(dbnToolsT('dash_delete_selected', $uiLang)) ?></button>
|
||||
<a href="/dashboard/upload.php" class="dash-btn dash-btn--primary" style="margin-left: auto;"><?= htmlspecialchars(dbnToolsT('dash_upload_btn_short', $uiLang)) ?></a>
|
||||
</div>
|
||||
<section class="dms-shell">
|
||||
<aside class="dms-tree" id="dmsTree" aria-label="Folder tree">
|
||||
<div class="dms-loading"></div>
|
||||
</aside>
|
||||
|
||||
<div id="docListWrap"><p class="dash-loading"></p></div>
|
||||
<div class="dms-main">
|
||||
<div class="dms-toolbar">
|
||||
<div class="dms-toolbar__crumbs" id="dmsCrumbs">
|
||||
<a href="#" data-folder-id="all">🗂 <?= htmlspecialchars(dbnToolsT('dash_dms_all_files', $uiLang) ?: 'All files') ?></a>
|
||||
</div>
|
||||
<div class="dms-toolbar__actions">
|
||||
<button class="dash-btn" id="dmsSaveSearch" type="button">★ <?= htmlspecialchars(dbnToolsT('dash_dms_save_view', $uiLang) ?: 'Save view') ?></button>
|
||||
<a href="/dashboard/upload.php" class="dash-btn dash-btn--primary">+ <?= htmlspecialchars(dbnToolsT('dash_upload_btn_short', $uiLang)) ?></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dash-pager" id="docPager" hidden>
|
||||
<span id="docPagerLabel"></span>
|
||||
<div class="dash-pager__actions">
|
||||
<button class="dash-btn" id="docPagerPrev"><?= htmlspecialchars(dbnToolsT('dash_prev', $uiLang)) ?></button>
|
||||
<button class="dash-btn" id="docPagerNext"><?= htmlspecialchars(dbnToolsT('dash_next', $uiLang)) ?></button>
|
||||
<div class="dms-filters">
|
||||
<input type="search" id="dmsFilterQ" placeholder="<?= htmlspecialchars(dbnToolsT('dash_filter_q_ph', $uiLang)) ?>" autocomplete="off">
|
||||
<select id="dmsFilterStatus">
|
||||
<option value=""><?= htmlspecialchars(dbnToolsT('dash_filter_all_status', $uiLang)) ?></option>
|
||||
<option value="ready">Ready</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
<select id="dmsFilterCategory">
|
||||
<option value=""><?= htmlspecialchars(dbnToolsT('dash_dms_all_categories', $uiLang) ?: 'All categories') ?></option>
|
||||
</select>
|
||||
<label style="font-size:13px;display:inline-flex;align-items:center;gap:4px;">
|
||||
<input type="checkbox" id="dmsIncludeSub" checked>
|
||||
<?= htmlspecialchars(dbnToolsT('dash_dms_include_sub', $uiLang) ?: 'Include subfolders') ?>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="dmsListWrap"><div class="dms-loading"></div></div>
|
||||
|
||||
<div class="dash-pager" id="dmsPager" hidden>
|
||||
<span id="dmsPagerLabel"></span>
|
||||
<div class="dash-pager__actions">
|
||||
<button class="dash-btn" id="dmsPagerPrev"><?= htmlspecialchars(dbnToolsT('dash_prev', $uiLang)) ?></button>
|
||||
<button class="dash-btn" id="dmsPagerNext"><?= htmlspecialchars(dbnToolsT('dash_next', $uiLang)) ?></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -34,177 +55,161 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
const I18N = window.DBN_I18N || {};
|
||||
const api = window.DBN_DASHBOARD.apiBase;
|
||||
const loc = I18N.locale || 'en-GB';
|
||||
const PAGE = 25;
|
||||
if (!window.DBN_DMS) { console.error('dms.js missing'); return; }
|
||||
const DMS = window.DBN_DMS;
|
||||
|
||||
const optReady = document.getElementById('optReady');
|
||||
const optPend = document.getElementById('optPending');
|
||||
const optProc = document.getElementById('optProcessing');
|
||||
const optErr = document.getElementById('optError');
|
||||
if (optReady) optReady.textContent = I18N.status_ready || 'Ready';
|
||||
if (optPend) optPend.textContent = I18N.status_pending || 'Pending';
|
||||
if (optProc) optProc.textContent = I18N.status_processing || 'Processing';
|
||||
if (optErr) optErr.textContent = I18N.status_error || 'Error';
|
||||
const $tree = document.getElementById('dmsTree');
|
||||
const $list = document.getElementById('dmsListWrap');
|
||||
const $crumbs = document.getElementById('dmsCrumbs');
|
||||
const $pager = document.getElementById('dmsPager');
|
||||
const $pl = document.getElementById('dmsPagerLabel');
|
||||
const $prev = document.getElementById('dmsPagerPrev');
|
||||
const $next = document.getElementById('dmsPagerNext');
|
||||
const $fq = document.getElementById('dmsFilterQ');
|
||||
const $fs = document.getElementById('dmsFilterStatus');
|
||||
const $fc = document.getElementById('dmsFilterCategory');
|
||||
const $sub = document.getElementById('dmsIncludeSub');
|
||||
const $save = document.getElementById('dmsSaveSearch');
|
||||
|
||||
const state = { offset: 0, total: 0, selected: new Set(), q: '', status: '' };
|
||||
let debounce;
|
||||
|
||||
const $wrap = document.getElementById('docListWrap');
|
||||
const $pager = document.getElementById('docPager');
|
||||
const $pl = document.getElementById('docPagerLabel');
|
||||
const $prev = document.getElementById('docPagerPrev');
|
||||
const $next = document.getElementById('docPagerNext');
|
||||
const $bulk = document.getElementById('docBulkDelete');
|
||||
const $fq = document.getElementById('docFilterQ');
|
||||
const $fs = document.getElementById('docFilterStatus');
|
||||
|
||||
function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[c])); }
|
||||
function fmtDate(s) {
|
||||
if (!s) return '—';
|
||||
try { return new Date(s.replace(' ', 'T') + 'Z').toLocaleDateString(loc, { day:'numeric', month:'short', year:'numeric' }); }
|
||||
catch (_) { return s; }
|
||||
}
|
||||
function fmtNum(n) { return n == null ? '—' : Number(n).toLocaleString(loc); }
|
||||
function statusPill(status) {
|
||||
const cls = { ready:'dash-status--ready', pending:'dash-status--pending', processing:'dash-status--processing', error:'dash-status--error' }[status] || 'dash-status--pending';
|
||||
const lbl = {
|
||||
ready: I18N.status_ready || 'Ready', pending: I18N.status_pending || 'Pending',
|
||||
processing: I18N.status_processing || 'Processing', error: I18N.status_error || 'Error',
|
||||
}[status] || status;
|
||||
return '<span class="dash-status ' + cls + '">' + lbl + '</span>';
|
||||
}
|
||||
|
||||
function load() {
|
||||
const qs = new URLSearchParams({ action:'list', offset:String(state.offset), limit:String(PAGE) });
|
||||
if (state.q) qs.set('q', state.q);
|
||||
if (state.status) qs.set('status', state.status);
|
||||
|
||||
$wrap.innerHTML = '<p class="dash-loading">' + (I18N.loading_docs || 'Loading documents…') + '</p>';
|
||||
fetch(api + '/documents.php?' + qs, { credentials:'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data.ok) throw new Error(data.error?.message || 'Failed');
|
||||
state.total = data.total;
|
||||
render(data.documents || []);
|
||||
})
|
||||
.catch(err => {
|
||||
$wrap.innerHTML = '<div class="dash-error">' + safe(err.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function render(docs) {
|
||||
if (!docs.length) {
|
||||
$wrap.innerHTML = '<div class="dash-empty"><span class="dash-empty__icon">📭</span>'
|
||||
+ (state.q || state.status
|
||||
? (I18N.empty_filter || 'No results for selected filter.')
|
||||
: (I18N.empty_docs || 'No documents yet.') + ' <a href="/dashboard/upload.php">' + (I18N.empty_docs_link || 'Upload your first') + '</a>.')
|
||||
+ '</div>';
|
||||
$pager.hidden = true;
|
||||
return;
|
||||
async function loadAll() {
|
||||
$list.innerHTML = '<div class="dms-loading"></div>';
|
||||
try {
|
||||
await DMS.Tree.load();
|
||||
DMS.Tree.render($tree, { activeFolderId: DMS.List.state.folderId });
|
||||
await DMS.Smart.load();
|
||||
await refreshCategories();
|
||||
await refreshList();
|
||||
} catch (e) {
|
||||
$list.innerHTML = '<div class="dms-list__empty"><strong>Error</strong><span>' + DMS.safe(e.message) + '</span></div>';
|
||||
}
|
||||
}
|
||||
|
||||
const table = document.createElement('table');
|
||||
table.className = 'dash-doctable';
|
||||
table.innerHTML =
|
||||
'<thead><tr>'
|
||||
+ '<th style="width:36px;"><input type="checkbox" id="docSelectAll"></th>'
|
||||
+ '<th>' + (I18N.th_title || 'Title') + '</th>'
|
||||
+ '<th>' + (I18N.th_category || 'Category') + '</th>'
|
||||
+ '<th>' + (I18N.th_status || 'Status') + '</th>'
|
||||
+ '<th>' + (I18N.th_chunks || 'Passages') + '</th>'
|
||||
+ '<th>' + (I18N.th_added || 'Added') + '</th>'
|
||||
+ '</tr></thead>';
|
||||
const tbody = document.createElement('tbody');
|
||||
async function refreshCategories() {
|
||||
try {
|
||||
const data = await DMS.api('/categories.php?action=list');
|
||||
const opts = ['<option value="">All categories</option>'].concat(
|
||||
(data.categories || []).map(c => '<option value="' + DMS.safe(c.slug) + '">' + DMS.safe(c.label) + ' (' + c.doc_count + ')</option>')
|
||||
);
|
||||
const prev = $fc.value;
|
||||
$fc.innerHTML = opts.join('');
|
||||
$fc.value = prev;
|
||||
} catch (_) { /* ignored */ }
|
||||
}
|
||||
|
||||
docs.forEach(doc => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.id = String(doc.id);
|
||||
tr.innerHTML =
|
||||
'<td><input type="checkbox" class="doc-check" value="' + doc.id + '"' + (state.selected.has(doc.id) ? ' checked' : '') + '></td>'
|
||||
+ '<td><span class="dash-doctable__title">' + safe(doc.title) + '</span>'
|
||||
+ (doc.source_tool ? '<div class="dash-doctable__meta">via ' + safe(doc.source_tool) + (doc.tags ? ' · ' + safe(doc.tags) : '') + '</div>' : (doc.tags ? '<div class="dash-doctable__meta">' + safe(doc.tags) + '</div>' : ''))
|
||||
+ '</td>'
|
||||
+ '<td>' + safe(doc.category || '—') + '</td>'
|
||||
+ '<td>' + statusPill(doc.status) + '</td>'
|
||||
+ '<td>' + fmtNum(doc.chunk_count) + '</td>'
|
||||
+ '<td>' + fmtDate(doc.created_at) + '</td>';
|
||||
tr.addEventListener('click', (e) => {
|
||||
if (e.target.matches('input[type="checkbox"]')) return;
|
||||
location.href = '/dashboard/document.php?id=' + doc.id;
|
||||
async function refreshList() {
|
||||
try {
|
||||
const data = await DMS.List.load();
|
||||
DMS.List.render($list);
|
||||
updateCrumbs(data.folder);
|
||||
updatePager(data);
|
||||
} catch (e) {
|
||||
$list.innerHTML = '<div class="dms-list__empty"><strong>Error</strong><span>' + DMS.safe(e.message) + '</span></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function updateCrumbs(folder) {
|
||||
let html = '<a href="#" data-folder-id="all">🗂 All files</a>';
|
||||
if (folder && folder.breadcrumb) {
|
||||
folder.breadcrumb.forEach((b, i) => {
|
||||
const isLast = i === folder.breadcrumb.length - 1;
|
||||
html += '<span class="dms-toolbar__crumb-sep">›</span>';
|
||||
if (isLast) html += '<span class="dms-toolbar__crumb--current">' + DMS.safe(b.name) + '</span>';
|
||||
else html += '<a href="#" data-folder-id="' + b.id + '">' + DMS.safe(b.name) + '</a>';
|
||||
});
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
$wrap.innerHTML = '';
|
||||
$wrap.appendChild(table);
|
||||
} else if (DMS.List.state.folderId === 'unassigned') {
|
||||
html += '<span class="dms-toolbar__crumb-sep">›</span><span class="dms-toolbar__crumb--current">Unassigned</span>';
|
||||
}
|
||||
$crumbs.innerHTML = html;
|
||||
}
|
||||
|
||||
const all = document.getElementById('docSelectAll');
|
||||
all.addEventListener('change', () => {
|
||||
tbody.querySelectorAll('.doc-check').forEach(c => {
|
||||
c.checked = all.checked;
|
||||
const id = parseInt(c.value, 10);
|
||||
if (all.checked) state.selected.add(id); else state.selected.delete(id);
|
||||
});
|
||||
updateBulk();
|
||||
});
|
||||
tbody.querySelectorAll('.doc-check').forEach(c => {
|
||||
c.addEventListener('change', (e) => {
|
||||
const id = parseInt(e.target.value, 10);
|
||||
if (e.target.checked) state.selected.add(id); else state.selected.delete(id);
|
||||
updateBulk();
|
||||
});
|
||||
});
|
||||
|
||||
const from = state.offset + 1;
|
||||
const to = Math.min(state.offset + docs.length, state.total);
|
||||
const tmpl = I18N.pager_showing || 'Showing {from}–{to} of {total}';
|
||||
$pl.textContent = tmpl.replace('{from}', from).replace('{to}', to).replace('{total}', state.total);
|
||||
$prev.disabled = state.offset === 0;
|
||||
$next.disabled = state.offset + PAGE >= state.total;
|
||||
function updatePager(data) {
|
||||
const total = data.total || 0;
|
||||
const limit = DMS.List.state.limit;
|
||||
const offset = DMS.List.state.offset;
|
||||
if (total <= limit) { $pager.hidden = true; return; }
|
||||
$pager.hidden = false;
|
||||
$pl.textContent = (offset + 1) + '–' + Math.min(total, offset + limit) + ' / ' + total;
|
||||
$prev.disabled = offset === 0;
|
||||
$next.disabled = offset + limit >= total;
|
||||
}
|
||||
|
||||
function updateBulk() {
|
||||
$bulk.disabled = state.selected.size === 0;
|
||||
$bulk.textContent = state.selected.size > 0
|
||||
? (I18N.delete_n_selected || 'Delete selected ({n})').replace('{n}', state.selected.size)
|
||||
: (I18N.delete_selected || 'Delete selected');
|
||||
}
|
||||
document.addEventListener('dms:folder-changed', e => {
|
||||
const id = e.detail.folderId;
|
||||
if (id === 'trash') { window.location.href = '/dashboard/trash.php'; return; }
|
||||
DMS.List.state.folderId = id;
|
||||
DMS.List.state.offset = 0;
|
||||
DMS.List.state.selected.clear();
|
||||
DMS.Tree.state.activeFolderId = id;
|
||||
DMS.Tree.render($tree, { activeFolderId: id });
|
||||
refreshList();
|
||||
});
|
||||
document.addEventListener('dms:reload-required', () => {
|
||||
DMS.Tree.load().then(() => DMS.Tree.render($tree, { activeFolderId: DMS.List.state.folderId }));
|
||||
refreshList();
|
||||
});
|
||||
document.addEventListener('dms:reload-tree', () => {
|
||||
DMS.Tree.load().then(() => DMS.Tree.render($tree, { activeFolderId: DMS.List.state.folderId }));
|
||||
});
|
||||
document.addEventListener('dms:apply-smart', e => {
|
||||
const q = e.detail || {};
|
||||
if ('q' in q) $fq.value = q.q || '';
|
||||
if ('status' in q) $fs.value = q.status || '';
|
||||
if ('category' in q) $fc.value = q.category || '';
|
||||
if ('folder_id' in q) DMS.List.state.folderId = String(q.folder_id == null ? 'all' : q.folder_id);
|
||||
if ('include_subfolders' in q) $sub.checked = !!q.include_subfolders;
|
||||
DMS.List.state.q = $fq.value;
|
||||
DMS.List.state.status = $fs.value;
|
||||
DMS.List.state.category = $fc.value;
|
||||
DMS.List.state.includeSubfolders = $sub.checked;
|
||||
DMS.List.state.offset = 0;
|
||||
refreshList();
|
||||
});
|
||||
|
||||
$prev.addEventListener('click', () => { state.offset = Math.max(0, state.offset - PAGE); load(); });
|
||||
$next.addEventListener('click', () => { state.offset += PAGE; load(); });
|
||||
$crumbs.addEventListener('click', e => {
|
||||
const a = e.target.closest('a[data-folder-id]');
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
DMS.List.state.folderId = a.dataset.folderId;
|
||||
DMS.List.state.offset = 0;
|
||||
DMS.Tree.state.activeFolderId = a.dataset.folderId;
|
||||
DMS.Tree.render($tree, { activeFolderId: a.dataset.folderId });
|
||||
refreshList();
|
||||
});
|
||||
|
||||
let filterTimer = null;
|
||||
$fq.addEventListener('input', () => {
|
||||
clearTimeout(filterTimer);
|
||||
filterTimer = setTimeout(() => { state.q = $fq.value.trim(); state.offset = 0; load(); }, 250);
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(() => {
|
||||
DMS.List.state.q = $fq.value.trim();
|
||||
DMS.List.state.offset = 0;
|
||||
refreshList();
|
||||
}, 200);
|
||||
});
|
||||
$fs.addEventListener('change', () => { state.status = $fs.value; state.offset = 0; load(); });
|
||||
$fs.addEventListener('change', () => { DMS.List.state.status = $fs.value; DMS.List.state.offset = 0; refreshList(); });
|
||||
$fc.addEventListener('change', () => { DMS.List.state.category = $fc.value; DMS.List.state.offset = 0; refreshList(); });
|
||||
$sub.addEventListener('change', () => { DMS.List.state.includeSubfolders = $sub.checked; refreshList(); });
|
||||
|
||||
$bulk.addEventListener('click', () => {
|
||||
if (!state.selected.size) return;
|
||||
const msg = (I18N.delete_docs_confirm || 'Delete {n} documents? This cannot be undone.').replace('{n}', state.selected.size);
|
||||
if (!confirm(msg)) return;
|
||||
const ids = Array.from(state.selected);
|
||||
$bulk.disabled = true;
|
||||
fetch(api + '/documents.php?action=delete', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data.ok) throw new Error(data.error?.message || 'Delete failed');
|
||||
state.selected.clear();
|
||||
updateBulk();
|
||||
load();
|
||||
})
|
||||
.catch(err => alert(err.message));
|
||||
$prev.addEventListener('click', () => { DMS.List.state.offset = Math.max(0, DMS.List.state.offset - DMS.List.state.limit); refreshList(); });
|
||||
$next.addEventListener('click', () => { DMS.List.state.offset += DMS.List.state.limit; refreshList(); });
|
||||
|
||||
$save.addEventListener('click', () => {
|
||||
DMS.Smart.save({
|
||||
q: $fq.value, status: $fs.value, category: $fc.value,
|
||||
folder_id: DMS.List.state.folderId,
|
||||
include_subfolders: $sub.checked,
|
||||
});
|
||||
});
|
||||
|
||||
load();
|
||||
DMS.installDropAnywhereUpload({ getFolderId: () => {
|
||||
const id = DMS.List.state.folderId;
|
||||
return (id && id !== 'all' && id !== 'unassigned' && id !== 'trash') ? id : null;
|
||||
}});
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('folder')) DMS.List.state.folderId = params.get('folder');
|
||||
|
||||
loadAll();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user