2e2b0b45fa
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>
272 lines
15 KiB
PHP
272 lines
15 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
require_once __DIR__ . '/../includes/bootstrap.php';
|
|
$dashboardPage = 'upload';
|
|
$dashboardTitle = dbnToolsT('dash_title_upload', dbnToolsCurrentLanguage());
|
|
$dashboardLead = dbnToolsT('dash_lead_upload', dbnToolsCurrentLanguage());
|
|
require_once __DIR__ . '/../includes/layout_dashboard.php';
|
|
?>
|
|
<section class="dash-card">
|
|
<div class="dash-card__head">
|
|
<h2><?= htmlspecialchars(dbnToolsT('dash_upload_source', $uiLang)) ?></h2>
|
|
<div class="dash-card__actions">
|
|
<button class="dash-btn upload-mode is-active" data-mode="file"><?= htmlspecialchars(dbnToolsT('dash_upload_file_btn', $uiLang)) ?></button>
|
|
<button class="dash-btn upload-mode" data-mode="text"><?= htmlspecialchars(dbnToolsT('dash_upload_text_btn', $uiLang)) ?></button>
|
|
<button class="dash-btn upload-mode" data-mode="url">URL</button>
|
|
</div>
|
|
</div>
|
|
|
|
<form id="upFileForm" class="upload-form" enctype="multipart/form-data" style="display:grid; gap:0.85rem;">
|
|
<label class="upload-drop" id="upDrop">
|
|
<input type="file" name="file" id="upFile" accept=".pdf,.docx,.txt,.md,.html,.htm,.csv,.xlsx,.pptx,.json" hidden>
|
|
<span class="upload-drop__icon">📥</span>
|
|
<strong id="upDropHint"><?= htmlspecialchars(dbnToolsT('dash_upload_drop_strong', $uiLang)) ?></strong>
|
|
<small><?= htmlspecialchars(dbnToolsT('dash_upload_drop_small', $uiLang)) ?></small>
|
|
<div style="margin-top:10px;display:flex;gap:6px;flex-wrap:wrap;">
|
|
<span class="dms-chip">PDF</span><span class="dms-chip">DOCX</span><span class="dms-chip">TXT</span>
|
|
<span class="dms-chip">MD</span><span class="dms-chip">HTML</span><span class="dms-chip">CSV</span>
|
|
<span class="dms-chip">XLSX</span><span class="dms-chip">PPTX</span><span class="dms-chip">JSON</span>
|
|
</div>
|
|
</label>
|
|
<label style="display:grid;gap:4px;font-size:13px;">
|
|
<span>📁 Destination folder</span>
|
|
<select name="folder_id" id="upFolderSel"><option value="">(Unassigned)</option></select>
|
|
</label>
|
|
<div class="upload-meta">
|
|
<label><?= htmlspecialchars(dbnToolsT('dash_upload_title_lbl', $uiLang)) ?><input name="title" placeholder="<?= htmlspecialchars(dbnToolsT('dash_upload_title_ph', $uiLang)) ?>"></label>
|
|
<label><?= htmlspecialchars(dbnToolsT('dash_upload_category_lbl', $uiLang)) ?><input name="category" placeholder="<?= htmlspecialchars(dbnToolsT('dash_upload_category_ph', $uiLang)) ?>"></label>
|
|
<label><?= htmlspecialchars(dbnToolsT('dash_upload_tags_lbl', $uiLang)) ?><input name="tags" placeholder="comma,separated,list"></label>
|
|
<label><?= htmlspecialchars(dbnToolsT('dash_upload_lang_lbl', $uiLang)) ?><input name="language" value="no" maxlength="10"></label>
|
|
</div>
|
|
<button type="submit" class="dash-btn dash-btn--primary" style="justify-self:start;"><?= htmlspecialchars(dbnToolsT('dash_upload_btn_file', $uiLang)) ?></button>
|
|
</form>
|
|
|
|
<form id="upTextForm" class="upload-form" hidden style="display:grid; gap:0.85rem;">
|
|
<label><?= htmlspecialchars(dbnToolsT('dash_upload_title_lbl', $uiLang)) ?><input name="title" required placeholder="<?= htmlspecialchars(dbnToolsT('dash_upload_note_title_ph', $uiLang)) ?>"></label>
|
|
<label><?= htmlspecialchars(dbnToolsT('dash_upload_content_lbl', $uiLang)) ?><textarea name="content" rows="12" required placeholder="<?= htmlspecialchars(dbnToolsT('dash_upload_content_ph', $uiLang)) ?>"></textarea></label>
|
|
<div class="upload-meta">
|
|
<label><?= htmlspecialchars(dbnToolsT('dash_upload_tags_lbl', $uiLang)) ?><input name="category" placeholder="e.g. note"></label>
|
|
<label>Tags<input name="tags"></label>
|
|
<label><?= htmlspecialchars(dbnToolsT('dash_upload_lang_lbl', $uiLang)) ?><input name="language" value="no" maxlength="10"></label>
|
|
</div>
|
|
<button type="submit" class="dash-btn dash-btn--primary" style="justify-self:start;"><?= htmlspecialchars(dbnToolsT('dash_upload_btn_text', $uiLang)) ?></button>
|
|
</form>
|
|
|
|
<form id="upUrlForm" class="upload-form" hidden style="display:grid; gap:0.85rem;">
|
|
<label>URL<input name="url" type="url" required placeholder="<?= htmlspecialchars(dbnToolsT('dash_upload_url_ph', $uiLang)) ?>"></label>
|
|
<label><?= htmlspecialchars(dbnToolsT('dash_upload_title_lbl', $uiLang)) ?><input name="title" placeholder="<?= htmlspecialchars(dbnToolsT('dash_upload_url_title_ph', $uiLang)) ?>"></label>
|
|
<div class="upload-meta">
|
|
<label>Category<input name="category"></label>
|
|
<label>Tags<input name="tags"></label>
|
|
<label><?= htmlspecialchars(dbnToolsT('dash_upload_lang_lbl', $uiLang)) ?><input name="language" value="no" maxlength="10"></label>
|
|
</div>
|
|
<button type="submit" class="dash-btn dash-btn--primary" style="justify-self:start;"><?= htmlspecialchars(dbnToolsT('dash_upload_btn_url', $uiLang)) ?></button>
|
|
<small style="color:rgba(22,19,15,0.55);"><?= htmlspecialchars(dbnToolsT('dash_upload_url_note', $uiLang)) ?></small>
|
|
</form>
|
|
|
|
<div id="upStatus" class="upload-status" hidden></div>
|
|
</section>
|
|
|
|
<style>
|
|
.upload-mode { background: #fff; }
|
|
.upload-mode.is-active { background: var(--dbn-blue); color: #fff; border-color: var(--dbn-blue); }
|
|
.upload-drop {
|
|
display: grid; gap: 0.4rem; place-items: center; text-align: center;
|
|
padding: 2.5rem 1rem; border: 2px dashed var(--dbn-line); border-radius: var(--dash-radius);
|
|
cursor: pointer; background: #fcfaf5; transition: border-color 150ms, background 150ms;
|
|
}
|
|
.upload-drop:hover, .upload-drop.is-drag { border-color: var(--dbn-blue); background: #fff; }
|
|
.upload-drop__icon { font-size: 2.5rem; opacity: 0.5; }
|
|
.upload-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.85rem; }
|
|
.upload-form label { display: grid; gap: 0.25rem; font-size: 0.85rem; color: rgba(22, 19, 15, 0.7); }
|
|
.upload-form input, .upload-form textarea {
|
|
padding: 0.55rem 0.7rem; border: 1px solid var(--dbn-line); border-radius: var(--dash-radius-sm);
|
|
font: inherit; background: #fff; width: 100%;
|
|
}
|
|
.upload-form textarea { font-family: "IBM Plex Sans", system-ui, sans-serif; resize: vertical; min-height: 8rem; }
|
|
.upload-status {
|
|
margin-top: 1.25rem; padding: 0.85rem 1rem; border-radius: var(--dash-radius-sm);
|
|
background: #fcfaf5; border: 1px solid var(--dbn-line); font-size: 0.9rem;
|
|
}
|
|
.upload-status--ok { border-color: rgba(15, 118, 110, 0.4); background: rgba(15, 118, 110, 0.05); }
|
|
.upload-status--err { border-color: rgba(186, 12, 47, 0.4); background: rgba(186, 12, 47, 0.05); color: var(--dbn-red); }
|
|
</style>
|
|
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
const I18N = window.DBN_I18N || {};
|
|
const api = window.DBN_DASHBOARD.apiBase;
|
|
const dropHintDefault = I18N.upload_drop_hint || 'Drop file here, or click to browse';
|
|
|
|
const forms = {
|
|
file: document.getElementById('upFileForm'),
|
|
text: document.getElementById('upTextForm'),
|
|
url: document.getElementById('upUrlForm'),
|
|
};
|
|
const status = document.getElementById('upStatus');
|
|
|
|
document.querySelectorAll('.upload-mode').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.upload-mode').forEach(b => b.classList.remove('is-active'));
|
|
btn.classList.add('is-active');
|
|
const mode = btn.dataset.mode;
|
|
for (const m in forms) forms[m].hidden = (m !== mode);
|
|
status.hidden = true;
|
|
});
|
|
});
|
|
|
|
const drop = document.getElementById('upDrop');
|
|
const fileInput = document.getElementById('upFile');
|
|
const dropHintEl = document.getElementById('upDropHint');
|
|
if (drop && fileInput) {
|
|
drop.addEventListener('click', () => fileInput.click());
|
|
['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, e => {
|
|
e.preventDefault(); drop.classList.add('is-drag');
|
|
}));
|
|
['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, e => {
|
|
e.preventDefault(); drop.classList.remove('is-drag');
|
|
}));
|
|
drop.addEventListener('drop', e => {
|
|
if (e.dataTransfer.files.length) {
|
|
fileInput.files = e.dataTransfer.files;
|
|
if (dropHintEl) dropHintEl.textContent = e.dataTransfer.files[0].name;
|
|
}
|
|
});
|
|
fileInput.addEventListener('change', () => {
|
|
if (fileInput.files.length && dropHintEl) {
|
|
dropHintEl.textContent = fileInput.files[0].name;
|
|
}
|
|
});
|
|
}
|
|
|
|
function setStatus(html, kind) {
|
|
status.hidden = false;
|
|
status.className = 'upload-status' + (kind ? ' upload-status--' + kind : '');
|
|
status.innerHTML = html;
|
|
}
|
|
|
|
function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[c])); }
|
|
|
|
// Populate folder picker from /api/dashboard/folders.php
|
|
(async function loadFolders() {
|
|
try {
|
|
const data = await fetch(api + '/folders.php?action=list_tree', { credentials: 'same-origin' }).then(r => r.json());
|
|
const sel = document.getElementById('upFolderSel');
|
|
if (!sel || !data.tree) return;
|
|
const flat = [];
|
|
const walk = (nodes, prefix) => {
|
|
nodes.forEach(n => {
|
|
flat.push({ id: n.id, label: prefix + n.name });
|
|
if (n.children && n.children.length) walk(n.children, prefix + n.name + ' / ');
|
|
});
|
|
};
|
|
walk(data.tree || [], '');
|
|
const opts = ['<option value="">(Unassigned)</option>'].concat(
|
|
flat.map(f => '<option value="' + f.id + '">' + safe(f.label) + '</option>')
|
|
);
|
|
sel.innerHTML = opts.join('');
|
|
// Preselect from ?folder=N
|
|
const initial = new URLSearchParams(location.search).get('folder');
|
|
if (initial) sel.value = initial;
|
|
} catch (_) { /* ignored */ }
|
|
})();
|
|
|
|
async function postFile(versionAction) {
|
|
const fd = new FormData(forms.file);
|
|
if (versionAction) fd.set('version_action', versionAction);
|
|
const res = await fetch(api + '/upload.php', { method: 'POST', credentials: 'same-origin', body: fd });
|
|
const json = await res.json();
|
|
if (res.status === 409 && json && json.collision) {
|
|
const action = await (window.DBN_DMS ? DBN_DMS.chooseCollisionAction(fileInput.files[0]?.name || '') : null);
|
|
if (action) return postFile(action);
|
|
setStatus('Cancelled.', 'err'); return null;
|
|
}
|
|
return json;
|
|
}
|
|
|
|
forms.file.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
if (!fileInput.files.length) { setStatus(I18N.upload_select_file || 'Select a file first.', 'err'); return; }
|
|
setStatus(I18N.upload_indexing || 'Uploading and indexing…');
|
|
try {
|
|
const data = await postFile();
|
|
if (data) handleResult(data);
|
|
} catch (err) { setStatus('❌ ' + safe(err.message), 'err'); }
|
|
});
|
|
|
|
forms.text.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
const payload = { kind: 'text' };
|
|
new FormData(forms.text).forEach((v, k) => payload[k] = v);
|
|
setStatus(I18N.upload_saving || 'Indexing…');
|
|
fetch(api + '/upload.php', {
|
|
method: 'POST', credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
}).then(r => r.json()).then(handleResult).catch(err => setStatus('❌ ' + safe(err.message), 'err'));
|
|
});
|
|
|
|
forms.url.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
const payload = { kind: 'url' };
|
|
new FormData(forms.url).forEach((v, k) => payload[k] = v);
|
|
setStatus(I18N.upload_queuing || 'Queuing URL for fetching…');
|
|
fetch(api + '/upload.php', {
|
|
method: 'POST', credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
}).then(r => r.json()).then(handleResult).catch(err => setStatus('❌ ' + safe(err.message), 'err'));
|
|
});
|
|
|
|
function handleResult(data) {
|
|
if (data.status === 'ready') {
|
|
const msg = (I18N.upload_indexed || '✅ Indexed! {n} passages added.').replace('{n}', data.chunks || 0);
|
|
setStatus(msg
|
|
+ ' <a href="/dashboard/document.php?id=' + data.document_id + '">' + (I18N.upload_open_doc || 'Open document') + '</a>'
|
|
+ ' · <a href="/dashboard/documents.php">' + (I18N.see_all || 'See all') + '</a>', 'ok');
|
|
['file', 'text', 'url'].forEach(m => forms[m].reset());
|
|
if (dropHintEl) dropHintEl.textContent = dropHintDefault;
|
|
} else if (data.status === 'pending') {
|
|
setStatus((I18N.upload_queued || '📥 Queued.')
|
|
+ ' <a href="/dashboard/documents.php">' + (I18N.upload_follow_docs || 'Follow progress in Documents') + '</a>.', 'ok');
|
|
pollUntilDone(data.document_id);
|
|
} else if (data.status === 'error') {
|
|
setStatus('❌ ' + safe(data.error?.message || 'Indexing failed.'), 'err');
|
|
} else if (!data.ok) {
|
|
setStatus('❌ ' + safe(data.error?.message || 'Upload failed.'), 'err');
|
|
} else {
|
|
setStatus('Unexpected response.', 'err');
|
|
}
|
|
}
|
|
|
|
function pollUntilDone(docId) {
|
|
let tries = 0;
|
|
const tick = () => {
|
|
if (++tries > 40) return;
|
|
fetch(api + '/ingest-status.php?ids=' + docId, { credentials: 'same-origin' })
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const s = (data.statuses || []).find(x => x.id === docId);
|
|
if (!s) return;
|
|
if (s.status === 'ready') {
|
|
const msg = (I18N.upload_bg_done || '✅ Background job done. {n} passages indexed.').replace('{n}', s.chunk_count);
|
|
setStatus(msg
|
|
+ ' <a href="/dashboard/document.php?id=' + docId + '">' + (I18N.upload_open_doc || 'Open document') + '</a>', 'ok');
|
|
return;
|
|
}
|
|
if (s.status === 'error') {
|
|
setStatus('❌ ' + safe(s.error_message || 'Background job failed.'), 'err');
|
|
return;
|
|
}
|
|
setTimeout(tick, 3000);
|
|
})
|
|
.catch(() => setTimeout(tick, 4000));
|
|
};
|
|
setTimeout(tick, 3000);
|
|
}
|
|
})();
|
|
</script>
|
|
|
|
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
|