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
+183
View File
@@ -0,0 +1,183 @@
<?php
/**
* /api/dashboard/bulk.php — bulk operations on documents.
*
* POST body: { op: "move"|"retag"|"recategorize"|"trash"|"restore",
* ids: [1,2,3,...],
* ...op-specific args }
*
* Op args:
* move: { folder_id } — null/0 = unassigned
* retag: { tags: "a,b,c", mode: "replace"|"append"|"remove" }
* recategorize: { category: "slug" }
* trash: {} — soft delete
* restore: {} — un-trash
*
* Max 500 IDs per call.
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
try {
$tenant = dbnToolsEnsureDashboardTenant();
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
}
$clientId = (int)$tenant['client_id'];
$userId = (int)($tenant['client_user_id'] ?? 0);
$tenantRole = (string)($tenant['role'] ?? 'editor');
$db = dbnToolsDb();
$input = dbnToolsJsonInput(200_000);
$op = (string)($input['op'] ?? '');
$ids = $input['ids'] ?? [];
if (!is_array($ids) || !$ids) {
dbnToolsError('ids array is required.', 400, 'missing_ids');
}
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v) => $v > 0)));
if (!$ids) {
dbnToolsError('No valid ids.', 400, 'invalid_ids');
}
if (count($ids) > 500) {
dbnToolsError('Maximum 500 ids per bulk operation.', 422, 'too_many');
}
$ph = implode(',', array_fill(0, count($ids), '?'));
// Load + ACL-check
$rows = $db->prepare("SELECT id, folder_id FROM client_documents WHERE client_id = ? AND id IN ({$ph}) AND deleted_at IS NULL");
$rows->execute(array_merge([$clientId], $ids));
$allowedIds = [];
$perDoc = [];
foreach ($rows->fetchAll() as $r) {
$fid = $r['folder_id'] ? (int)$r['folder_id'] : 0;
if (dbnDmsUserCanAccessFolder($fid ?: null, 'write', $clientId, $userId, $tenantRole)) {
$allowedIds[] = (int)$r['id'];
$perDoc[(int)$r['id']] = $fid;
}
}
if (!$allowedIds) {
dbnToolsError('No accessible documents in selection.', 403, 'forbidden');
}
try {
switch ($op) {
case 'move': $result = opMove($db, $clientId, $userId, $tenantRole, $allowedIds, $input); break;
case 'retag': $result = opRetag($db, $clientId, $userId, $allowedIds, $input); break;
case 'recategorize': $result = opRecategorize($db, $clientId, $userId, $allowedIds, $input); break;
case 'trash': $result = opTrash($db, $clientId, $userId, $allowedIds); break;
case 'restore': $result = opRestore($db, $clientId, $userId, $allowedIds); break;
default:
dbnToolsError('Unknown op: ' . $op, 400, 'unknown_op');
}
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra);
} catch (Throwable $e) {
error_log('[dbn-dms/bulk] ' . $e->getMessage());
dbnToolsError('Bulk operation failed.', 500, 'bulk_failed');
}
dbnToolsRespond($result);
function opMove(PDO $db, int $clientId, int $userId, string $tenantRole, array $ids, array $input): array
{
$dest = $input['folder_id'] ?? null;
$destId = ($dest === null || $dest === '' || $dest === 'unassigned' || (int)$dest === 0) ? null : (int)$dest;
if ($destId !== null && !dbnDmsUserCanAccessFolder($destId, 'write', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden on destination folder.', 403, 'forbidden_dest');
}
if ($destId !== null) {
$check = $db->prepare('SELECT id FROM client_folders WHERE id = ? AND client_id = ? AND deleted_at IS NULL');
$check->execute([$destId, $clientId]);
if (!$check->fetchColumn()) {
dbnToolsError('Destination folder not found.', 404, 'folder_not_found');
}
}
$ph = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare("UPDATE client_documents SET folder_id = ?, updated_at = NOW() WHERE client_id = ? AND id IN ({$ph})");
$stmt->execute(array_merge([$destId, $clientId], $ids));
dbnDmsLogAudit($clientId, $userId ?: null, 'bulk_move', ['dest' => $destId, 'count' => count($ids), 'ids' => $ids]);
return ['ok' => true, 'op' => 'move', 'affected' => $stmt->rowCount()];
}
function opRetag(PDO $db, int $clientId, int $userId, array $ids, array $input): array
{
$mode = strtolower((string)($input['mode'] ?? 'replace'));
if (!in_array($mode, ['replace','append','remove'], true)) {
dbnToolsError('Invalid mode (replace|append|remove).', 422, 'invalid_mode');
}
$raw = (string)($input['tags'] ?? '');
$newTags = array_values(array_filter(array_map('trim', explode(',', $raw))));
$newTags = array_map(fn($t) => substr($t, 0, 32), $newTags);
$newTags = array_slice($newTags, 0, 20);
if ($mode === 'replace') {
$ph = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare("UPDATE client_documents SET tags = ?, updated_at = NOW() WHERE client_id = ? AND id IN ({$ph})");
$stmt->execute(array_merge([implode(',', $newTags), $clientId], $ids));
$affected = $stmt->rowCount();
} else {
// Per-row merge.
$affected = 0;
$ph = implode(',', array_fill(0, count($ids), '?'));
$cur = $db->prepare("SELECT id, tags FROM client_documents WHERE client_id = ? AND id IN ({$ph})");
$cur->execute(array_merge([$clientId], $ids));
$upd = $db->prepare("UPDATE client_documents SET tags = ?, updated_at = NOW() WHERE id = ? AND client_id = ?");
foreach ($cur->fetchAll() as $row) {
$existing = array_values(array_filter(array_map('trim', explode(',', (string)$row['tags']))));
if ($mode === 'append') {
$merged = array_values(array_unique(array_merge($existing, $newTags)));
} else { // remove
$merged = array_values(array_diff($existing, $newTags));
}
$merged = array_slice($merged, 0, 20);
$upd->execute([implode(',', $merged), (int)$row['id'], $clientId]);
$affected++;
}
}
dbnDmsLogAudit($clientId, $userId ?: null, 'bulk_retag', ['mode' => $mode, 'tags' => $newTags, 'count' => count($ids), 'ids' => $ids]);
return ['ok' => true, 'op' => 'retag', 'mode' => $mode, 'affected' => $affected];
}
function opRecategorize(PDO $db, int $clientId, int $userId, array $ids, array $input): array
{
$cat = strtolower(trim((string)($input['category'] ?? '')));
$cat = preg_replace('/[^a-z0-9\-_]/', '', $cat) ?: 'uncategorized';
$cat = substr($cat, 0, 50);
$ph = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare("UPDATE client_documents SET category = ?, updated_at = NOW() WHERE client_id = ? AND id IN ({$ph})");
$stmt->execute(array_merge([$cat, $clientId], $ids));
dbnDmsLogAudit($clientId, $userId ?: null, 'bulk_recategorize', ['category' => $cat, 'count' => count($ids), 'ids' => $ids]);
return ['ok' => true, 'op' => 'recategorize', 'category' => $cat, 'affected' => $stmt->rowCount()];
}
function opTrash(PDO $db, int $clientId, int $userId, array $ids): array
{
$ph = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare(
"UPDATE client_documents SET deleted_at = NOW(), deleted_by = ?, updated_at = NOW()
WHERE client_id = ? AND id IN ({$ph}) AND deleted_at IS NULL"
);
$stmt->execute(array_merge([$userId ?: null, $clientId], $ids));
dbnDmsLogAudit($clientId, $userId ?: null, 'bulk_trash', ['count' => count($ids), 'ids' => $ids]);
return ['ok' => true, 'op' => 'trash', 'affected' => $stmt->rowCount()];
}
function opRestore(PDO $db, int $clientId, int $userId, array $ids): array
{
$ph = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare(
"UPDATE client_documents SET deleted_at = NULL, deleted_by = NULL, updated_at = NOW()
WHERE client_id = ? AND id IN ({$ph}) AND deleted_at IS NOT NULL"
);
$stmt->execute(array_merge([$clientId], $ids));
dbnDmsLogAudit($clientId, $userId ?: null, 'bulk_restore', ['count' => count($ids), 'ids' => $ids]);
return ['ok' => true, 'op' => 'restore', 'affected' => $stmt->rowCount()];
}
+209
View File
@@ -0,0 +1,209 @@
<?php
/**
* /api/dashboard/categories.php — tenant-managed category dictionary.
*
* GET ?action=list → { ok, categories: [...] }
* Auto-seeds defaults if dictionary empty for this tenant.
*
* POST ?action=create body: { slug, label, color?, icon? }
* POST ?action=update body: { id, label?, color?, icon?, sort_order? }
* POST ?action=delete body: { id } — blocked if is_system=1
* POST ?action=reorder body: { ids: [...] }
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
dbnToolsRequireAuth();
try {
$tenant = dbnToolsEnsureDashboardTenant();
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
}
$clientId = (int)$tenant['client_id'];
$userId = (int)($tenant['client_user_id'] ?? 0);
$tenantRole = (string)($tenant['role'] ?? 'editor');
$db = dbnToolsDb();
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$action = (string)($_GET['action'] ?? ($method === 'POST' ? '' : 'list'));
try {
switch ($action) {
case 'list': dbnToolsRequireMethod('GET'); listCats($db, $clientId); break;
case 'create': dbnToolsRequireMethod('POST'); createCat($db, $clientId, $userId, $tenantRole); break;
case 'update': dbnToolsRequireMethod('POST'); updateCat($db, $clientId, $userId, $tenantRole); break;
case 'delete': dbnToolsRequireMethod('POST'); deleteCat($db, $clientId, $userId, $tenantRole); break;
case 'reorder': dbnToolsRequireMethod('POST'); reorderCats($db, $clientId); break;
default: dbnToolsError('Unknown action.', 400, 'unknown_action');
}
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra);
} catch (Throwable $e) {
error_log('[dbn-dms/categories] ' . $e->getMessage());
dbnToolsError('Category operation failed.', 500, 'op_failed');
}
function listCats(PDO $db, int $clientId): void
{
dbnDmsSeedDefaultCategoriesIfEmpty($clientId);
$stmt = $db->prepare(
'SELECT id, slug, label, color, icon, sort_order, is_system, created_at
FROM client_categories
WHERE client_id = ?
ORDER BY sort_order ASC, label ASC'
);
$stmt->execute([$clientId]);
// Also fetch usage counts.
$counts = $db->prepare(
"SELECT category, COUNT(*) AS n
FROM client_documents
WHERE client_id = ? AND deleted_at IS NULL
GROUP BY category"
);
$counts->execute([$clientId]);
$countMap = [];
foreach ($counts->fetchAll() as $r) {
$countMap[(string)$r['category']] = (int)$r['n'];
}
$rows = $stmt->fetchAll();
foreach ($rows as &$r) {
$r['doc_count'] = $countMap[(string)$r['slug']] ?? 0;
$r['is_system'] = (int)$r['is_system'] === 1;
}
dbnToolsRespond(['ok' => true, 'categories' => $rows]);
}
function createCat(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
if (!in_array($tenantRole, ['editor','admin','owner'], true)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$input = dbnToolsJsonInput(10_000);
$slug = strtolower(trim((string)($input['slug'] ?? '')));
$slug = preg_replace('/[^a-z0-9\-_]/', '', $slug) ?: '';
$label = trim((string)($input['label'] ?? ''));
$color = trim((string)($input['color'] ?? ''));
$icon = trim((string)($input['icon'] ?? ''));
if ($slug === '' || mb_strlen($slug, 'UTF-8') > 50) {
dbnToolsError('Slug is required (lowercase, 150, [a-z0-9-_]).', 422, 'invalid_slug');
}
if ($label === '' || mb_strlen($label, 'UTF-8') > 100) {
dbnToolsError('Label is required (1100).', 422, 'invalid_label');
}
if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
dbnToolsError('Invalid color.', 422, 'invalid_color');
}
try {
$stmt = $db->prepare(
'INSERT INTO client_categories (client_id, slug, label, color, icon, sort_order, is_system, created_at)
VALUES (?, ?, ?, ?, ?, 999, 0, NOW())'
);
$stmt->execute([
$clientId, $slug, $label,
$color !== '' ? $color : null,
$icon !== '' ? substr($icon, 0, 40) : null,
]);
} catch (PDOException $e) {
if ((int)$e->errorInfo[1] === 1062) {
dbnToolsError('A category with this slug already exists.', 409, 'duplicate_slug');
}
throw $e;
}
$id = (int)$db->lastInsertId();
dbnDmsLogAudit($clientId, $userId, 'category_create', ['slug' => $slug]);
dbnToolsRespond(['ok' => true, 'id' => $id], 201);
}
function updateCat(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
if (!in_array($tenantRole, ['editor','admin','owner'], true)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$input = dbnToolsJsonInput(10_000);
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
dbnToolsError('id is required.', 400, 'missing_id');
}
$fields = [];
$params = [];
if (array_key_exists('label', $input)) {
$label = trim((string)$input['label']);
if ($label === '' || mb_strlen($label, 'UTF-8') > 100) {
dbnToolsError('Invalid label.', 422, 'invalid_label');
}
$fields[] = 'label = ?'; $params[] = $label;
}
if (array_key_exists('color', $input)) {
$color = trim((string)$input['color']);
if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
dbnToolsError('Invalid color.', 422, 'invalid_color');
}
$fields[] = 'color = ?'; $params[] = $color !== '' ? $color : null;
}
if (array_key_exists('icon', $input)) {
$icon = trim((string)$input['icon']);
$fields[] = 'icon = ?'; $params[] = $icon !== '' ? substr($icon, 0, 40) : null;
}
if (array_key_exists('sort_order', $input)) {
$fields[] = 'sort_order = ?'; $params[] = (int)$input['sort_order'];
}
if (!$fields) {
dbnToolsError('Nothing to update.', 400, 'no_fields');
}
$params[] = $id;
$params[] = $clientId;
$stmt = $db->prepare('UPDATE client_categories SET ' . implode(', ', $fields) . ' WHERE id = ? AND client_id = ?');
$stmt->execute($params);
dbnDmsLogAudit($clientId, $userId, 'category_update', ['id' => $id]);
dbnToolsRespond(['ok' => true]);
}
function deleteCat(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
if (!in_array($tenantRole, ['admin','owner'], true)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$input = dbnToolsJsonInput(2_000);
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
dbnToolsError('id is required.', 400, 'missing_id');
}
$row = $db->prepare('SELECT slug, is_system FROM client_categories WHERE id = ? AND client_id = ?');
$row->execute([$id, $clientId]);
$existing = $row->fetch();
if (!$existing) {
dbnToolsError('Not found.', 404, 'not_found');
}
if ((int)$existing['is_system'] === 1) {
dbnToolsError('System categories cannot be deleted.', 422, 'is_system');
}
// Reassign any docs in this category to uncategorized.
$db->prepare("UPDATE client_documents SET category = 'uncategorized' WHERE client_id = ? AND category = ?")
->execute([$clientId, $existing['slug']]);
$del = $db->prepare('DELETE FROM client_categories WHERE id = ? AND client_id = ?');
$del->execute([$id, $clientId]);
dbnDmsLogAudit($clientId, $userId, 'category_delete', ['id' => $id, 'slug' => $existing['slug']]);
dbnToolsRespond(['ok' => true]);
}
function reorderCats(PDO $db, int $clientId): void
{
$input = dbnToolsJsonInput(20_000);
$ids = $input['ids'] ?? [];
if (!is_array($ids)) {
dbnToolsError('ids array is required.', 400, 'missing_ids');
}
$ids = array_values(array_filter(array_map('intval', $ids), fn($v) => $v > 0));
$upd = $db->prepare('UPDATE client_categories SET sort_order = ? WHERE id = ? AND client_id = ?');
foreach ($ids as $i => $id) {
$upd->execute([$i, $id, $clientId]);
}
dbnToolsRespond(['ok' => true, 'reordered' => count($ids)]);
}
+56
View File
@@ -48,6 +48,15 @@ $history = array_values(array_filter($history, fn($m) => is_array($m)
$category = trim((string)($input['category'] ?? '')) ?: null;
$language = in_array($input['language'] ?? 'no', ['no', 'en'], true) ? $input['language'] : 'no';
// Folder scope: limit retrieval to a folder subtree, ACL-checked.
$folderScopeRaw = $input['folder_id'] ?? null;
$folderScope = null;
if ($folderScopeRaw !== null && $folderScopeRaw !== '' && $folderScopeRaw !== 'all') {
$folderScope = $folderScopeRaw === 'unassigned' ? 0 : (int)$folderScopeRaw;
}
$includeSubfolders = !empty($input['include_subfolders']);
$includeRelated = !empty($input['include_related']);
// SSE setup
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache, no-transform');
@@ -75,6 +84,37 @@ try {
'user_role' => 'owner',
];
// Apply folder scoping via allowed_folder_ids (supported by ClientRagPipeline).
if ($folderScope !== null) {
if ($folderScope === 0) {
// Unassigned only — currently not supported by allowed_folder_ids; pass empty array
// which the pipeline treats as "no folders allowed" → falls back to docs with NULL folder_id.
$options['allowed_folder_ids'] = [];
} else {
$ids = [$folderScope];
if ($includeSubfolders) {
try {
$db = dbnToolsDb();
$stack = [$folderScope];
$guard = 0;
while ($stack && $guard++ < 1000) {
$batch = $stack;
$stack = [];
$ph = implode(',', array_fill(0, count($batch), '?'));
$st = $db->prepare("SELECT id FROM client_folders WHERE client_id = ? AND parent_id IN ({$ph}) AND deleted_at IS NULL");
$st->execute(array_merge([$clientId], $batch));
foreach ($st->fetchAll() as $r) {
$cid = (int)$r['id'];
$ids[] = $cid;
$stack[] = $cid;
}
}
} catch (Throwable $e) { /* tolerated */ }
}
$options['allowed_folder_ids'] = $ids;
}
}
$result = $rag->askStreaming(
$question,
null, // model: let pipeline choose default
@@ -97,12 +137,28 @@ try {
];
}
// Related documents from FalkorDB graph (co-citation), based on the top source doc.
$related = [];
if ($includeRelated && $sources && method_exists($rag, 'relatedDocumentsFromGraph')) {
$topDoc = (int)($sources[0]['document_id'] ?? 0);
if ($topDoc > 0) {
try {
$related = $rag->relatedDocumentsFromGraph($topDoc, 6);
} catch (Throwable $e) { /* tolerated */ }
}
}
sseEmit('done', [
'ok' => true,
'chunks_used' => (int)($result['chunks_used'] ?? count($sources)),
'model' => (string)($result['model'] ?? ''),
'response_time_ms'=> (int)($result['response_time_ms'] ?? 0),
'sources' => $sources,
'related_documents' => $related,
'scope' => [
'folder_id' => $folderScope,
'include_subfolders'=> $includeSubfolders,
],
]);
} catch (Throwable $e) {
sseEmit('fail', ['ok' => false, 'message' => $e->getMessage()]);
+165
View File
@@ -0,0 +1,165 @@
<?php
/**
* /api/dashboard/diagnostics.php
*
* Returns live status of the DMS stack for the current tenant:
* - MariaDB tenant tables (counts, FULLTEXT index)
* - Qdrant bnl_client_chunks collection (points)
* - LiteLLM embed endpoint (reachability + model)
* - FalkorDB dbn_client_graph (node count for this client)
* - On-disk storage usage
*
* GET → { ok, sections: [{key,label,value,status,detail}] }
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
dbnToolsRequireAuth();
try {
$tenant = dbnToolsEnsureDashboardTenant();
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
}
$clientId = (int)$tenant['client_id'];
$out = [];
/* MariaDB doc/chunk counts */
try {
$db = dbnToolsDb();
$docs = (int)$db->query("SELECT COUNT(*) FROM client_documents WHERE client_id = {$clientId} AND deleted_at IS NULL")->fetchColumn();
$deleted = (int)$db->query("SELECT COUNT(*) FROM client_documents WHERE client_id = {$clientId} AND deleted_at IS NOT NULL")->fetchColumn();
$chunks = 0;
try {
$chunks = (int)$db->query("SELECT COUNT(*) FROM client_chunks WHERE client_id = {$clientId}")->fetchColumn();
} catch (Throwable $_) {}
$out[] = ['key' => 'mariadb', 'label' => 'MariaDB (bnl_admin)',
'value' => "{$docs} docs · {$chunks} chunks · {$deleted} trashed",
'status' => 'ok',
'detail' => 'chloe MySQL — client_documents + client_chunks tables'];
} catch (Throwable $e) {
$out[] = ['key' => 'mariadb', 'label' => 'MariaDB', 'value' => 'unreachable', 'status' => 'err', 'detail' => $e->getMessage()];
}
/* FULLTEXT index presence */
try {
$db = dbnToolsDb();
$ft = $db->query("SHOW INDEX FROM client_chunks WHERE Key_name = 'ft_content'")->fetchAll();
$out[] = ['key' => 'fulltext', 'label' => 'MariaDB FULLTEXT (BM25)',
'value' => $ft ? 'present on client_chunks.content' : 'missing',
'status' => $ft ? 'ok' : 'warn',
'detail' => 'Required for hybrid keyword search; migration 007'];
} catch (Throwable $e) {
$out[] = ['key' => 'fulltext', 'label' => 'MariaDB FULLTEXT', 'value' => '—', 'status' => 'warn', 'detail' => $e->getMessage()];
}
/* Qdrant — count vectors for this client via scroll */
try {
$ch = curl_init('http://10.0.1.10:6333/collections/bnl_client_chunks/points/count');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 4,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'exact' => true,
'filter' => [ 'must' => [ ['key' => 'client_id', 'match' => ['value' => $clientId]] ] ],
]),
]);
$body = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http === 200 && $body) {
$j = json_decode($body, true);
$cnt = (int)($j['result']['count'] ?? 0);
$out[] = ['key' => 'qdrant', 'label' => 'Qdrant (bnl_client_chunks)',
'value' => $cnt . ' points',
'status' => 'ok',
'detail' => 'Colin Docker @ 10.0.2.10:6333, filtered by client_id'];
} else {
$out[] = ['key' => 'qdrant', 'label' => 'Qdrant', 'value' => 'http ' . $http, 'status' => 'warn', 'detail' => 'Not reachable from web container; vector search may fall back to MariaDB.'];
}
} catch (Throwable $e) {
$out[] = ['key' => 'qdrant', 'label' => 'Qdrant', 'value' => 'error', 'status' => 'err', 'detail' => $e->getMessage()];
}
/* LiteLLM embed endpoint */
try {
$ch = curl_init('http://10.0.1.10:4000/v1/models');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 4,
CURLOPT_HTTPHEADER => ['Authorization: Bearer sk-bnl-litellm-26xR9mK4qvN3wL8sTj7pB2d'],
]);
$body = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$hasEmbed = $body && strpos($body, 'nomic-embed') !== false;
$out[] = ['key' => 'litellm', 'label' => 'LiteLLM (Colin)',
'value' => $http === 200 ? ($hasEmbed ? 'reachable · nomic-embed-text registered' : 'reachable · embed model not found') : ('http ' . $http),
'status' => $http === 200 ? ($hasEmbed ? 'ok' : 'warn') : 'warn',
'detail' => 'http://10.0.1.10:4000 — chunker uses /v1/embeddings'];
} catch (Throwable $e) {
$out[] = ['key' => 'litellm', 'label' => 'LiteLLM', 'value' => 'error', 'status' => 'err', 'detail' => $e->getMessage()];
}
/* FalkorDB nodes for this client */
try {
$sock = @stream_socket_client('tcp://10.0.2.10:6379', $errno, $errstr, 2);
if ($sock) {
$cmd = "GRAPH.QUERY";
$graph = "dbn_client_graph";
$cypher = "MATCH (d:Document {client_id: {$clientId}}) RETURN count(d)";
$payload = "*4\r\n$" . strlen($cmd) . "\r\n{$cmd}\r\n$" . strlen($graph) . "\r\n{$graph}\r\n$" . strlen($cypher) . "\r\n{$cypher}\r\n$8\r\n--compact\r\n";
fwrite($sock, $payload);
stream_set_timeout($sock, 2);
$resp = @fread($sock, 8192);
fclose($sock);
$count = 0;
if (preg_match('/(\d+)/', (string)$resp, $m)) $count = (int)$m[1];
$out[] = ['key' => 'falkor', 'label' => 'FalkorDB (dbn_client_graph)',
'value' => $count . ' Document nodes',
'status' => 'ok',
'detail' => 'Colin @ 10.0.2.10:6379 — populated during ingest'];
} else {
$out[] = ['key' => 'falkor', 'label' => 'FalkorDB', 'value' => 'unreachable', 'status' => 'warn',
'detail' => 'Graph features hidden; ingest still works.'];
}
} catch (Throwable $e) {
$out[] = ['key' => 'falkor', 'label' => 'FalkorDB', 'value' => 'error', 'status' => 'warn', 'detail' => $e->getMessage()];
}
/* On-disk storage usage */
try {
$root = dbnToolsEnv('DBN_TOOLS_UPLOAD_ROOT', '')
?: (is_dir('/home/dobetternorge/uploads') ? '/home/dobetternorge/uploads' : DBN_TOOLS_ROOT . '/uploads');
$clientDir = rtrim($root, '/') . '/' . $clientId;
$total = 0; $files = 0;
if (is_dir($clientDir)) {
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($clientDir, FilesystemIterator::SKIP_DOTS));
foreach ($it as $f) {
if ($f->isFile()) { $total += $f->getSize(); $files++; }
}
}
$human = $total < 1024*1024 ? round($total/1024) . ' KB'
: ($total < 1024*1024*1024 ? round($total/1024/1024, 1) . ' MB' : round($total/1024/1024/1024, 2) . ' GB');
$out[] = ['key' => 'storage', 'label' => 'Original file storage',
'value' => $human . ' · ' . $files . ' files',
'status' => is_dir($clientDir) ? 'ok' : 'warn',
'detail' => $clientDir];
} catch (Throwable $e) {
$out[] = ['key' => 'storage', 'label' => 'Storage', 'value' => 'error', 'status' => 'warn', 'detail' => $e->getMessage()];
}
dbnToolsRespond([
'ok' => true,
'tenant' => [
'client_id' => $clientId,
'corpus_id' => (int)$tenant['corpus_id'],
'user_id' => (int)$tenant['client_user_id'],
],
'sections' => $out,
]);
+206
View File
@@ -0,0 +1,206 @@
<?php
/**
* /api/dashboard/document-versions.php — per-document version history.
*
* GET ?action=list&document_id=X → { ok, versions: [...] }
* GET ?action=get&version_id=X → { ok, version: {...with content} }
* POST ?action=restore body: { document_id, version_id }
* → { ok, document_id, new_version_number, chunks }
* POST ?action=delete body: { version_id }
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
dbnToolsRequireAuth();
try {
$tenant = dbnToolsEnsureDashboardTenant();
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
}
$clientId = (int)$tenant['client_id'];
$userId = (int)($tenant['client_user_id'] ?? 0);
$tenantRole = (string)($tenant['role'] ?? 'editor');
$db = dbnToolsDb();
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$action = (string)($_GET['action'] ?? ($method === 'POST' ? '' : 'list'));
try {
switch ($action) {
case 'list':
dbnToolsRequireMethod('GET');
listVersions($db, $clientId, $userId, $tenantRole);
break;
case 'get':
dbnToolsRequireMethod('GET');
getVersion($db, $clientId, $userId, $tenantRole);
break;
case 'restore':
dbnToolsRequireMethod('POST');
restoreVersion($db, $clientId, $userId, $tenantRole);
break;
case 'delete':
dbnToolsRequireMethod('POST');
deleteVersion($db, $clientId, $userId, $tenantRole);
break;
default:
dbnToolsError('Unknown action.', 400, 'unknown_action');
}
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra);
} catch (Throwable $e) {
error_log('[dbn-dms/versions] ' . $e->getMessage());
dbnToolsError('Version operation failed.', 500, 'version_op_failed');
}
function assertDocumentReadable(PDO $db, int $clientId, int $userId, string $tenantRole, int $docId): array
{
$stmt = $db->prepare('SELECT id, folder_id, title, current_version FROM client_documents WHERE id = ? AND client_id = ?');
$stmt->execute([$docId, $clientId]);
$row = $stmt->fetch();
if (!$row) {
dbnToolsError('Document not found.', 404, 'not_found');
}
$fid = $row['folder_id'] ? (int)$row['folder_id'] : 0;
if (!dbnDmsUserCanAccessFolder($fid ?: null, 'read', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
return $row;
}
function listVersions(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$docId = (int)($_GET['document_id'] ?? 0);
if ($docId <= 0) {
dbnToolsError('document_id is required.', 400, 'missing_document_id');
}
assertDocumentReadable($db, $clientId, $userId, $tenantRole, $docId);
$stmt = $db->prepare(
'SELECT v.id, v.version_number, v.title, v.original_filename, v.file_size_bytes,
v.word_count, v.notes, v.uploaded_by, v.created_at,
u.email AS uploaded_email, u.full_name AS uploaded_name
FROM client_document_versions v
LEFT JOIN client_users u ON u.id = v.uploaded_by
WHERE v.document_id = ? AND v.client_id = ?
ORDER BY v.version_number DESC'
);
$stmt->execute([$docId, $clientId]);
dbnToolsRespond(['ok' => true, 'versions' => $stmt->fetchAll()]);
}
function getVersion(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$vid = (int)($_GET['version_id'] ?? 0);
if ($vid <= 0) {
dbnToolsError('version_id is required.', 400, 'missing_version_id');
}
$stmt = $db->prepare(
'SELECT * FROM client_document_versions WHERE id = ? AND client_id = ?'
);
$stmt->execute([$vid, $clientId]);
$row = $stmt->fetch();
if (!$row) {
dbnToolsError('Version not found.', 404, 'not_found');
}
assertDocumentReadable($db, $clientId, $userId, $tenantRole, (int)$row['document_id']);
dbnToolsRespond(['ok' => true, 'version' => $row]);
}
function restoreVersion(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(5_000);
$docId = (int)($input['document_id'] ?? 0);
$vid = (int)($input['version_id'] ?? 0);
if ($docId <= 0 || $vid <= 0) {
dbnToolsError('document_id and version_id are required.', 400, 'missing_args');
}
$cur = assertDocumentReadable($db, $clientId, $userId, $tenantRole, $docId);
$fid = $cur['folder_id'] ? (int)$cur['folder_id'] : 0;
if (!dbnDmsUserCanAccessFolder($fid ?: null, 'write', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$vstmt = $db->prepare('SELECT * FROM client_document_versions WHERE id = ? AND document_id = ? AND client_id = ?');
$vstmt->execute([$vid, $docId, $clientId]);
$ver = $vstmt->fetch();
if (!$ver) {
dbnToolsError('Version not found.', 404, 'not_found');
}
// Snapshot current state before restoring so we can roll-forward later.
dbnDmsSnapshotVersion($docId, $clientId, $userId, "Snapshot before restoring v{$ver['version_number']}");
$next = (int)$db->query("SELECT current_version FROM client_documents WHERE id = {$docId}")->fetchColumn();
$next = max($next, (int)$ver['version_number']) + 1;
$upd = $db->prepare(
"UPDATE client_documents
SET title = ?, content = ?, file_size_bytes = ?, word_count = ?,
original_filename = ?, storage_path = ?, current_version = ?, status = 'pending',
error_message = NULL, updated_at = NOW()
WHERE id = ? AND client_id = ?"
);
$upd->execute([
(string)$ver['title'],
(string)$ver['content'],
(int)$ver['file_size_bytes'],
(int)$ver['word_count'],
$ver['original_filename'],
$ver['storage_path'],
$next,
$docId, $clientId,
]);
try {
$db->prepare('DELETE FROM client_chunks WHERE client_id = ? AND document_id = ?')->execute([$clientId, $docId]);
} catch (Throwable $e) { /* tolerated */ }
$chunks = 0;
try {
$rag = new ClientRagPipeline($clientId);
$chunks = (int)$rag->ingestDocument($docId);
} catch (Throwable $e) {
$db->prepare("UPDATE client_documents SET status='error', error_message=? WHERE id=?")
->execute([substr($e->getMessage(), 0, 1000), $docId]);
}
dbnDmsLogAudit($clientId, $userId ?: null, 'restore_version',
['version_id' => $vid, 'restored_to' => $next], $docId, $fid ?: null);
dbnToolsRespond([
'ok' => true,
'document_id' => $docId,
'new_version_number' => $next,
'chunks' => $chunks,
]);
}
function deleteVersion(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(2_000);
$vid = (int)($input['version_id'] ?? 0);
if ($vid <= 0) {
dbnToolsError('version_id is required.', 400, 'missing_version_id');
}
$stmt = $db->prepare('SELECT document_id, storage_path FROM client_document_versions WHERE id = ? AND client_id = ?');
$stmt->execute([$vid, $clientId]);
$ver = $stmt->fetch();
if (!$ver) {
dbnToolsError('Version not found.', 404, 'not_found');
}
$cur = assertDocumentReadable($db, $clientId, $userId, $tenantRole, (int)$ver['document_id']);
$fid = $cur['folder_id'] ? (int)$cur['folder_id'] : 0;
if (!dbnDmsUserCanAccessFolder($fid ?: null, 'write', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$del = $db->prepare('DELETE FROM client_document_versions WHERE id = ? AND client_id = ?');
$del->execute([$vid, $clientId]);
if (!empty($ver['storage_path']) && is_file($ver['storage_path'])) {
@unlink($ver['storage_path']);
}
dbnDmsLogAudit($clientId, $userId ?: null, 'delete_version', ['version_id' => $vid], (int)$ver['document_id']);
dbnToolsRespond(['ok' => true]);
}
+308 -60
View File
@@ -1,17 +1,25 @@
<?php
/**
* /api/dashboard/documents.php — CRUD for the current user's CaveauAI documents.
* /api/dashboard/documents.php — CRUD for the current tenant's documents.
*
* GET ?action=list &offset=&limit=&q=&status=&category=&source_type=
* &folder_id=(int|'unassigned'|'all')&include_subfolders=1
* &trashed=0|1&sort=updated_at|title|file_size_bytes&dir=asc|desc
* → { ok, total, documents: [...], folder?: {...} }
*
* GET ?action=list&offset=0&limit=20&q=&status=&category=
* → { ok, total, documents: [...] }
* GET ?action=get&id=123
* → { ok, document: {...}, chunks: [...], versions: [...], permissions: {...} }
*
* POST ?action=update body: { id, title?, category?, tags?, language?, author?, folder_id? }
* → { ok, document: {...} }
* POST ?action=update body: { id, title?, category?, tags?, language?, author? }
* → { ok, document: {...} }
* POST ?action=delete body: { ids: [1,2,3] }
*
* POST ?action=delete body: { ids: [1,2,3], hard_delete?: false }
* → { ok, deleted: N }
*
* All filtered by client_id from the dashboard session — no cross-tenant access possible.
* POST ?action=restore body: { ids: [1,2,3] }
* → { ok, restored: N }
*
* All tenant-isolated via dbnToolsEnsureDashboardTenant().
*/
declare(strict_types=1);
@@ -25,53 +33,105 @@ try {
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
}
$clientId = (int)$tenant['client_id'];
$userId = (int)($tenant['client_user_id'] ?? 0);
$tenantRole = (string)($tenant['role'] ?? 'editor');
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$action = (string)($_GET['action'] ?? ($method === 'POST' ? '' : 'list'));
$db = dbnToolsDb();
try {
switch ($action) {
case 'list':
dbnToolsRequireMethod('GET');
respondList($db, $clientId);
respondList($db, $clientId, $userId, $tenantRole);
break;
case 'get':
dbnToolsRequireMethod('GET');
respondGet($db, $clientId);
respondGet($db, $clientId, $userId, $tenantRole);
break;
case 'update':
dbnToolsRequireMethod('POST');
respondUpdate($db, $clientId);
respondUpdate($db, $clientId, $userId, $tenantRole);
break;
case 'delete':
dbnToolsRequireMethod('POST');
respondDelete($db, $clientId);
respondDelete($db, $clientId, $userId, $tenantRole);
break;
case 'restore':
dbnToolsRequireMethod('POST');
respondRestore($db, $clientId, $userId, $tenantRole);
break;
default:
dbnToolsError('Unknown action.', 400, 'unknown_action');
}
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra);
} catch (Throwable $e) {
error_log('[dbn-dms/documents] ' . $e->getMessage());
dbnToolsError('Document operation failed.', 500, 'doc_op_failed');
}
function respondList(PDO $db, int $clientId): void
function respondList(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$offset = max(0, (int)($_GET['offset'] ?? 0));
$limit = max(1, min(100, (int)($_GET['limit'] ?? 20)));
$limit = max(1, min(200, (int)($_GET['limit'] ?? 25)));
$q = trim((string)($_GET['q'] ?? ''));
$status = trim((string)($_GET['status'] ?? ''));
$category = trim((string)($_GET['category'] ?? ''));
$sourceType = trim((string)($_GET['source_type'] ?? ''));
$trashed = !empty($_GET['trashed']);
$folderParam = (string)($_GET['folder_id'] ?? 'all');
$includeSub = !empty($_GET['include_subfolders']);
$sort = strtolower((string)($_GET['sort'] ?? 'updated_at'));
$dir = strtolower((string)($_GET['dir'] ?? 'desc')) === 'asc' ? 'ASC' : 'DESC';
$where = ['client_id = ?'];
$params = [$clientId];
if ($trashed) {
$where[] = 'deleted_at IS NOT NULL';
} else {
$where[] = 'deleted_at IS NULL';
}
// Folder scoping
$folderMeta = null;
if ($folderParam === 'unassigned') {
$where[] = 'folder_id IS NULL';
} elseif ($folderParam === 'all' || $folderParam === '') {
// no folder filter
} else {
$fid = (int)$folderParam;
if ($fid > 0) {
if (!dbnDmsUserCanAccessFolder($fid, 'read', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
if ($includeSub) {
$ids = array_merge([$fid], dbnDmsCollectSubtreeIdsForList($db, $fid, $clientId));
$ph = implode(',', array_fill(0, count($ids), '?'));
$where[] = "folder_id IN ({$ph})";
$params = array_merge($params, $ids);
} else {
$where[] = 'folder_id = ?';
$params[] = $fid;
}
$folderRow = $db->prepare('SELECT id, name, parent_id, color FROM client_folders WHERE id = ? AND client_id = ?');
$folderRow->execute([$fid, $clientId]);
$folderMeta = $folderRow->fetch() ?: null;
}
}
if ($q !== '') {
$where[] = '(title LIKE ? OR tags LIKE ?)';
$like = '%' . str_replace(['%','_'], ['\%','\_'], $q) . '%';
$params[] = $like;
$params[] = $like;
}
$allowedStatus = ['pending', 'processing', 'ready', 'error'];
if ($status !== '' && in_array($status, $allowedStatus, true)) {
if ($status !== '' && in_array($status, ['pending','processing','ready','error'], true)) {
$where[] = 'status = ?';
$params[] = $status;
}
@@ -79,77 +139,138 @@ function respondList(PDO $db, int $clientId): void
$where[] = 'category = ?';
$params[] = $category;
}
$sourceType = trim((string)($_GET['source_type'] ?? ''));
$allowedSourceTypes = ['text', 'audio', 'url', 'tool-output', 'upload'];
if ($sourceType !== '' && in_array($sourceType, $allowedSourceTypes, true)) {
if ($sourceType !== '' && in_array($sourceType, ['text','audio','url','tool-output','upload','pdf','docx'], true)) {
$where[] = 'source_type = ?';
$params[] = $sourceType;
}
$whereSql = 'WHERE ' . implode(' AND ', $where);
$sortMap = [
'updated_at' => 'COALESCE(updated_at, created_at)',
'created_at' => 'created_at',
'title' => 'title',
'file_size_bytes' => 'file_size_bytes',
'word_count' => 'word_count',
];
$sortCol = $sortMap[$sort] ?? 'COALESCE(updated_at, created_at)';
$countStmt = $db->prepare("SELECT COUNT(*) FROM client_documents {$whereSql}");
$countStmt->execute($params);
$total = (int)$countStmt->fetchColumn();
$sql = "SELECT id, title, source_type, language, category, tags, author,
$sql = "SELECT id, folder_id, title, source_type, language, category, tags, author,
source_tool, import_method, status, word_count, chunk_count,
file_size_bytes, source_url, error_message,
file_size_bytes, source_url, original_filename, storage_path,
current_version, deleted_at, error_message,
created_at, updated_at
FROM client_documents
{$whereSql}
ORDER BY id DESC
ORDER BY {$sortCol} {$dir}, id DESC
LIMIT {$limit} OFFSET {$offset}";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
// ACL filter: drop docs whose folder the user can't read.
$visible = [];
$aclCache = [];
foreach ($rows as $row) {
$fid = isset($row['folder_id']) ? (int)$row['folder_id'] : 0;
if (!isset($aclCache[$fid])) {
$aclCache[$fid] = $fid === 0
? true
: dbnDmsUserCanAccessFolder($fid, 'read', $clientId, $userId, $tenantRole);
}
if (!$aclCache[$fid]) {
continue;
}
$visible[] = shapeDoc($row);
}
dbnToolsRespond([
'ok' => true,
'total' => $total,
'offset' => $offset,
'limit' => $limit,
'documents' => array_map('shapeDoc', $rows),
'documents' => $visible,
'folder' => $folderMeta ? [
'id' => (int)$folderMeta['id'],
'name' => (string)$folderMeta['name'],
'parent_id' => $folderMeta['parent_id'] ? (int)$folderMeta['parent_id'] : null,
'color' => $folderMeta['color'] ?? null,
'breadcrumb'=> dbnDmsBreadcrumb((int)$folderMeta['id'], $clientId),
] : null,
]);
}
function respondGet(PDO $db, int $clientId): void
function respondGet(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$id = (int)($_GET['id'] ?? 0);
if ($id <= 0) {
dbnToolsError('id is required.', 400, 'missing_id');
}
$stmt = $db->prepare(
'SELECT * FROM client_documents WHERE id = ? AND client_id = ? LIMIT 1'
);
$stmt = $db->prepare('SELECT * FROM client_documents WHERE id = ? AND client_id = ? LIMIT 1');
$stmt->execute([$id, $clientId]);
$doc = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$doc) {
dbnToolsError('Document not found.', 404, 'not_found');
}
$fid = $doc['folder_id'] ? (int)$doc['folder_id'] : 0;
if (!dbnDmsUserCanAccessFolder($fid ?: null, 'read', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$chunkRows = [];
try {
$chunks = $db->prepare(
'SELECT id, content, section_title
FROM client_chunks
WHERE client_id = ? AND document_id = ?
ORDER BY id ASC
LIMIT 200'
ORDER BY id ASC LIMIT 200'
);
try {
$chunks->execute([$clientId, $id]);
$chunkRows = $chunks->fetchAll(PDO::FETCH_ASSOC);
$chunkRows = $chunks->fetchAll();
} catch (Throwable $e) {
$chunkRows = [];
// tolerated
}
$versions = [];
try {
$vstmt = $db->prepare(
'SELECT id, version_number, title, original_filename, file_size_bytes, word_count,
uploaded_by, notes, created_at
FROM client_document_versions
WHERE document_id = ? AND client_id = ?
ORDER BY version_number DESC LIMIT 50'
);
$vstmt->execute([$id, $clientId]);
$versions = $vstmt->fetchAll();
} catch (Throwable $e) {
// table may not exist yet
}
$permissions = [
'can_read' => true,
'can_write' => dbnDmsUserCanAccessFolder($fid ?: null, 'write', $clientId, $userId, $tenantRole),
'can_manage' => dbnDmsUserCanAccessFolder($fid ?: null, 'manage', $clientId, $userId, $tenantRole),
];
dbnDmsLogAudit($clientId, $userId ?: null, 'view', [], $id, $fid ?: null);
dbnToolsRespond([
'ok' => true,
'document' => shapeDoc($doc) + ['content' => (string)$doc['content']],
'document' => shapeDoc($doc) + [
'content' => (string)($doc['content'] ?? ''),
'breadcrumb' => dbnDmsBreadcrumb($fid ?: null, $clientId),
],
'chunks' => $chunkRows,
'versions' => $versions,
'permissions' => $permissions,
]);
}
function respondUpdate(PDO $db, int $clientId): void
function respondUpdate(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(20_000);
$id = (int)($input['id'] ?? 0);
@@ -157,16 +278,29 @@ function respondUpdate(PDO $db, int $clientId): void
dbnToolsError('id is required.', 400, 'missing_id');
}
// Load doc to ACL-check current folder.
$cur = $db->prepare('SELECT id, folder_id FROM client_documents WHERE id = ? AND client_id = ?');
$cur->execute([$id, $clientId]);
$existing = $cur->fetch();
if (!$existing) {
dbnToolsError('Document not found.', 404, 'not_found');
}
$existingFid = $existing['folder_id'] ? (int)$existing['folder_id'] : 0;
if (!dbnDmsUserCanAccessFolder($existingFid ?: null, 'write', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden on source folder.', 403, 'forbidden_source');
}
$allowed = [
'title' => 500,
'category' => 50,
'tags' => 500,
'language' => 10,
'author' => 200,
];
$fields = [];
$params = [];
$allowed = [
'title' => ['VARCHAR', 500],
'category' => ['VARCHAR', 50],
'tags' => ['VARCHAR', 500],
'language' => ['VARCHAR', 10],
'author' => ['VARCHAR', 200],
];
foreach ($allowed as $col => [$kind, $max]) {
foreach ($allowed as $col => $max) {
if (!array_key_exists($col, $input)) {
continue;
}
@@ -177,6 +311,26 @@ function respondUpdate(PDO $db, int $clientId): void
$fields[] = "{$col} = ?";
$params[] = $val !== '' ? $val : null;
}
// folder_id move
$movedTo = null;
if (array_key_exists('folder_id', $input)) {
$newFid = $input['folder_id'] === null || $input['folder_id'] === '' ? null : (int)$input['folder_id'];
if ($newFid !== null && $newFid > 0) {
if (!dbnDmsUserCanAccessFolder($newFid, 'write', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden on destination folder.', 403, 'forbidden_dest');
}
$check = $db->prepare('SELECT id FROM client_folders WHERE id = ? AND client_id = ? AND deleted_at IS NULL');
$check->execute([$newFid, $clientId]);
if (!$check->fetchColumn()) {
dbnToolsError('Destination folder not found.', 404, 'folder_not_found');
}
}
$fields[] = 'folder_id = ?';
$params[] = $newFid;
$movedTo = $newFid;
}
if (!$fields) {
dbnToolsError('No editable fields supplied.', 400, 'no_fields');
}
@@ -189,6 +343,12 @@ function respondUpdate(PDO $db, int $clientId): void
);
$stmt->execute($params);
dbnDmsLogAudit($clientId, $userId ?: null,
$movedTo !== null ? 'move' : 'edit',
['fields' => array_keys(array_intersect_key($input, $allowed)),
'new_folder_id' => $movedTo],
$id, $movedTo);
$stmt = $db->prepare('SELECT * FROM client_documents WHERE id = ? AND client_id = ? LIMIT 1');
$stmt->execute([$id, $clientId]);
$doc = $stmt->fetch(PDO::FETCH_ASSOC);
@@ -196,7 +356,65 @@ function respondUpdate(PDO $db, int $clientId): void
dbnToolsRespond(['ok' => true, 'document' => shapeDoc($doc ?: [])]);
}
function respondDelete(PDO $db, int $clientId): void
function respondDelete(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(50_000);
$ids = $input['ids'] ?? [];
$hardDelete = !empty($input['hard_delete']);
if (!is_array($ids) || !$ids) {
dbnToolsError('ids array is required.', 400, 'missing_ids');
}
$ids = array_values(array_unique(array_map('intval', $ids)));
$ids = array_filter($ids, fn($v) => $v > 0);
if (!$ids) {
dbnToolsError('No valid ids.', 400, 'invalid_ids');
}
if (count($ids) > 500) {
dbnToolsError('Cannot delete more than 500 documents at once.', 422, 'too_many');
}
// ACL-check each doc's folder.
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$rows = $db->prepare("SELECT id, folder_id FROM client_documents WHERE client_id = ? AND id IN ({$placeholders})");
$rows->execute(array_merge([$clientId], $ids));
$allowedIds = [];
foreach ($rows->fetchAll() as $r) {
$fid = $r['folder_id'] ? (int)$r['folder_id'] : 0;
if (dbnDmsUserCanAccessFolder($fid ?: null, 'write', $clientId, $userId, $tenantRole)) {
$allowedIds[] = (int)$r['id'];
}
}
if (!$allowedIds) {
dbnToolsError('Nothing to delete (insufficient permissions).', 403, 'forbidden');
}
$ph = implode(',', array_fill(0, count($allowedIds), '?'));
if ($hardDelete) {
if (!in_array($tenantRole, ['admin','owner'], true)) {
dbnToolsError('Hard delete requires admin role.', 403, 'forbidden_hard_delete');
}
$stmt = $db->prepare("DELETE FROM client_documents WHERE client_id = ? AND id IN ({$ph})");
$stmt->execute(array_merge([$clientId], $allowedIds));
try {
$chunks = $db->prepare("DELETE FROM client_chunks WHERE client_id = ? AND document_id IN ({$ph})");
$chunks->execute(array_merge([$clientId], $allowedIds));
} catch (Throwable $e) { /* tolerated */ }
dbnDmsLogAudit($clientId, $userId ?: null, 'delete_hard', ['count' => count($allowedIds), 'ids' => $allowedIds]);
dbnToolsRespond(['ok' => true, 'deleted' => $stmt->rowCount(), 'hard' => true]);
}
// Soft delete (default)
$stmt = $db->prepare(
"UPDATE client_documents
SET deleted_at = NOW(), deleted_by = ?, updated_at = NOW()
WHERE client_id = ? AND id IN ({$ph}) AND deleted_at IS NULL"
);
$stmt->execute(array_merge([$userId ?: null, $clientId], $allowedIds));
dbnDmsLogAudit($clientId, $userId ?: null, 'delete', ['count' => count($allowedIds), 'ids' => $allowedIds]);
dbnToolsRespond(['ok' => true, 'deleted' => $stmt->rowCount(), 'hard' => false]);
}
function respondRestore(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(50_000);
$ids = $input['ids'] ?? [];
@@ -208,33 +426,36 @@ function respondDelete(PDO $db, int $clientId): void
if (!$ids) {
dbnToolsError('No valid ids.', 400, 'invalid_ids');
}
if (count($ids) > 200) {
dbnToolsError('Cannot delete more than 200 documents at once.', 422, 'too_many');
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare(
"DELETE FROM client_documents
WHERE client_id = ? AND id IN ({$placeholders})"
);
$stmt->execute(array_merge([$clientId], $ids));
try {
$chunks = $db->prepare(
"DELETE FROM client_chunks WHERE client_id = ? AND document_id IN ({$placeholders})"
);
$chunks->execute(array_merge([$clientId], $ids));
} catch (Throwable $e) {
// table may be filtered to client_id only; non-fatal
$rows = $db->prepare("SELECT id, folder_id FROM client_documents WHERE client_id = ? AND id IN ({$placeholders}) AND deleted_at IS NOT NULL");
$rows->execute(array_merge([$clientId], $ids));
$allowedIds = [];
foreach ($rows->fetchAll() as $r) {
$fid = $r['folder_id'] ? (int)$r['folder_id'] : 0;
if (dbnDmsUserCanAccessFolder($fid ?: null, 'write', $clientId, $userId, $tenantRole)) {
$allowedIds[] = (int)$r['id'];
}
dbnToolsRespond(['ok' => true, 'deleted' => $stmt->rowCount()]);
}
if (!$allowedIds) {
dbnToolsRespond(['ok' => true, 'restored' => 0]);
}
$ph = implode(',', array_fill(0, count($allowedIds), '?'));
$stmt = $db->prepare(
"UPDATE client_documents
SET deleted_at = NULL, deleted_by = NULL, updated_at = NOW()
WHERE client_id = ? AND id IN ({$ph})"
);
$stmt->execute(array_merge([$clientId], $allowedIds));
dbnDmsLogAudit($clientId, $userId ?: null, 'restore', ['count' => count($allowedIds), 'ids' => $allowedIds]);
dbnToolsRespond(['ok' => true, 'restored' => $stmt->rowCount()]);
}
function shapeDoc(array $row): array
{
return [
'id' => (int)($row['id'] ?? 0),
'folder_id' => isset($row['folder_id']) && $row['folder_id'] !== null ? (int)$row['folder_id'] : null,
'title' => (string)($row['title'] ?? ''),
'source_type' => (string)($row['source_type'] ?? ''),
'language' => (string)($row['language'] ?? ''),
@@ -248,8 +469,35 @@ function shapeDoc(array $row): array
'word_count' => (int)($row['word_count'] ?? 0),
'chunk_count' => (int)($row['chunk_count'] ?? 0),
'file_size_bytes' => (int)($row['file_size_bytes'] ?? 0),
'original_filename'=> $row['original_filename'] ?? null,
'has_storage' => !empty($row['storage_path']),
'current_version' => (int)($row['current_version'] ?? 1),
'deleted_at' => $row['deleted_at'] ?? null,
'error_message' => $row['error_message'] ?? null,
'created_at' => (string)($row['created_at'] ?? ''),
'updated_at' => (string)($row['updated_at'] ?? ''),
];
}
function dbnDmsCollectSubtreeIdsForList(PDO $db, int $rootId, int $clientId): array
{
$collected = [];
$stack = [$rootId];
$guard = 0;
while ($stack && $guard++ < 1000) {
$batch = $stack;
$stack = [];
$ph = implode(',', array_fill(0, count($batch), '?'));
$stmt = $db->prepare(
"SELECT id FROM client_folders
WHERE client_id = ? AND parent_id IN ({$ph}) AND deleted_at IS NULL"
);
$stmt->execute(array_merge([$clientId], $batch));
foreach ($stmt->fetchAll() as $row) {
$cid = (int)$row['id'];
$collected[] = $cid;
$stack[] = $cid;
}
}
return $collected;
}
+493
View File
@@ -0,0 +1,493 @@
<?php
/**
* /api/dashboard/folders.php — folder tree CRUD + per-folder ACLs.
*
* GET ?action=list_tree → { ok, tree: [...] }
* GET ?action=get_breadcrumb&folder_id=X → { ok, breadcrumb: [...] }
* POST ?action=create body: { parent_id?, name, color?, description? }
* POST ?action=rename body: { folder_id, name }
* POST ?action=recolor body: { folder_id, color }
* POST ?action=move body: { folder_id, parent_id|null }
* POST ?action=delete body: { folder_id } (soft delete — docs become Unassigned via SET NULL)
* POST ?action=set_permission body: { folder_id, min_role?, user_id?, can_read?, can_write?, can_manage? }
* POST ?action=remove_permission body: { permission_id }
* GET ?action=list_permissions&folder_id=X → { ok, permissions: [...] }
*
* Tenant-scoped via dbnToolsEnsureDashboardTenant().
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
dbnToolsRequireAuth();
try {
$tenant = dbnToolsEnsureDashboardTenant();
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
}
$clientId = (int)$tenant['client_id'];
$corpusId = (int)$tenant['corpus_id'];
$clientUser = (int)($tenant['client_user_id'] ?? 0);
$tenantRole = (string)($tenant['role'] ?? 'editor');
$db = dbnToolsDb();
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$action = (string)($_GET['action'] ?? ($method === 'POST' ? '' : 'list_tree'));
try {
switch ($action) {
case 'list_tree':
dbnToolsRequireMethod('GET');
respondTree($db, $clientId, $corpusId, $clientUser, $tenantRole);
break;
case 'get_breadcrumb':
dbnToolsRequireMethod('GET');
$fid = (int)($_GET['folder_id'] ?? 0);
dbnToolsRespond(['ok' => true, 'breadcrumb' => dbnDmsBreadcrumb($fid ?: null, $clientId)]);
break;
case 'create':
dbnToolsRequireMethod('POST');
respondCreate($db, $clientId, $corpusId, $clientUser, $tenantRole);
break;
case 'rename':
dbnToolsRequireMethod('POST');
respondRename($db, $clientId, $clientUser, $tenantRole);
break;
case 'recolor':
dbnToolsRequireMethod('POST');
respondRecolor($db, $clientId, $clientUser, $tenantRole);
break;
case 'move':
dbnToolsRequireMethod('POST');
respondMove($db, $clientId, $clientUser, $tenantRole);
break;
case 'delete':
dbnToolsRequireMethod('POST');
respondDelete($db, $clientId, $clientUser, $tenantRole);
break;
case 'list_permissions':
dbnToolsRequireMethod('GET');
respondListPermissions($db, $clientId, $clientUser, $tenantRole);
break;
case 'set_permission':
dbnToolsRequireMethod('POST');
respondSetPermission($db, $clientId, $clientUser, $tenantRole);
break;
case 'remove_permission':
dbnToolsRequireMethod('POST');
respondRemovePermission($db, $clientId, $clientUser, $tenantRole);
break;
default:
dbnToolsError('Unknown action.', 400, 'unknown_action');
}
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra);
} catch (Throwable $e) {
error_log('[dbn-dms/folders] ' . $e->getMessage());
dbnToolsError('Folder operation failed.', 500, 'folder_op_failed');
}
function respondTree(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole): void
{
$stmt = $db->prepare(
"SELECT f.id, f.parent_id, f.name, f.slug, f.color, f.description, f.sort_order, f.created_at,
COALESCE(c.cnt, 0) AS doc_count
FROM client_folders f
LEFT JOIN (
SELECT folder_id, COUNT(*) AS cnt
FROM client_documents
WHERE client_id = ? AND deleted_at IS NULL
GROUP BY folder_id
) c ON c.folder_id = f.id
WHERE f.client_id = ? AND f.corpus_id = ? AND f.deleted_at IS NULL
ORDER BY f.sort_order ASC, f.name ASC"
);
$stmt->execute([$clientId, $clientId, $corpusId]);
$rows = $stmt->fetchAll();
// Filter by read ACL.
$visible = [];
foreach ($rows as $row) {
if (dbnDmsUserCanAccessFolder((int)$row['id'], 'read', $clientId, $userId, $tenantRole)) {
$visible[(int)$row['id']] = [
'id' => (int)$row['id'],
'parent_id' => $row['parent_id'] ? (int)$row['parent_id'] : null,
'name' => (string)$row['name'],
'slug' => (string)$row['slug'],
'color' => $row['color'] ?? null,
'description'=> $row['description'] ?? null,
'sort_order' => (int)$row['sort_order'],
'doc_count' => (int)$row['doc_count'],
'children' => [],
];
}
}
// Tree assembly.
$roots = [];
foreach ($visible as $id => &$node) {
$pid = $node['parent_id'];
if ($pid && isset($visible[$pid])) {
$visible[$pid]['children'][] = &$node;
} else {
$roots[] = &$node;
}
}
unset($node);
// Unassigned bucket count.
$unassigned = $db->prepare(
'SELECT COUNT(*) FROM client_documents WHERE client_id = ? AND folder_id IS NULL AND deleted_at IS NULL'
);
$unassigned->execute([$clientId]);
// Trash count.
$trash = $db->prepare(
'SELECT COUNT(*) FROM client_documents WHERE client_id = ? AND deleted_at IS NOT NULL'
);
$trash->execute([$clientId]);
dbnToolsRespond([
'ok' => true,
'tree' => $roots,
'unassigned_count' => (int)$unassigned->fetchColumn(),
'trash_count' => (int)$trash->fetchColumn(),
'max_depth' => DBN_DMS_MAX_FOLDER_DEPTH,
]);
}
function respondCreate(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(20_000);
$name = trim((string)($input['name'] ?? ''));
$parentId = isset($input['parent_id']) && $input['parent_id'] !== null && $input['parent_id'] !== ''
? (int)$input['parent_id'] : null;
$color = trim((string)($input['color'] ?? ''));
$desc = trim((string)($input['description'] ?? ''));
if ($name === '' || mb_strlen($name, 'UTF-8') > 200) {
dbnToolsError('Folder name is required (1200 chars).', 422, 'invalid_name');
}
if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
dbnToolsError('Color must be a #RRGGBB hex value.', 422, 'invalid_color');
}
if (mb_strlen($desc, 'UTF-8') > 1000) {
dbnToolsError('Description is too long (max 1000 chars).', 422, 'description_too_long');
}
$parentDepth = dbnDmsFolderDepth($parentId, $clientId);
if ($parentDepth + 1 > DBN_DMS_MAX_FOLDER_DEPTH) {
dbnToolsError("Folder depth limit reached (max " . DBN_DMS_MAX_FOLDER_DEPTH . " levels).", 422, 'depth_exceeded');
}
if (!dbnDmsUserCanAccessFolder($parentId, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('You do not have permission to create folders here.', 403, 'forbidden');
}
if ($parentId !== null) {
$parentCheck = $db->prepare('SELECT id FROM client_folders WHERE id = ? AND client_id = ? AND deleted_at IS NULL');
$parentCheck->execute([$parentId, $clientId]);
if (!$parentCheck->fetchColumn()) {
dbnToolsError('Parent folder not found.', 404, 'parent_not_found');
}
}
$slug = dbnDmsUniqueSlug($db, $clientId, $corpusId, $name);
$stmt = $db->prepare(
'INSERT INTO client_folders
(client_id, corpus_id, parent_id, name, slug, description, color, sort_order, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, NOW())'
);
$stmt->execute([
$clientId, $corpusId, $parentId, $name, $slug,
$desc !== '' ? $desc : null,
$color !== '' ? $color : null,
$userId ?: null,
]);
$id = (int)$db->lastInsertId();
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_create', ['name' => $name, 'parent_id' => $parentId], null, $id);
dbnToolsRespond(['ok' => true, 'folder_id' => $id, 'slug' => $slug], 201);
}
function respondRename(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(10_000);
$fid = (int)($input['folder_id'] ?? 0);
$name = trim((string)($input['name'] ?? ''));
if ($fid <= 0 || $name === '' || mb_strlen($name, 'UTF-8') > 200) {
dbnToolsError('folder_id and a valid name (1200) are required.', 422, 'invalid_input');
}
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('You do not have permission to rename this folder.', 403, 'forbidden');
}
$stmt = $db->prepare('UPDATE client_folders SET name = ?, updated_at = NOW() WHERE id = ? AND client_id = ?');
$stmt->execute([$name, $fid, $clientId]);
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_rename', ['name' => $name], null, $fid);
dbnToolsRespond(['ok' => true]);
}
function respondRecolor(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(2_000);
$fid = (int)($input['folder_id'] ?? 0);
$color = trim((string)($input['color'] ?? ''));
if ($fid <= 0) {
dbnToolsError('folder_id is required.', 422, 'invalid_input');
}
if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
dbnToolsError('Color must be a #RRGGBB hex value.', 422, 'invalid_color');
}
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$stmt = $db->prepare('UPDATE client_folders SET color = ?, updated_at = NOW() WHERE id = ? AND client_id = ?');
$stmt->execute([$color !== '' ? $color : null, $fid, $clientId]);
dbnToolsRespond(['ok' => true]);
}
function respondMove(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(5_000);
$fid = (int)($input['folder_id'] ?? 0);
$parentId = isset($input['parent_id']) && $input['parent_id'] !== null && $input['parent_id'] !== ''
? (int)$input['parent_id'] : null;
if ($fid <= 0) {
dbnToolsError('folder_id is required.', 422, 'invalid_input');
}
if ($parentId !== null && $parentId === $fid) {
dbnToolsError('Folder cannot be its own parent.', 422, 'invalid_parent');
}
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden on source.', 403, 'forbidden_source');
}
if ($parentId !== null && !dbnDmsUserCanAccessFolder($parentId, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden on destination.', 403, 'forbidden_dest');
}
// Cycle + depth checks.
if ($parentId !== null) {
$chain = dbnDmsFolderChain($parentId, $clientId);
foreach ($chain as $c) {
if ((int)$c['id'] === $fid) {
dbnToolsError('Cannot move a folder into one of its own descendants.', 422, 'invalid_cycle');
}
}
$newDepth = count($chain) + 1; // +1 for the moved folder itself
$childDepth = dbnDmsSubtreeMaxDepth($db, $fid);
if ($newDepth + ($childDepth - 1) > DBN_DMS_MAX_FOLDER_DEPTH) {
dbnToolsError('Move would exceed max folder depth.', 422, 'depth_exceeded');
}
}
$stmt = $db->prepare('UPDATE client_folders SET parent_id = ?, updated_at = NOW() WHERE id = ? AND client_id = ?');
$stmt->execute([$parentId, $fid, $clientId]);
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_move', ['parent_id' => $parentId], null, $fid);
dbnToolsRespond(['ok' => true]);
}
function respondDelete(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(5_000);
$fid = (int)($input['folder_id'] ?? 0);
if ($fid <= 0) {
dbnToolsError('folder_id is required.', 422, 'invalid_input');
}
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
// Soft delete folder + cascade soft-delete on descendant folders.
$db->beginTransaction();
try {
$allIds = dbnDmsCollectSubtreeIds($db, $fid, $clientId);
$allIds[] = $fid;
$placeholders = implode(',', array_fill(0, count($allIds), '?'));
$stmt = $db->prepare(
"UPDATE client_folders SET deleted_at = NOW(), deleted_by = ?
WHERE client_id = ? AND id IN ({$placeholders})"
);
$stmt->execute(array_merge([$userId ?: null, $clientId], $allIds));
// Documents inside: also soft-delete (they appear in Trash).
$docStmt = $db->prepare(
"UPDATE client_documents SET deleted_at = NOW(), deleted_by = ?
WHERE client_id = ? AND folder_id IN ({$placeholders}) AND deleted_at IS NULL"
);
$docStmt->execute(array_merge([$userId ?: null, $clientId], $allIds));
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_delete', [], null, $fid);
dbnToolsRespond(['ok' => true]);
}
function respondListPermissions(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$fid = (int)($_GET['folder_id'] ?? 0);
if ($fid <= 0) {
dbnToolsError('folder_id is required.', 422, 'invalid_input');
}
if (!dbnDmsUserCanAccessFolder($fid, 'read', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$stmt = $db->prepare(
"SELECT p.id, p.folder_id, p.min_role, p.user_id, p.can_read, p.can_write, p.can_manage,
p.created_at, u.email AS user_email, u.full_name AS user_name
FROM client_folder_permissions p
LEFT JOIN client_users u ON u.id = p.user_id
WHERE p.folder_id = ? AND p.client_id = ?
ORDER BY p.id ASC"
);
$stmt->execute([$fid, $clientId]);
dbnToolsRespond(['ok' => true, 'permissions' => $stmt->fetchAll()]);
}
function respondSetPermission(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(10_000);
$fid = (int)($input['folder_id'] ?? 0);
$minRole = trim((string)($input['min_role'] ?? ''));
$targetUid= isset($input['user_id']) && $input['user_id'] ? (int)$input['user_id'] : null;
$canRead = !empty($input['can_read']) ? 1 : 0;
$canWrite = !empty($input['can_write']) ? 1 : 0;
$canManage= !empty($input['can_manage']) ? 1 : 0;
if ($fid <= 0) {
dbnToolsError('folder_id is required.', 422, 'invalid_input');
}
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$validRoles = ['viewer','editor','admin','owner'];
if ($minRole !== '' && !in_array($minRole, $validRoles, true)) {
dbnToolsError('Invalid min_role.', 422, 'invalid_role');
}
if (($minRole === '' && $targetUid === null) || ($minRole !== '' && $targetUid !== null)) {
dbnToolsError('Exactly one of min_role or user_id must be set.', 422, 'invalid_grantee');
}
// UPSERT on the appropriate unique key.
if ($minRole !== '') {
$stmt = $db->prepare(
'INSERT INTO client_folder_permissions
(folder_id, client_id, min_role, can_read, can_write, can_manage, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
can_read = VALUES(can_read),
can_write = VALUES(can_write),
can_manage = VALUES(can_manage)'
);
$stmt->execute([$fid, $clientId, $minRole, $canRead, $canWrite, $canManage, $userId ?: null]);
} else {
$stmt = $db->prepare(
'INSERT INTO client_folder_permissions
(folder_id, client_id, user_id, can_read, can_write, can_manage, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
can_read = VALUES(can_read),
can_write = VALUES(can_write),
can_manage = VALUES(can_manage)'
);
$stmt->execute([$fid, $clientId, $targetUid, $canRead, $canWrite, $canManage, $userId ?: null]);
}
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_acl_set', [
'min_role' => $minRole ?: null,
'user_id' => $targetUid,
'can_read' => $canRead, 'can_write' => $canWrite, 'can_manage' => $canManage,
], null, $fid);
dbnToolsRespond(['ok' => true]);
}
function respondRemovePermission(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(2_000);
$pid = (int)($input['permission_id'] ?? 0);
if ($pid <= 0) {
dbnToolsError('permission_id is required.', 422, 'invalid_input');
}
// Look up the folder to ACL-check.
$row = $db->prepare('SELECT folder_id FROM client_folder_permissions WHERE id = ? AND client_id = ?');
$row->execute([$pid, $clientId]);
$fid = (int)$row->fetchColumn();
if (!$fid) {
dbnToolsError('Permission not found.', 404, 'not_found');
}
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$del = $db->prepare('DELETE FROM client_folder_permissions WHERE id = ? AND client_id = ?');
$del->execute([$pid, $clientId]);
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_acl_remove', ['permission_id' => $pid], null, $fid);
dbnToolsRespond(['ok' => true]);
}
function dbnDmsCollectSubtreeIds(PDO $db, int $rootId, int $clientId): array
{
$collected = [];
$stack = [$rootId];
$guard = 0;
while ($stack && $guard++ < 1000) {
$batch = $stack;
$stack = [];
$placeholders = implode(',', array_fill(0, count($batch), '?'));
$stmt = $db->prepare(
"SELECT id FROM client_folders
WHERE client_id = ? AND parent_id IN ({$placeholders}) AND deleted_at IS NULL"
);
$stmt->execute(array_merge([$clientId], $batch));
foreach ($stmt->fetchAll() as $row) {
$cid = (int)$row['id'];
$collected[] = $cid;
$stack[] = $cid;
}
}
return $collected;
}
function dbnDmsSubtreeMaxDepth(PDO $db, int $rootId): int
{
// Depth of the subtree starting at rootId, where rootId itself counts as 1.
$depth = 1;
$current = [$rootId];
$guard = 0;
while ($current && $guard++ < 20) {
$placeholders = implode(',', array_fill(0, count($current), '?'));
$stmt = $db->prepare(
"SELECT id FROM client_folders
WHERE parent_id IN ({$placeholders}) AND deleted_at IS NULL"
);
$stmt->execute($current);
$next = array_map(fn($r) => (int)$r['id'], $stmt->fetchAll());
if (!$next) {
break;
}
$current = $next;
$depth++;
}
return $depth;
}
function dbnDmsUniqueSlug(PDO $db, int $clientId, int $corpusId, string $name): string
{
$base = strtolower(trim($name));
$base = preg_replace('/[^a-z0-9]+/u', '-', $base) ?: 'folder';
$base = trim($base, '-');
if ($base === '') {
$base = 'folder';
}
$base = substr($base, 0, 180);
$slug = $base;
$check = $db->prepare('SELECT 1 FROM client_folders WHERE client_id = ? AND corpus_id = ? AND slug = ? LIMIT 1');
$n = 2;
while (true) {
$check->execute([$clientId, $corpusId, $slug]);
if (!$check->fetchColumn()) {
return $slug;
}
$slug = $base . '-' . $n++;
if ($n > 999) {
return $base . '-' . substr(bin2hex(random_bytes(3)), 0, 6);
}
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
/**
* /api/dashboard/preview.php?id=123[&version_id=5][&download=1]
*
* Streams the on-disk original file for a document, with the correct
* Content-Type. Used by PDF.js, the <audio> player, image previews, and
* "Download original" links.
*
* ACL: enforces folder read permission on the document.
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
dbnToolsRequireAuth();
try {
$tenant = dbnToolsEnsureDashboardTenant();
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
}
$clientId = (int)$tenant['client_id'];
$userId = (int)($tenant['client_user_id'] ?? 0);
$tenantRole = (string)($tenant['role'] ?? 'editor');
$id = (int)($_GET['id'] ?? 0);
$versionId = (int)($_GET['version_id'] ?? 0);
$download = !empty($_GET['download']);
if ($id <= 0) {
dbnToolsError('id is required.', 400, 'missing_id');
}
$db = dbnToolsDb();
$stmt = $db->prepare('SELECT id, folder_id, storage_path, original_filename, source_type FROM client_documents WHERE id = ? AND client_id = ?');
$stmt->execute([$id, $clientId]);
$doc = $stmt->fetch();
if (!$doc) {
dbnToolsError('Document not found.', 404, 'not_found');
}
$fid = $doc['folder_id'] ? (int)$doc['folder_id'] : 0;
if (!dbnDmsUserCanAccessFolder($fid ?: null, 'read', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$path = (string)($doc['storage_path'] ?? '');
$filename = (string)($doc['original_filename'] ?? '');
if ($versionId > 0) {
$vs = $db->prepare('SELECT storage_path, original_filename FROM client_document_versions WHERE id = ? AND document_id = ? AND client_id = ?');
$vs->execute([$versionId, $id, $clientId]);
$ver = $vs->fetch();
if (!$ver) {
dbnToolsError('Version not found.', 404, 'version_not_found');
}
$path = (string)($ver['storage_path'] ?? '');
$filename = (string)($ver['original_filename'] ?? $filename);
}
if ($path === '' || !is_file($path) || !is_readable($path)) {
dbnToolsError('Original file is not available for this document.', 404, 'file_missing',
['hint' => 'Document predates disk storage, or file was purged.']);
}
$ext = dbnDmsExtensionFromFilename($filename);
if ($ext === '' && $path !== '') {
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
}
$contentType = dbnDmsContentTypeForExt($ext);
dbnDmsLogAudit($clientId, $userId ?: null, $download ? 'download' : 'preview',
['version_id' => $versionId ?: null, 'ext' => $ext], $id, $fid ?: null);
// Suppress any earlier output (defensive).
if (ob_get_level() > 0) { @ob_end_clean(); }
$size = filesize($path) ?: 0;
header('Content-Type: ' . $contentType);
header('Content-Length: ' . $size);
header('X-Content-Type-Options: nosniff');
header('Cache-Control: private, max-age=300');
$disposition = $download ? 'attachment' : 'inline';
$safeName = $filename !== '' ? $filename : ('document-' . $id . '.' . ($ext ?: 'bin'));
$safeName = preg_replace('/[\r\n"]/', '_', $safeName) ?? $safeName;
header(sprintf('Content-Disposition: %s; filename="%s"', $disposition, $safeName));
// Range requests (basic) — useful for PDF.js + audio scrubbing.
$range = $_SERVER['HTTP_RANGE'] ?? '';
if ($range && preg_match('/bytes=(\d+)-(\d+)?/', $range, $m)) {
$start = (int)$m[1];
$end = isset($m[2]) && $m[2] !== '' ? (int)$m[2] : ($size - 1);
if ($end >= $size) $end = $size - 1;
$length = $end - $start + 1;
http_response_code(206);
header('Accept-Ranges: bytes');
header("Content-Range: bytes {$start}-{$end}/{$size}");
header('Content-Length: ' . $length);
$fh = fopen($path, 'rb');
if ($fh) {
fseek($fh, $start);
$remaining = $length;
while (!feof($fh) && $remaining > 0) {
$chunk = fread($fh, min(8192, $remaining));
if ($chunk === false) break;
echo $chunk;
$remaining -= strlen($chunk);
@ob_flush(); @flush();
}
fclose($fh);
}
exit;
}
header('Accept-Ranges: bytes');
readfile($path);
exit;
+222
View File
@@ -0,0 +1,222 @@
<?php
/**
* /api/dashboard/saved-searches.php — "smart folders" CRUD.
*
* GET ?action=list → { ok, items: [...] } (user-owned + tenant-shared)
* POST ?action=create body: { name, query, is_shared?, color?, icon? }
* POST ?action=update body: { id, name?, query?, is_shared?, color?, icon? }
* POST ?action=delete body: { id }
* POST ?action=reorder body: { ids: [...] }
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
dbnToolsRequireAuth();
try {
$tenant = dbnToolsEnsureDashboardTenant();
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
}
$clientId = (int)$tenant['client_id'];
$userId = (int)($tenant['client_user_id'] ?? 0);
$tenantRole = (string)($tenant['role'] ?? 'editor');
$db = dbnToolsDb();
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$action = (string)($_GET['action'] ?? ($method === 'POST' ? '' : 'list'));
try {
switch ($action) {
case 'list': dbnToolsRequireMethod('GET'); listItems($db, $clientId, $userId); break;
case 'create': dbnToolsRequireMethod('POST'); createItem($db, $clientId, $userId, $tenantRole); break;
case 'update': dbnToolsRequireMethod('POST'); updateItem($db, $clientId, $userId, $tenantRole); break;
case 'delete': dbnToolsRequireMethod('POST'); deleteItem($db, $clientId, $userId, $tenantRole); break;
case 'reorder': dbnToolsRequireMethod('POST'); reorderItems($db, $clientId, $userId); break;
default: dbnToolsError('Unknown action.', 400, 'unknown_action');
}
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra);
} catch (Throwable $e) {
error_log('[dbn-dms/saved-searches] ' . $e->getMessage());
dbnToolsError('Saved-search operation failed.', 500, 'op_failed');
}
function listItems(PDO $db, int $clientId, int $userId): void
{
$stmt = $db->prepare(
'SELECT id, name, icon, color, query_json, is_shared, sort_order, user_id, created_at, updated_at
FROM client_saved_searches
WHERE client_id = ? AND (user_id = ? OR is_shared = 1)
ORDER BY sort_order ASC, name ASC'
);
$stmt->execute([$clientId, $userId]);
$rows = $stmt->fetchAll();
foreach ($rows as &$r) {
$r['query'] = json_decode((string)$r['query_json'], true) ?? [];
$r['is_mine'] = (int)$r['user_id'] === $userId;
$r['is_shared'] = (int)$r['is_shared'] === 1;
unset($r['query_json']);
}
dbnToolsRespond(['ok' => true, 'items' => $rows]);
}
function createItem(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(20_000);
$name = trim((string)($input['name'] ?? ''));
$query = $input['query'] ?? [];
$isShared = !empty($input['is_shared']);
$color = trim((string)($input['color'] ?? ''));
$icon = trim((string)($input['icon'] ?? ''));
if ($name === '' || mb_strlen($name, 'UTF-8') > 120) {
dbnToolsError('Name is required (1120 chars).', 422, 'invalid_name');
}
if (!is_array($query) || !$query) {
dbnToolsError('Query payload is required.', 422, 'invalid_query');
}
if ($isShared && !in_array($tenantRole, ['editor','admin','owner'], true)) {
dbnToolsError('Only editors+ can share smart folders.', 403, 'forbidden');
}
if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
dbnToolsError('Invalid color.', 422, 'invalid_color');
}
$stmt = $db->prepare(
'INSERT INTO client_saved_searches
(client_id, user_id, name, icon, color, query_json, is_shared, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, NOW(), NOW())'
);
$stmt->execute([
$clientId, $userId, $name,
$icon !== '' ? substr($icon, 0, 40) : null,
$color !== '' ? $color : null,
json_encode(sanitizeQuery($query), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$isShared ? 1 : 0,
]);
$id = (int)$db->lastInsertId();
dbnDmsLogAudit($clientId, $userId, 'saved_search_create', ['name' => $name, 'id' => $id]);
dbnToolsRespond(['ok' => true, 'id' => $id], 201);
}
function updateItem(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(20_000);
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
dbnToolsError('id is required.', 400, 'missing_id');
}
$row = $db->prepare('SELECT user_id, is_shared FROM client_saved_searches WHERE id = ? AND client_id = ?');
$row->execute([$id, $clientId]);
$existing = $row->fetch();
if (!$existing) {
dbnToolsError('Not found.', 404, 'not_found');
}
$isMine = (int)$existing['user_id'] === $userId;
$canEdit = $isMine || in_array($tenantRole, ['admin','owner'], true);
if (!$canEdit) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$fields = [];
$params = [];
if (array_key_exists('name', $input)) {
$name = trim((string)$input['name']);
if ($name === '' || mb_strlen($name, 'UTF-8') > 120) {
dbnToolsError('Invalid name.', 422, 'invalid_name');
}
$fields[] = 'name = ?';
$params[] = $name;
}
if (array_key_exists('query', $input)) {
if (!is_array($input['query']) || !$input['query']) {
dbnToolsError('Invalid query.', 422, 'invalid_query');
}
$fields[] = 'query_json = ?';
$params[] = json_encode(sanitizeQuery($input['query']), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
if (array_key_exists('is_shared', $input)) {
$fields[] = 'is_shared = ?';
$params[] = !empty($input['is_shared']) ? 1 : 0;
}
if (array_key_exists('color', $input)) {
$color = trim((string)$input['color']);
if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
dbnToolsError('Invalid color.', 422, 'invalid_color');
}
$fields[] = 'color = ?';
$params[] = $color !== '' ? $color : null;
}
if (array_key_exists('icon', $input)) {
$icon = trim((string)$input['icon']);
$fields[] = 'icon = ?';
$params[] = $icon !== '' ? substr($icon, 0, 40) : null;
}
if (!$fields) {
dbnToolsError('Nothing to update.', 400, 'no_fields');
}
$params[] = $id;
$params[] = $clientId;
$stmt = $db->prepare(
'UPDATE client_saved_searches SET ' . implode(', ', $fields) . ', updated_at = NOW()
WHERE id = ? AND client_id = ?'
);
$stmt->execute($params);
dbnDmsLogAudit($clientId, $userId, 'saved_search_update', ['id' => $id]);
dbnToolsRespond(['ok' => true]);
}
function deleteItem(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(2_000);
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
dbnToolsError('id is required.', 400, 'missing_id');
}
$row = $db->prepare('SELECT user_id FROM client_saved_searches WHERE id = ? AND client_id = ?');
$row->execute([$id, $clientId]);
$existing = $row->fetch();
if (!$existing) {
dbnToolsError('Not found.', 404, 'not_found');
}
$isMine = (int)$existing['user_id'] === $userId;
if (!$isMine && !in_array($tenantRole, ['admin','owner'], true)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$del = $db->prepare('DELETE FROM client_saved_searches WHERE id = ? AND client_id = ?');
$del->execute([$id, $clientId]);
dbnDmsLogAudit($clientId, $userId, 'saved_search_delete', ['id' => $id]);
dbnToolsRespond(['ok' => true]);
}
function reorderItems(PDO $db, int $clientId, int $userId): void
{
$input = dbnToolsJsonInput(20_000);
$ids = $input['ids'] ?? [];
if (!is_array($ids)) {
dbnToolsError('ids array is required.', 400, 'missing_ids');
}
$ids = array_values(array_filter(array_map('intval', $ids), fn($v) => $v > 0));
$upd = $db->prepare('UPDATE client_saved_searches SET sort_order = ? WHERE id = ? AND client_id = ? AND (user_id = ? OR is_shared = 1)');
foreach ($ids as $i => $id) {
$upd->execute([$i, $id, $clientId, $userId]);
}
dbnToolsRespond(['ok' => true, 'reordered' => count($ids)]);
}
function sanitizeQuery(array $query): array
{
$allowed = ['q', 'category', 'status', 'source_type', 'folder_id', 'include_subfolders', 'tags', 'sort', 'dir'];
$clean = [];
foreach ($allowed as $key) {
if (array_key_exists($key, $query)) {
$clean[$key] = is_array($query[$key]) ? array_slice(array_values($query[$key]), 0, 50) : $query[$key];
}
}
return $clean;
}
+257
View File
@@ -0,0 +1,257 @@
<?php
/**
* /api/dashboard/trash.php — trashed documents + folders, restore + permanent purge.
*
* GET ?action=list&offset=&limit= → { ok, total, items: [...] }
* POST ?action=restore body: { document_ids?: [..], folder_ids?: [..] }
* POST ?action=purge body: { document_ids?: [..], folder_ids?: [..], all?: bool }
* — admin/owner only for `all`
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
dbnToolsRequireAuth();
try {
$tenant = dbnToolsEnsureDashboardTenant();
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
}
$clientId = (int)$tenant['client_id'];
$userId = (int)($tenant['client_user_id'] ?? 0);
$tenantRole = (string)($tenant['role'] ?? 'editor');
$db = dbnToolsDb();
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$action = (string)($_GET['action'] ?? ($method === 'POST' ? '' : 'list'));
try {
switch ($action) {
case 'list':
dbnToolsRequireMethod('GET');
listTrash($db, $clientId, $userId, $tenantRole);
break;
case 'restore':
dbnToolsRequireMethod('POST');
restoreTrash($db, $clientId, $userId, $tenantRole);
break;
case 'purge':
dbnToolsRequireMethod('POST');
purgeTrash($db, $clientId, $userId, $tenantRole);
break;
default:
dbnToolsError('Unknown action.', 400, 'unknown_action');
}
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra);
} catch (Throwable $e) {
error_log('[dbn-dms/trash] ' . $e->getMessage());
dbnToolsError('Trash operation failed.', 500, 'trash_op_failed');
}
function listTrash(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$offset = max(0, (int)($_GET['offset'] ?? 0));
$limit = max(1, min(200, (int)($_GET['limit'] ?? 50)));
$docs = $db->prepare(
"SELECT id, title, folder_id, source_type, file_size_bytes, deleted_at, deleted_by,
DATEDIFF(NOW(), deleted_at) AS days_in_trash
FROM client_documents
WHERE client_id = ? AND deleted_at IS NOT NULL
ORDER BY deleted_at DESC
LIMIT {$limit} OFFSET {$offset}"
);
$docs->execute([$clientId]);
$docRows = $docs->fetchAll();
// Filter by ACL
$visible = [];
foreach ($docRows as $row) {
$fid = $row['folder_id'] ? (int)$row['folder_id'] : 0;
if (dbnDmsUserCanAccessFolder($fid ?: null, 'read', $clientId, $userId, $tenantRole)) {
$row['kind'] = 'document';
$row['expires_in_days'] = max(0, DBN_DMS_TRASH_RETENTION_DAYS - (int)$row['days_in_trash']);
$visible[] = $row;
}
}
$folders = $db->prepare(
"SELECT id, name, color, deleted_at, deleted_by,
DATEDIFF(NOW(), deleted_at) AS days_in_trash
FROM client_folders
WHERE client_id = ? AND deleted_at IS NOT NULL
ORDER BY deleted_at DESC LIMIT 200"
);
$folders->execute([$clientId]);
foreach ($folders->fetchAll() as $row) {
$row['kind'] = 'folder';
$row['expires_in_days'] = max(0, DBN_DMS_TRASH_RETENTION_DAYS - (int)$row['days_in_trash']);
$visible[] = $row;
}
$countStmt = $db->prepare(
"SELECT COUNT(*) FROM client_documents WHERE client_id = ? AND deleted_at IS NOT NULL"
);
$countStmt->execute([$clientId]);
$total = (int)$countStmt->fetchColumn();
dbnToolsRespond([
'ok' => true,
'total' => $total,
'items' => $visible,
'retention_days' => DBN_DMS_TRASH_RETENTION_DAYS,
]);
}
function restoreTrash(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(20_000);
$docIds = sanitizeIdList($input['document_ids'] ?? []);
$folderIds = sanitizeIdList($input['folder_ids'] ?? []);
$restoredDocs = 0;
$restoredFolders = 0;
if ($docIds) {
$ph = implode(',', array_fill(0, count($docIds), '?'));
$rows = $db->prepare("SELECT id, folder_id FROM client_documents WHERE client_id = ? AND id IN ({$ph}) AND deleted_at IS NOT NULL");
$rows->execute(array_merge([$clientId], $docIds));
$allowed = [];
foreach ($rows->fetchAll() as $r) {
$fid = $r['folder_id'] ? (int)$r['folder_id'] : 0;
if (dbnDmsUserCanAccessFolder($fid ?: null, 'write', $clientId, $userId, $tenantRole)) {
$allowed[] = (int)$r['id'];
}
}
if ($allowed) {
$ph2 = implode(',', array_fill(0, count($allowed), '?'));
$upd = $db->prepare("UPDATE client_documents SET deleted_at = NULL, deleted_by = NULL WHERE client_id = ? AND id IN ({$ph2})");
$upd->execute(array_merge([$clientId], $allowed));
$restoredDocs = $upd->rowCount();
dbnDmsLogAudit($clientId, $userId ?: null, 'restore', ['ids' => $allowed]);
}
}
if ($folderIds) {
$ph = implode(',', array_fill(0, count($folderIds), '?'));
$rows = $db->prepare("SELECT id FROM client_folders WHERE client_id = ? AND id IN ({$ph}) AND deleted_at IS NOT NULL");
$rows->execute(array_merge([$clientId], $folderIds));
$allowed = [];
foreach ($rows->fetchAll() as $r) {
if (dbnDmsUserCanAccessFolder((int)$r['id'], 'manage', $clientId, $userId, $tenantRole)) {
$allowed[] = (int)$r['id'];
}
}
if ($allowed) {
$ph2 = implode(',', array_fill(0, count($allowed), '?'));
$upd = $db->prepare("UPDATE client_folders SET deleted_at = NULL, deleted_by = NULL WHERE client_id = ? AND id IN ({$ph2})");
$upd->execute(array_merge([$clientId], $allowed));
$restoredFolders = $upd->rowCount();
dbnDmsLogAudit($clientId, $userId ?: null, 'restore_folder', ['ids' => $allowed]);
}
}
dbnToolsRespond(['ok' => true, 'restored_documents' => $restoredDocs, 'restored_folders' => $restoredFolders]);
}
function purgeTrash(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
if (!in_array($tenantRole, ['admin','owner'], true)) {
dbnToolsError('Permanent purge requires admin role.', 403, 'forbidden');
}
$input = dbnToolsJsonInput(20_000);
$all = !empty($input['all']);
$docIds = sanitizeIdList($input['document_ids'] ?? []);
$folderIds = sanitizeIdList($input['folder_ids'] ?? []);
$purgedDocs = 0;
$purgedFolders = 0;
if ($all) {
// Documents
$docs = $db->prepare("SELECT id, storage_path FROM client_documents WHERE client_id = ? AND deleted_at IS NOT NULL");
$docs->execute([$clientId]);
foreach ($docs->fetchAll() as $row) {
purgeDocument($db, $clientId, (int)$row['id'], $row['storage_path'] ?? null);
$purgedDocs++;
}
$delFolders = $db->prepare("DELETE FROM client_folders WHERE client_id = ? AND deleted_at IS NOT NULL");
$delFolders->execute([$clientId]);
$purgedFolders = $delFolders->rowCount();
dbnDmsLogAudit($clientId, $userId ?: null, 'purge_all', ['documents' => $purgedDocs, 'folders' => $purgedFolders]);
} else {
if ($docIds) {
$ph = implode(',', array_fill(0, count($docIds), '?'));
$rows = $db->prepare("SELECT id, storage_path FROM client_documents WHERE client_id = ? AND id IN ({$ph}) AND deleted_at IS NOT NULL");
$rows->execute(array_merge([$clientId], $docIds));
foreach ($rows->fetchAll() as $row) {
purgeDocument($db, $clientId, (int)$row['id'], $row['storage_path'] ?? null);
$purgedDocs++;
}
}
if ($folderIds) {
$ph = implode(',', array_fill(0, count($folderIds), '?'));
$del = $db->prepare("DELETE FROM client_folders WHERE client_id = ? AND id IN ({$ph}) AND deleted_at IS NOT NULL");
$del->execute(array_merge([$clientId], $folderIds));
$purgedFolders = $del->rowCount();
}
dbnDmsLogAudit($clientId, $userId ?: null, 'purge', ['documents' => $purgedDocs, 'folders' => $purgedFolders]);
}
dbnToolsRespond(['ok' => true, 'purged_documents' => $purgedDocs, 'purged_folders' => $purgedFolders]);
}
function purgeDocument(PDO $db, int $clientId, int $docId, ?string $storagePath): void
{
// Delete chunks + Qdrant points + on-disk file + versions
try {
$verRows = $db->prepare('SELECT storage_path FROM client_document_versions WHERE document_id = ? AND client_id = ?');
$verRows->execute([$docId, $clientId]);
foreach ($verRows->fetchAll() as $vr) {
if (!empty($vr['storage_path']) && is_file($vr['storage_path'])) {
@unlink($vr['storage_path']);
}
}
} catch (Throwable $e) { /* tolerated */ }
try {
$db->prepare('DELETE FROM client_chunks WHERE client_id = ? AND document_id = ?')->execute([$clientId, $docId]);
} catch (Throwable $e) { /* tolerated */ }
// Best-effort Qdrant cleanup — issue a delete-by-filter
try {
dbnToolsBootCaveau();
if (class_exists('QdrantClient')) {
$qd = new QdrantClient();
$qd->deleteByFilter('bnl_client_chunks', [
'must' => [
['key' => 'client_id', 'match' => ['value' => $clientId]],
['key' => 'document_id', 'match' => ['value' => $docId]],
],
]);
}
} catch (Throwable $e) { /* tolerated */ }
if ($storagePath && is_file($storagePath)) {
@unlink($storagePath);
}
// Also remove the versions folder if it exists.
if ($storagePath) {
$verDir = dirname($storagePath) . '/' . $docId . '_versions';
if (is_dir($verDir)) {
foreach (glob($verDir . '/*') ?: [] as $f) { @unlink($f); }
@rmdir($verDir);
}
}
$db->prepare('DELETE FROM client_documents WHERE id = ? AND client_id = ?')->execute([$docId, $clientId]);
}
function sanitizeIdList(mixed $raw): array
{
if (!is_array($raw)) return [];
$ids = array_values(array_unique(array_filter(array_map('intval', $raw), fn($v) => $v > 0)));
return array_slice($ids, 0, 500);
}
+213 -29
View File
@@ -3,16 +3,19 @@
* POST /api/dashboard/upload.php
*
* Three input modes:
* - multipart/form-data with `file` field (PDF/DOCX/TXT, <= 8 MB)
* - JSON body { "kind":"text", "title":..., "content":..., "category"?, "tags"?, "author"?, "language"? }
* - JSON body { "kind":"url", "title":..., "url":... } (fetched via ClientUniversalScraper; queued)
* - multipart/form-data with `file` field
* Allowed: pdf, docx, txt, md, html, htm, csv, xlsx, pptx, json (≤8 MB)
* Optional fields: title, category, tags, author, language, folder_id,
* version_action (replace|new|force_separate)
*
* For file + text: writes pending row, runs ClientRagPipeline::ingestDocument() synchronously,
* returns { ok, document_id, chunks, status }
* For url: writes pending row, returns immediately with status:'pending' — a separate cron job
* (run_client_one.php on the ai-portal) does the ingest.
* - JSON body { "kind":"text", "title":..., "content":..., "category"?, "tags"?,
* "author"?, "language"?, "folder_id"?, "version_action"? }
*
* If file text extraction yields less than 200 chars, attempts OCR via `tesseract` shell util.
* - JSON body { "kind":"url", "title":..., "url":..., "folder_id"? }
*
* On title collision in the same folder, returns HTTP 409 with
* { ok: false, collision: true, existing_id, message }
* unless `version_action` is provided.
*/
declare(strict_types=1);
@@ -27,8 +30,11 @@ try {
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
}
$clientId = (int)$tenant['client_id'];
$corpusId = (int)$tenant['corpus_id'];
$userId = (int)($tenant['client_user_id'] ?? 0);
$tenantRole = (string)($tenant['role'] ?? 'editor');
dbnToolsBootCaveau();
$db = getDb();
@@ -38,13 +44,13 @@ $isMultipart = stripos($contentType, 'multipart/form-data') === 0;
try {
if ($isMultipart) {
$result = handleFileUpload($db, $clientId, $corpusId);
$result = handleFileUpload($db, $clientId, $corpusId, $userId, $tenantRole);
} else {
$input = dbnToolsJsonInput(2_500_000);
$kind = (string)($input['kind'] ?? 'text');
$result = match ($kind) {
'text' => handleTextPaste($db, $clientId, $corpusId, $input),
'url' => handleUrlImport($db, $clientId, $corpusId, $input),
'text' => handleTextPaste($db, $clientId, $corpusId, $userId, $tenantRole, $input),
'url' => handleUrlImport($db, $clientId, $corpusId, $userId, $tenantRole, $input),
default => dbnToolsError('Unknown kind: ' . $kind, 400, 'unknown_kind'),
};
}
@@ -57,12 +63,19 @@ try {
dbnToolsRespond($result, 201);
function handleFileUpload(PDO $db, int $clientId, int $corpusId): array
function handleFileUpload(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole): array
{
if (empty($_FILES['file'])) {
dbnToolsError('No file uploaded.', 400, 'missing_file');
}
$folderId = resolveFolderId($_POST['folder_id'] ?? null);
$versionAction = trim((string)($_POST['version_action'] ?? ''));
if ($folderId !== null && !dbnDmsUserCanAccessFolder($folderId, 'write', $clientId, $userId, $tenantRole)) {
dbnToolsError('You do not have permission to upload here.', 403, 'forbidden_dest');
}
$tmpPath = (string)($_FILES['file']['tmp_name'] ?? '');
$extract = dbnToolsExtractUploadedFile($_FILES['file']);
$text = (string)$extract['text'];
$filename = (string)$extract['filename'];
@@ -71,17 +84,22 @@ function handleFileUpload(PDO $db, int $clientId, int $corpusId): array
$sourceType = match ($ext) {
'pdf' => 'pdf',
'docx' => 'docx',
'xlsx' => 'xlsx',
'pptx' => 'pptx',
'html', 'htm' => 'html',
'csv' => 'csv',
'md' => 'markdown',
default => 'text',
};
$importMethod = 'dbn_upload';
if (mb_strlen($text, 'UTF-8') < 200 && $ext === 'pdf') {
$ocrText = tryOcrPdf((string)($_FILES['file']['tmp_name'] ?? ''));
$ocrText = tryOcrPdf($tmpPath);
if ($ocrText !== null && mb_strlen($ocrText, 'UTF-8') > mb_strlen($text, 'UTF-8')) {
$text = $ocrText;
$importMethod = 'ocr_scan';
}
}
$importMethod = $importMethod ?? 'dbn_upload';
$title = trim((string)($_POST['title'] ?? '')) ?: pathinfo($filename, PATHINFO_FILENAME);
$category = sanitizeCategory((string)($_POST['category'] ?? 'uncategorized'));
@@ -89,7 +107,7 @@ function handleFileUpload(PDO $db, int $clientId, int $corpusId): array
$author = trim((string)($_POST['author'] ?? '')) ?: null;
$language = trim((string)($_POST['language'] ?? 'no')) ?: 'no';
return persistAndIngest($db, $clientId, $corpusId, [
$doc = [
'title' => $title,
'source_type' => $sourceType,
'content' => $text,
@@ -101,10 +119,15 @@ function handleFileUpload(PDO $db, int $clientId, int $corpusId): array
'original_filename' => $filename,
'file_size_bytes' => (int)($_FILES['file']['size'] ?? 0),
'source_tool' => 'dashboard-upload',
]);
'folder_id' => $folderId,
'_tmp_path' => $tmpPath,
'_ext' => $ext,
];
return handleCollisionAndIngest($db, $clientId, $corpusId, $userId, $tenantRole, $doc, $versionAction);
}
function handleTextPaste(PDO $db, int $clientId, int $corpusId, array $input): array
function handleTextPaste(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole, array $input): array
{
$title = trim((string)($input['title'] ?? ''));
$content = trim((string)($input['content'] ?? ''));
@@ -112,7 +135,13 @@ function handleTextPaste(PDO $db, int $clientId, int $corpusId, array $input): a
if (mb_strlen($content, 'UTF-8') < 30) dbnToolsError('content too short (min 30 chars).', 400, 'content_too_short');
if (mb_strlen($content, 'UTF-8') > 2_000_000) dbnToolsError('content exceeds 2 MB.', 400, 'content_too_large');
return persistAndIngest($db, $clientId, $corpusId, [
$folderId = resolveFolderId($input['folder_id'] ?? null);
$versionAction = trim((string)($input['version_action'] ?? ''));
if ($folderId !== null && !dbnDmsUserCanAccessFolder($folderId, 'write', $clientId, $userId, $tenantRole)) {
dbnToolsError('You do not have permission to upload here.', 403, 'forbidden_dest');
}
$doc = [
'title' => $title,
'source_type' => 'text',
'content' => $content,
@@ -122,10 +151,12 @@ function handleTextPaste(PDO $db, int $clientId, int $corpusId, array $input): a
'language' => trim((string)($input['language'] ?? 'no')) ?: 'no',
'import_method' => 'manual',
'source_tool' => 'dashboard-paste',
]);
'folder_id' => $folderId,
];
return handleCollisionAndIngest($db, $clientId, $corpusId, $userId, $tenantRole, $doc, $versionAction);
}
function handleUrlImport(PDO $db, int $clientId, int $corpusId, array $input): array
function handleUrlImport(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole, array $input): array
{
$url = trim((string)($input['url'] ?? ''));
$title = trim((string)($input['title'] ?? ''));
@@ -138,42 +169,155 @@ function handleUrlImport(PDO $db, int $clientId, int $corpusId, array $input): a
}
if ($title === '') $title = $url;
$folderId = resolveFolderId($input['folder_id'] ?? null);
if ($folderId !== null && !dbnDmsUserCanAccessFolder($folderId, 'write', $clientId, $userId, $tenantRole)) {
dbnToolsError('You do not have permission to upload here.', 403, 'forbidden_dest');
}
$stmt = $db->prepare("
INSERT INTO client_documents
(client_id, corpus_id, title, source_type, source_url, content,
(client_id, corpus_id, folder_id, title, source_type, source_url, content,
category, tags, language, import_method, source_tool, status)
VALUES (?, ?, ?, 'url', ?, '', ?, ?, ?, 'url', 'dashboard-url', 'pending')
VALUES (?, ?, ?, ?, 'url', ?, '', ?, ?, ?, 'url', 'dashboard-url', 'pending')
");
$stmt->execute([
$clientId, $corpusId, $title, $url,
$clientId, $corpusId, $folderId, $title, $url,
sanitizeCategory((string)($input['category'] ?? 'uncategorized')),
sanitizeTagsCsv((string)($input['tags'] ?? '')),
trim((string)($input['language'] ?? 'no')) ?: 'no',
]);
$docId = (int)$db->lastInsertId();
dbnDmsLogAudit($clientId, $userId ?: null, 'upload', ['mode' => 'url', 'url' => $url], $docId, $folderId);
return [
'ok' => true,
'document_id' => (int)$db->lastInsertId(),
'document_id' => $docId,
'status' => 'pending',
'chunks' => 0,
'note' => 'URL queued for background ingest.',
];
}
function persistAndIngest(PDO $db, int $clientId, int $corpusId, array $doc): array
/**
* Title collision detection inside the same folder; dispatches to insert/replace per action.
*/
function handleCollisionAndIngest(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole, array $doc, string $versionAction): array
{
$wordCount = str_word_count($doc['content']);
if ($versionAction !== 'force_separate') {
$check = $db->prepare(
"SELECT id FROM client_documents
WHERE client_id = ?
AND (folder_id <=> ?)
AND LOWER(title) = LOWER(?)
AND deleted_at IS NULL
ORDER BY id DESC LIMIT 1"
);
$check->execute([$clientId, $doc['folder_id'], $doc['title']]);
$existingId = (int)$check->fetchColumn();
if ($existingId > 0 && $versionAction === '') {
dbnToolsError(
'A document with this title already exists in the target folder.',
409,
'title_collision',
['collision' => true, 'existing_id' => $existingId,
'options' => ['replace','new','force_separate']]
);
}
if ($existingId > 0 && in_array($versionAction, ['replace', 'new'], true)) {
return replaceAsVersion($db, $clientId, $userId, $existingId, $doc, $versionAction);
}
}
if ($versionAction === 'force_separate') {
$doc['title'] = uniqueTitle($db, $clientId, $doc['folder_id'], $doc['title']);
}
return persistAndIngest($db, $clientId, $corpusId, $userId, $doc);
}
function replaceAsVersion(PDO $db, int $clientId, int $userId, int $existingId, array $doc, string $versionAction): array
{
// Snapshot current → versions
$newVer = dbnDmsSnapshotVersion($existingId, $clientId, $userId, "Replaced via {$versionAction}");
$current = (int)$db->query("SELECT current_version FROM client_documents WHERE id = {$existingId}")->fetchColumn();
$nextVer = max($current + 1, $newVer + 1);
// Update with new content
$stmt = $db->prepare(
"UPDATE client_documents
SET title=?, source_type=?, content=?, category=?, tags=?, author=?, language=?,
import_method=?, source_tool=?, original_filename=?, file_size_bytes=?, word_count=?,
current_version=?, status='pending', error_message=NULL, updated_at=NOW(),
storage_path = NULL
WHERE id=? AND client_id=?"
);
$stmt->execute([
$doc['title'], $doc['source_type'], $doc['content'], $doc['category'], $doc['tags'],
$doc['author'] ?? null, $doc['language'], $doc['import_method'], $doc['source_tool'],
$doc['original_filename'] ?? null,
(int)($doc['file_size_bytes'] ?? 0),
str_word_count((string)$doc['content']),
$nextVer,
$existingId, $clientId,
]);
// Persist file to disk if we have a tmp upload
if (!empty($doc['_tmp_path']) && !empty($doc['_ext'])) {
$storagePath = dbnDmsPersistFile($doc['_tmp_path'], $clientId, $existingId, $doc['_ext'], $nextVer);
if ($storagePath) {
$db->prepare('UPDATE client_documents SET storage_path = ? WHERE id = ?')
->execute([$storagePath, $existingId]);
}
}
// Wipe chunks & re-ingest
try {
$db->prepare('DELETE FROM client_chunks WHERE client_id = ? AND document_id = ?')->execute([$clientId, $existingId]);
} catch (Throwable $e) { /* tolerated */ }
$chunks = 0;
try {
$rag = new ClientRagPipeline($clientId);
$chunks = (int)$rag->ingestDocument($existingId);
dbnDmsLogAudit($clientId, $userId ?: null, 'version', ['version' => $nextVer], $existingId, $doc['folder_id']);
return [
'ok' => true,
'document_id' => $existingId,
'version_number' => $nextVer,
'chunks' => $chunks,
'status' => 'ready',
'collision_resolved' => $versionAction,
];
} catch (Throwable $e) {
$db->prepare("UPDATE client_documents SET status='error', error_message=? WHERE id=?")
->execute([substr($e->getMessage(), 0, 1000), $existingId]);
return [
'ok' => false,
'document_id' => $existingId,
'version_number' => $nextVer,
'status' => 'error',
'error' => ['code' => 'index_failed', 'message' => $e->getMessage()],
];
}
}
function persistAndIngest(PDO $db, int $clientId, int $corpusId, int $userId, array $doc): array
{
$wordCount = str_word_count((string)$doc['content']);
$stmt = $db->prepare("
INSERT INTO client_documents
(client_id, corpus_id, title, source_type, original_filename, file_size_bytes,
(client_id, corpus_id, folder_id, title, source_type, original_filename, file_size_bytes,
content, category, tags, author, language,
import_method, source_tool, word_count, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
import_method, source_tool, word_count, status, current_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 1)
");
$stmt->execute([
$clientId,
$corpusId,
$doc['folder_id'] ?? null,
$doc['title'],
$doc['source_type'],
$doc['original_filename'] ?? null,
@@ -189,9 +333,21 @@ function persistAndIngest(PDO $db, int $clientId, int $corpusId, array $doc): ar
]);
$docId = (int)$db->lastInsertId();
// Persist original file bytes if available (file upload path only).
if (!empty($doc['_tmp_path']) && !empty($doc['_ext'])) {
$storagePath = dbnDmsPersistFile($doc['_tmp_path'], $clientId, $docId, $doc['_ext']);
if ($storagePath) {
$db->prepare('UPDATE client_documents SET storage_path = ? WHERE id = ?')
->execute([$storagePath, $docId]);
}
}
try {
$rag = new ClientRagPipeline($clientId);
$chunks = $rag->ingestDocument($docId);
dbnDmsLogAudit($clientId, $userId ?: null, 'upload',
['source_type' => $doc['source_type'], 'word_count' => $wordCount],
$docId, $doc['folder_id'] ?? null);
return [
'ok' => true,
'document_id' => $docId,
@@ -211,6 +367,34 @@ function persistAndIngest(PDO $db, int $clientId, int $corpusId, array $doc): ar
}
}
function resolveFolderId(mixed $raw): ?int
{
if ($raw === null || $raw === '' || $raw === 'unassigned' || $raw === '0') {
return null;
}
$n = (int)$raw;
return $n > 0 ? $n : null;
}
function uniqueTitle(PDO $db, int $clientId, ?int $folderId, string $title): string
{
$check = $db->prepare(
"SELECT COUNT(*) FROM client_documents
WHERE client_id = ? AND (folder_id <=> ?) AND LOWER(title) = LOWER(?) AND deleted_at IS NULL"
);
$n = 2;
$base = $title;
while ($n < 100) {
$candidate = $base . ' (' . $n . ')';
$check->execute([$clientId, $folderId, $candidate]);
if ((int)$check->fetchColumn() === 0) {
return $candidate;
}
$n++;
}
return $base . ' (' . substr(bin2hex(random_bytes(3)), 0, 6) . ')';
}
function sanitizeCategory(string $cat): string
{
$cat = strtolower(trim($cat));
+719
View File
@@ -0,0 +1,719 @@
/* ============================================================
* DMS — Document Management System styles
* Two-pane Drive-style browser, folder tree, list, modals.
* Reuses dashboard.css design tokens (paper, navy, red, gold, line).
* ============================================================ */
:root {
--dms-tree-w: 240px;
--dms-row-h: 48px;
--dms-radius: 10px;
--dms-radius-sm: 6px;
--dms-stroke: rgba(22,19,15,0.16);
--dms-stroke-soft: rgba(22,19,15,0.08);
--dms-hover: rgba(0,32,91,0.06);
--dms-selected: rgba(184,138,44,0.16);
--dms-paper: #f6f2ea;
--dms-navy: #00205b;
--dms-red: #ba0c2f;
--dms-gold: #b88a2c;
--dms-shadow-soft: 0 1px 0 var(--dms-stroke);
--dms-shadow-modal: 0 12px 48px rgba(0,0,0,0.18);
}
/* ─── Drive-style two-pane shell (lives inside .dash-main__body) ─── */
.dms-shell {
display: grid;
grid-template-columns: var(--dms-tree-w) minmax(0, 1fr);
gap: 16px;
min-height: 60vh;
}
.dms-shell--single { grid-template-columns: 1fr; }
/* ─── Tree sidebar ─── */
.dms-tree {
background: #fff;
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius);
padding: 8px 6px;
height: fit-content;
position: sticky;
top: 16px;
max-height: calc(100vh - 32px);
overflow-y: auto;
}
.dms-tree__section {
padding: 8px 8px 4px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(22,19,15,0.55);
font-weight: 600;
}
.dms-tree__list { list-style: none; margin: 0; padding: 0; }
.dms-tree__node {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: var(--dms-radius-sm);
cursor: pointer;
user-select: none;
font-size: 14px;
color: #1a1c2c;
line-height: 1.2;
}
.dms-tree__node:hover { background: var(--dms-hover); }
.dms-tree__node.is-active {
background: var(--dms-selected);
color: var(--dms-navy);
font-weight: 600;
}
.dms-tree__node.is-drop-target {
background: rgba(0,32,91,0.10);
outline: 2px dashed var(--dms-navy);
outline-offset: -2px;
}
.dms-tree__caret {
width: 14px;
height: 14px;
flex: 0 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.15s;
opacity: 0.6;
}
.dms-tree__caret.is-open { transform: rotate(90deg); }
.dms-tree__caret--empty { visibility: hidden; }
.dms-tree__icon { width: 16px; flex: 0 0 16px; }
.dms-tree__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--dms-stroke);
flex: 0 0 8px;
}
.dms-tree__label {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dms-tree__count {
font-size: 11px;
color: rgba(22,19,15,0.55);
background: var(--dms-stroke-soft);
padding: 1px 6px;
border-radius: 999px;
}
.dms-tree__node.is-active .dms-tree__count {
background: rgba(255,255,255,0.6);
color: var(--dms-navy);
}
.dms-tree__children {
list-style: none;
margin: 0;
padding-left: 18px;
}
.dms-tree__btn {
width: 100%;
background: none;
border: 0;
padding: 6px 8px;
text-align: left;
color: var(--dms-navy);
cursor: pointer;
border-radius: var(--dms-radius-sm);
font-weight: 600;
font-size: 13px;
margin-top: 4px;
}
.dms-tree__btn:hover { background: var(--dms-hover); }
/* ─── Main pane ─── */
.dms-main {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.dms-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
background: #fff;
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius);
padding: 10px 12px;
}
.dms-toolbar__crumbs {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
font-size: 14px;
color: #1a1c2c;
min-width: 0;
}
.dms-toolbar__crumbs a {
color: inherit;
text-decoration: none;
padding: 2px 6px;
border-radius: 4px;
}
.dms-toolbar__crumbs a:hover { background: var(--dms-hover); }
.dms-toolbar__crumb-sep { opacity: 0.4; font-size: 12px; }
.dms-toolbar__crumb--current { font-weight: 600; color: var(--dms-navy); }
.dms-toolbar__actions {
margin-left: auto;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.dms-filters {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
padding: 8px 12px;
background: #fdfaf3;
border: 1px solid var(--dms-stroke-soft);
border-radius: var(--dms-radius);
}
.dms-filters input[type=search],
.dms-filters select {
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius-sm);
padding: 6px 10px;
background: #fff;
font-size: 13px;
min-width: 0;
}
.dms-filters input[type=search] { flex: 1 1 240px; }
/* Chips */
.dms-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
background: var(--dms-stroke-soft);
font-size: 12px;
color: #1a1c2c;
}
.dms-chip--cat { background: rgba(0,32,91,0.10); color: var(--dms-navy); }
.dms-chip--tag { background: rgba(184,138,44,0.16); color: #6c5212; }
.dms-chip--folder{ background: rgba(255,255,255,0.6); color: var(--dms-navy); border: 1px solid var(--dms-stroke-soft); }
.dms-chip__x {
cursor: pointer;
opacity: 0.6;
margin-left: 2px;
}
.dms-chip__x:hover { opacity: 1; }
/* ─── Doc list (table style) ─── */
.dms-list {
background: #fff;
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius);
overflow: hidden;
}
.dms-list__head,
.dms-list__row {
display: grid;
grid-template-columns: 36px minmax(0,3.5fr) 1.2fr 1.2fr 110px 90px 36px;
gap: 8px;
align-items: center;
padding: 8px 12px;
}
.dms-list__head {
background: #fdfaf3;
border-bottom: 1px solid var(--dms-stroke);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(22,19,15,0.65);
font-weight: 600;
}
.dms-list__head button {
background: none;
border: 0;
padding: 0;
cursor: pointer;
text-align: left;
color: inherit;
font: inherit;
text-transform: inherit;
letter-spacing: inherit;
}
.dms-list__head button.is-sorted { color: var(--dms-navy); }
.dms-list__row {
border-bottom: 1px solid var(--dms-stroke-soft);
font-size: 14px;
cursor: pointer;
transition: background 0.1s;
}
.dms-list__row:hover { background: var(--dms-hover); }
.dms-list__row.is-selected { background: var(--dms-selected); }
.dms-list__row.is-dragging { opacity: 0.4; }
.dms-list__row[data-status="pending"] { color: #6c5212; }
.dms-list__row[data-status="processing"] { color: #6c5212; }
.dms-list__row[data-status="error"] { color: var(--dms-red); }
.dms-list__title {
font-weight: 600;
color: var(--dms-navy);
display: flex;
align-items: center;
gap: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.dms-list__title-icon {
width: 18px;
flex: 0 0 18px;
opacity: 0.7;
}
.dms-list__cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.dms-list__cell--muted { color: rgba(22,19,15,0.55); font-size: 13px; }
.dms-list__more {
background: none;
border: 0;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
opacity: 0.5;
}
.dms-list__more:hover { opacity: 1; background: var(--dms-hover); }
.dms-list__empty {
padding: 60px 24px;
text-align: center;
color: rgba(22,19,15,0.55);
}
.dms-list__empty strong {
display: block;
color: var(--dms-navy);
font-size: 16px;
margin-bottom: 6px;
}
/* ─── Bulk-action bar (sticky bottom of list when selection > 0) ─── */
.dms-bulk-bar {
position: sticky;
bottom: 12px;
margin-top: 8px;
background: var(--dms-navy);
color: #fff;
padding: 8px 12px;
border-radius: var(--dms-radius);
box-shadow: 0 6px 24px rgba(0,32,91,0.30);
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
z-index: 10;
}
.dms-bulk-bar__count {
font-weight: 600;
margin-right: 4px;
}
.dms-bulk-bar__btn {
background: rgba(255,255,255,0.10);
border: 1px solid rgba(255,255,255,0.20);
color: #fff;
padding: 4px 10px;
border-radius: var(--dms-radius-sm);
cursor: pointer;
font-size: 13px;
}
.dms-bulk-bar__btn:hover { background: rgba(255,255,255,0.20); }
.dms-bulk-bar__btn--danger { background: var(--dms-red); border-color: var(--dms-red); }
.dms-bulk-bar__btn--danger:hover { background: #8c0822; }
.dms-bulk-bar__cancel {
margin-left: auto;
background: none;
border: 0;
color: rgba(255,255,255,0.7);
cursor: pointer;
padding: 4px 8px;
font-size: 13px;
}
/* ─── Context menu (right-click) ─── */
.dms-ctx-menu {
position: fixed;
z-index: 50;
background: #fff;
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius-sm);
box-shadow: var(--dms-shadow-modal);
padding: 4px;
min-width: 180px;
font-size: 13px;
}
.dms-ctx-menu__item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
color: #1a1c2c;
}
.dms-ctx-menu__item:hover { background: var(--dms-hover); }
.dms-ctx-menu__item--danger { color: var(--dms-red); }
.dms-ctx-menu__sep {
height: 1px;
background: var(--dms-stroke-soft);
margin: 4px 0;
}
/* ─── Modal ─── */
.dms-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(10,15,30,0.40);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.dms-modal {
background: #fff;
border-radius: var(--dms-radius);
box-shadow: var(--dms-shadow-modal);
max-width: 520px;
width: 100%;
overflow: hidden;
}
.dms-modal--wide { max-width: 760px; }
.dms-modal__head {
padding: 16px 20px;
border-bottom: 1px solid var(--dms-stroke-soft);
display: flex;
align-items: center;
gap: 8px;
}
.dms-modal__title {
font-size: 17px;
font-weight: 600;
color: var(--dms-navy);
margin: 0;
}
.dms-modal__body {
padding: 16px 20px;
max-height: 70vh;
overflow-y: auto;
}
.dms-modal__foot {
padding: 12px 20px;
border-top: 1px solid var(--dms-stroke-soft);
display: flex;
gap: 8px;
justify-content: flex-end;
flex-wrap: wrap;
}
.dms-modal__close {
margin-left: auto;
background: none;
border: 0;
font-size: 22px;
line-height: 1;
color: rgba(22,19,15,0.55);
cursor: pointer;
padding: 0 6px;
}
/* ─── Form bits ─── */
.dms-field {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.dms-field label {
font-size: 12px;
font-weight: 600;
color: rgba(22,19,15,0.7);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dms-field input,
.dms-field select,
.dms-field textarea {
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius-sm);
padding: 8px 10px;
font-size: 14px;
background: #fff;
font-family: inherit;
}
.dms-field textarea { min-height: 80px; resize: vertical; }
.dms-field--inline {
flex-direction: row;
align-items: center;
gap: 8px;
}
/* ─── Tabs (document.php) ─── */
.dms-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--dms-stroke);
margin-bottom: 16px;
}
.dms-tab {
padding: 10px 16px;
border: 0;
background: none;
cursor: pointer;
font-size: 14px;
color: rgba(22,19,15,0.65);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
font-weight: 500;
}
.dms-tab.is-active {
color: var(--dms-navy);
border-bottom-color: var(--dms-navy);
font-weight: 600;
}
.dms-tab__pill {
display: inline-block;
background: var(--dms-stroke-soft);
color: rgba(22,19,15,0.7);
border-radius: 999px;
font-size: 11px;
padding: 1px 7px;
margin-left: 4px;
}
.dms-tab-panel { display: none; }
.dms-tab-panel.is-active { display: block; }
/* ─── Version timeline ─── */
.dms-version {
display: grid;
grid-template-columns: 60px 1fr auto;
gap: 12px;
align-items: start;
padding: 12px;
border: 1px solid var(--dms-stroke-soft);
border-radius: var(--dms-radius);
margin-bottom: 8px;
}
.dms-version__num {
background: var(--dms-navy);
color: #fff;
border-radius: var(--dms-radius-sm);
padding: 4px 8px;
text-align: center;
font-weight: 600;
font-size: 12px;
}
.dms-version__meta {
font-size: 13px;
color: rgba(22,19,15,0.7);
}
.dms-version__title {
color: var(--dms-navy);
font-weight: 600;
font-size: 14px;
}
.dms-version__actions { display: flex; gap: 6px; }
.dms-version--current {
border-left: 3px solid var(--dms-gold);
background: #fdfaf3;
}
/* ─── Permissions panel ─── */
.dms-perm-row {
display: grid;
grid-template-columns: 1fr 70px 70px 70px 32px;
gap: 8px;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--dms-stroke-soft);
font-size: 13px;
}
.dms-perm-row__head {
background: #fdfaf3;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(22,19,15,0.65);
font-weight: 600;
}
/* ─── Preview frames ─── */
.dms-preview-frame {
width: 100%;
height: 70vh;
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius);
background: #f9f7f1;
}
.dms-preview-audio {
width: 100%;
margin: 20px 0;
}
/* ─── Drop overlay for upload drag-anywhere ─── */
.dms-drop-overlay {
position: fixed;
inset: 0;
background: rgba(0,32,91,0.85);
color: #fff;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 12px;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
font-size: 24px;
font-weight: 600;
}
.dms-drop-overlay.is-visible { opacity: 1; }
/* ─── KPI tiles ─── */
.dms-kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.dms-kpi {
background: #fff;
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius);
padding: 14px 16px;
}
.dms-kpi__label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(22,19,15,0.6);
font-weight: 600;
margin: 0 0 6px;
}
.dms-kpi__value {
font-size: 28px;
line-height: 1;
color: var(--dms-navy);
font-weight: 700;
margin: 0;
}
.dms-kpi__hint {
font-size: 12px;
color: rgba(22,19,15,0.55);
margin: 6px 0 0;
}
/* ─── Activity feed ─── */
.dms-activity {
background: #fff;
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius);
padding: 8px 0;
}
.dms-activity__row {
display: grid;
grid-template-columns: 28px 1fr auto;
gap: 10px;
align-items: center;
padding: 8px 16px;
font-size: 13px;
border-bottom: 1px solid var(--dms-stroke-soft);
}
.dms-activity__row:last-child { border-bottom: 0; }
.dms-activity__icon {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--dms-stroke-soft);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--dms-navy);
font-size: 12px;
}
.dms-activity__time {
font-size: 11px;
color: rgba(22,19,15,0.5);
}
/* ─── Settings diagnostics ─── */
.dms-diag {
background: #fff;
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius);
overflow: hidden;
}
.dms-diag__row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--dms-stroke-soft);
font-size: 13px;
align-items: center;
}
.dms-diag__row:last-child { border-bottom: 0; }
.dms-diag__label { font-weight: 600; color: var(--dms-navy); }
.dms-diag__value { font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; font-size: 12px; }
.dms-diag__status {
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
}
.dms-diag__status--ok { background: rgba(4,120,87,0.12); color: #047857; }
.dms-diag__status--warn { background: rgba(180,138,44,0.16); color: #6c5212; }
.dms-diag__status--err { background: rgba(186,12,47,0.12); color: var(--dms-red); }
/* ─── Loading / empty states ─── */
.dms-loading {
padding: 40px 16px;
text-align: center;
color: rgba(22,19,15,0.5);
font-size: 14px;
}
.dms-loading::before {
content: '';
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--dms-stroke);
border-top-color: var(--dms-navy);
border-radius: 50%;
animation: dms-spin 0.8s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes dms-spin { to { transform: rotate(360deg); } }
/* ─── Responsive ─── */
@media (max-width: 880px) {
.dms-shell {
grid-template-columns: 1fr;
}
.dms-tree {
position: relative;
max-height: 280px;
}
.dms-list__head,
.dms-list__row {
grid-template-columns: 36px 1fr 90px 36px;
}
.dms-list__head > :nth-child(n+4):nth-child(-n+6),
.dms-list__row > :nth-child(n+4):nth-child(-n+6) {
display: none;
}
}
+696
View File
@@ -0,0 +1,696 @@
/**
* dms.js — Drive-style DMS interactivity bundle.
*
* Exposes window.DBN_DMS with helpers used by documents.php, folders.php,
* trash.php, document.php. Vanilla JS, no build step.
*/
(function () {
'use strict';
const API = (window.DBN_DASHBOARD && window.DBN_DASHBOARD.apiBase) || '/api/dashboard';
const I18N = window.DBN_I18N || {};
const LOC = I18N.locale || 'en-GB';
/* ─── utils ─── */
function $(sel, ctx) { return (ctx || document).querySelector(sel); }
function $$(sel, ctx) { return Array.from((ctx || document).querySelectorAll(sel)); }
function safe(s) {
return String(s == null ? '' : s)
.replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
}
function fmtBytes(n) {
n = Number(n) || 0;
if (n < 1024) return n + ' B';
if (n < 1024*1024) return (n/1024).toFixed(0) + ' KB';
if (n < 1024*1024*1024) return (n/1024/1024).toFixed(1) + ' MB';
return (n/1024/1024/1024).toFixed(2) + ' GB';
}
function fmtDate(s) {
if (!s) return '—';
try { return new Date(String(s).replace(' ', 'T') + 'Z')
.toLocaleDateString(LOC, { day:'numeric', month:'short', year:'numeric' }); }
catch (_) { return s; }
}
function fmtRelative(s) {
if (!s) return '—';
const d = new Date(String(s).replace(' ', 'T') + 'Z');
const diff = (Date.now() - d.getTime()) / 1000;
if (diff < 60) return I18N.just_now || '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 fmtDate(s);
}
function fileIcon(srcType) {
const map = { pdf:'📄', docx:'📝', xlsx:'📊', pptx:'📑', csv:'📋',
html:'🌐', markdown:'📔', text:'📃', audio:'🔊', url:'🔗' };
return map[srcType] || '📄';
}
async function api(path, opts) {
opts = opts || {};
const headers = Object.assign({ 'Accept': 'application/json' }, opts.headers || {});
let body = opts.body;
if (body && typeof body === 'object' && !(body instanceof FormData)) {
body = JSON.stringify(body);
headers['Content-Type'] = 'application/json';
}
const res = await fetch(API + path, {
method: opts.method || 'GET',
credentials: 'same-origin',
headers,
body,
});
let json = null;
try { json = await res.json(); } catch (_) { /* may be a stream */ }
if (!res.ok) {
const err = new Error((json && json.message) || ('HTTP ' + res.status));
err.status = res.status;
err.payload = json;
throw err;
}
return json;
}
/* ─── Folder tree ─── */
const Tree = {
state: { tree: [], activeFolderId: null, unassignedCount: 0, trashCount: 0, maxDepth: 2 },
async load() {
const data = await api('/folders.php?action=list_tree');
this.state.tree = data.tree || [];
this.state.unassignedCount = data.unassigned_count || 0;
this.state.trashCount = data.trash_count || 0;
this.state.maxDepth = data.max_depth || 2;
return this.state;
},
render(container, opts) {
opts = opts || {};
const active = String(opts.activeFolderId == null ? '' : opts.activeFolderId);
const html = [];
html.push('<div class="dms-tree__section">' + safe(I18N.dms_folders || 'Folders') + '</div>');
html.push('<ul class="dms-tree__list">');
html.push(this._row({ id: 'all', name: I18N.dms_all_files || 'All files', icon: '🗂' },
active === 'all' || active === 'null', 0));
if (this.state.unassignedCount) {
html.push(this._row({ id: 'unassigned',
name: I18N.dms_unassigned || 'Unassigned',
icon: '📥',
doc_count: this.state.unassignedCount },
active === 'unassigned', 0));
}
(this.state.tree || []).forEach(node => {
html.push(this._renderNode(node, active, 0));
});
html.push('</ul>');
html.push('<div class="dms-tree__section">' + safe(I18N.dms_smart || 'Smart folders') + '</div>');
html.push('<ul class="dms-tree__list" id="dmsSmartList"><li class="dms-tree__node dms-tree__node--placeholder" style="opacity:.5;font-size:12px;padding-left:14px">—</li></ul>');
html.push('<button class="dms-tree__btn" type="button" data-action="new-folder">+ ' + safe(I18N.dms_new_folder || 'New folder') + '</button>');
html.push('<button class="dms-tree__btn" type="button" data-action="manage-folders">⚙ ' + safe(I18N.dms_manage_folders || 'Manage folders') + '</button>');
html.push('<div class="dms-tree__section" style="margin-top:8px">' + safe(I18N.dms_system || 'System') + '</div>');
html.push('<ul class="dms-tree__list">');
html.push(this._row({ id: 'trash', name: (I18N.dms_trash || 'Trash') + (this.state.trashCount ? '' : ''),
icon: '🗑', doc_count: this.state.trashCount }, active === 'trash', 0, { href: '/dashboard/trash.php' }));
html.push('</ul>');
container.innerHTML = html.join('');
container.addEventListener('click', this._onClick.bind(this));
this._installDropHandlers(container);
},
_row(node, isActive, depth, opts) {
opts = opts || {};
const count = node.doc_count ? ('<span class="dms-tree__count">' + node.doc_count + '</span>') : '';
const dot = node.color ? ('<span class="dms-tree__dot" style="background:' + safe(node.color) + '"></span>') : '';
const icon = node.icon || dot || '📁';
const tag = opts.href ? 'a' : 'div';
const href = opts.href ? (' href="' + safe(opts.href) + '"') : '';
return '<li><'+tag+' class="dms-tree__node ' + (isActive ? 'is-active' : '') + '"'
+ ' data-folder-id="' + safe(node.id) + '"' + href + '>'
+ '<span class="dms-tree__icon">' + (typeof icon === 'string' && icon.length < 3 ? icon : dot) + '</span>'
+ '<span class="dms-tree__label">' + safe(node.name) + '</span>'
+ count + '</'+tag+'></li>';
},
_renderNode(node, active, depth) {
const hasChildren = node.children && node.children.length;
const isActive = String(node.id) === active;
const html = [];
html.push('<li>');
html.push('<div class="dms-tree__node ' + (isActive ? 'is-active' : '') + '"'
+ ' data-folder-id="' + node.id + '"'
+ ' data-droptarget="1">');
html.push('<span class="dms-tree__caret ' + (hasChildren ? 'is-open' : 'dms-tree__caret--empty') + '">▸</span>');
html.push('<span class="dms-tree__dot" style="background:' + (node.color || '#94a3b8') + '"></span>');
html.push('<span class="dms-tree__label">' + safe(node.name) + '</span>');
if (node.doc_count) html.push('<span class="dms-tree__count">' + node.doc_count + '</span>');
html.push('</div>');
if (hasChildren) {
html.push('<ul class="dms-tree__children">');
node.children.forEach(c => html.push(this._renderNode(c, active, depth + 1)));
html.push('</ul>');
}
html.push('</li>');
return html.join('');
},
_onClick(e) {
const node = e.target.closest('.dms-tree__node');
const btn = e.target.closest('[data-action]');
if (btn) {
e.preventDefault();
if (btn.dataset.action === 'new-folder') {
DMS.folderModal.open({ parentId: this.state.activeFolderId });
} else if (btn.dataset.action === 'manage-folders') {
window.location.href = '/dashboard/folders.php';
}
return;
}
if (node && !node.matches('a')) {
e.preventDefault();
const id = node.dataset.folderId;
this.state.activeFolderId = id;
document.dispatchEvent(new CustomEvent('dms:folder-changed', { detail: { folderId: id } }));
}
},
_installDropHandlers(container) {
container.addEventListener('dragover', e => {
const node = e.target.closest('[data-droptarget="1"]');
if (!node) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
$$('.is-drop-target', container).forEach(el => el.classList.remove('is-drop-target'));
node.classList.add('is-drop-target');
});
container.addEventListener('dragleave', e => {
const node = e.target.closest('[data-droptarget="1"]');
if (node) node.classList.remove('is-drop-target');
});
container.addEventListener('drop', async e => {
const node = e.target.closest('[data-droptarget="1"]');
if (!node) return;
e.preventDefault();
node.classList.remove('is-drop-target');
const folderId = node.dataset.folderId;
let payload;
try { payload = JSON.parse(e.dataTransfer.getData('application/x-dms')); } catch (_) { return; }
if (!payload || !payload.ids || !payload.ids.length) return;
try {
await api('/bulk.php', { method: 'POST', body: { op: 'move', ids: payload.ids, folder_id: folderId === 'unassigned' ? null : folderId } });
document.dispatchEvent(new CustomEvent('dms:reload-required'));
} catch (err) {
alert((I18N.dms_move_failed || 'Move failed') + ': ' + err.message);
}
});
},
};
/* ─── Folder create/rename modal ─── */
const folderModal = {
open(opts) {
opts = opts || {};
const isEdit = !!opts.folderId;
const m = openModal({
title: isEdit ? (I18N.dms_rename_folder || 'Rename folder') : (I18N.dms_new_folder || 'New folder'),
body:
'<div class="dms-field"><label>' + safe(I18N.dms_folder_name || 'Name') + '</label>' +
'<input id="dmsFolderName" type="text" maxlength="200" value="' + safe(opts.name || '') + '"></div>' +
'<div class="dms-field"><label>' + safe(I18N.dms_folder_color || 'Colour') + '</label>' +
'<input id="dmsFolderColor" type="color" value="' + safe(opts.color || '#3b82f6') + '"></div>',
actions: [
{ label: I18N.dms_cancel || 'Cancel', cls: 'dash-btn', onclick: closeModal },
{ label: isEdit ? (I18N.dms_save || 'Save') : (I18N.dms_create || 'Create'),
cls: 'dash-btn dash-btn--primary',
onclick: async () => {
const name = $('#dmsFolderName').value.trim();
const color = $('#dmsFolderColor').value;
if (!name) return;
try {
if (isEdit) {
await api('/folders.php?action=rename', { method: 'POST', body: { folder_id: opts.folderId, name } });
await api('/folders.php?action=recolor', { method: 'POST', body: { folder_id: opts.folderId, color } });
} else {
const res = await api('/folders.php?action=create', {
method: 'POST',
body: { name, color, parent_id: opts.parentId && opts.parentId !== 'all' && opts.parentId !== 'unassigned' ? opts.parentId : null },
});
if (opts.onCreated) opts.onCreated(res);
}
closeModal();
document.dispatchEvent(new CustomEvent('dms:reload-required'));
} catch (err) {
alert(err.message);
}
} },
],
});
setTimeout(() => $('#dmsFolderName').focus(), 30);
},
};
/* ─── Generic modal ─── */
let _modalRoot = null;
function openModal(opts) {
closeModal();
_modalRoot = document.createElement('div');
_modalRoot.className = 'dms-modal-backdrop';
const actionsHtml = (opts.actions || []).map((a, i) =>
'<button data-mi="' + i + '" class="' + safe(a.cls || 'dash-btn') + '">' + safe(a.label) + '</button>'
).join('');
_modalRoot.innerHTML =
'<div class="dms-modal ' + safe(opts.modalClass || '') + '">' +
'<div class="dms-modal__head">' +
'<h3 class="dms-modal__title">' + safe(opts.title || '') + '</h3>' +
'<button class="dms-modal__close" type="button" aria-label="Close">×</button>' +
'</div>' +
'<div class="dms-modal__body">' + (opts.body || '') + '</div>' +
(opts.actions ? '<div class="dms-modal__foot">' + actionsHtml + '</div>' : '') +
'</div>';
document.body.appendChild(_modalRoot);
_modalRoot.addEventListener('click', e => { if (e.target === _modalRoot) closeModal(); });
$('.dms-modal__close', _modalRoot).addEventListener('click', closeModal);
(opts.actions || []).forEach((a, i) => {
const b = _modalRoot.querySelector('[data-mi="' + i + '"]');
if (b && a.onclick) b.addEventListener('click', a.onclick);
});
return _modalRoot;
}
function closeModal() {
if (_modalRoot && _modalRoot.parentNode) _modalRoot.parentNode.removeChild(_modalRoot);
_modalRoot = null;
}
/* ─── Context menu ─── */
let _ctxMenu = null;
function openCtxMenu(x, y, items) {
closeCtxMenu();
_ctxMenu = document.createElement('div');
_ctxMenu.className = 'dms-ctx-menu';
_ctxMenu.style.left = x + 'px';
_ctxMenu.style.top = y + 'px';
_ctxMenu.innerHTML = items.map((it, i) => {
if (it === '-') return '<div class="dms-ctx-menu__sep"></div>';
return '<div class="dms-ctx-menu__item ' + (it.danger ? 'dms-ctx-menu__item--danger' : '') + '" data-i="' + i + '">'
+ (it.icon ? '<span>' + it.icon + '</span>' : '')
+ safe(it.label) + '</div>';
}).join('');
document.body.appendChild(_ctxMenu);
// Position correction if off-screen
const r = _ctxMenu.getBoundingClientRect();
if (r.right > window.innerWidth) _ctxMenu.style.left = (window.innerWidth - r.width - 8) + 'px';
if (r.bottom > window.innerHeight) _ctxMenu.style.top = (window.innerHeight - r.height - 8) + 'px';
items.forEach((it, i) => {
if (it === '-') return;
const el = _ctxMenu.querySelector('[data-i="' + i + '"]');
if (el && it.onclick) el.addEventListener('click', () => { closeCtxMenu(); it.onclick(); });
});
setTimeout(() => document.addEventListener('click', closeCtxMenu, { once: true }), 0);
}
function closeCtxMenu() {
if (_ctxMenu && _ctxMenu.parentNode) _ctxMenu.parentNode.removeChild(_ctxMenu);
_ctxMenu = null;
}
/* ─── Doc list ─── */
const List = {
state: {
offset: 0, limit: 50, total: 0,
folderId: 'all', includeSubfolders: true,
q: '', status: '', category: '',
sort: 'updated_at', dir: 'desc',
selected: new Set(),
docs: [],
},
async load() {
const qs = new URLSearchParams({
action: 'list',
offset: String(this.state.offset),
limit: String(this.state.limit),
folder_id: String(this.state.folderId),
include_subfolders: this.state.includeSubfolders ? '1' : '0',
sort: this.state.sort,
dir: this.state.dir,
});
if (this.state.q) qs.set('q', this.state.q);
if (this.state.status) qs.set('status', this.state.status);
if (this.state.category) qs.set('category', this.state.category);
const data = await api('/documents.php?' + qs.toString());
this.state.docs = data.documents || [];
this.state.total = data.total || 0;
return data;
},
render(container) {
const docs = this.state.docs;
if (!docs.length) {
container.innerHTML =
'<div class="dms-list">' +
'<div class="dms-list__empty">' +
'<strong>' + safe(I18N.dms_empty_title || 'No documents here yet') + '</strong>' +
'<span>' + safe(I18N.dms_empty_hint || 'Drag files anywhere, or click Upload.') + '</span>' +
'</div></div>';
return;
}
const sortHead = (key, label) => {
const active = this.state.sort === key;
const arrow = active ? (this.state.dir === 'asc' ? ' ↑' : ' ↓') : '';
return '<button data-sort="' + key + '" class="' + (active ? 'is-sorted' : '') + '">' + safe(label) + arrow + '</button>';
};
const rows = docs.map(d => {
const isSel = this.state.selected.has(d.id);
const cat = d.category ? '<span class="dms-chip dms-chip--cat">' + safe(d.category) + '</span>' : '';
const tags = (d.tags || '').split(',').filter(Boolean).slice(0,3)
.map(t => '<span class="dms-chip dms-chip--tag">' + safe(t.trim()) + '</span>').join(' ');
return '<div class="dms-list__row" data-id="' + d.id + '" data-status="' + safe(d.status) + '"'
+ (isSel ? ' class="is-selected"' : '') + ' draggable="true">'
+ '<input type="checkbox" class="dms-sel"' + (isSel ? ' checked' : '') + '>'
+ '<div class="dms-list__title"><span class="dms-list__title-icon">' + fileIcon(d.source_type) + '</span>'
+ '<a href="/dashboard/document.php?id=' + d.id + '">' + safe(d.title || '(untitled)') + '</a></div>'
+ '<div class="dms-list__cell dms-list__cell--muted">' + cat + ' ' + tags + '</div>'
+ '<div class="dms-list__cell dms-list__cell--muted">' + safe(d.author || '') + '</div>'
+ '<div class="dms-list__cell dms-list__cell--muted">' + fmtRelative(d.updated_at || d.created_at) + '</div>'
+ '<div class="dms-list__cell dms-list__cell--muted">' + (d.file_size_bytes ? fmtBytes(d.file_size_bytes) : '—') + '</div>'
+ '<button class="dms-list__more" data-id="' + d.id + '" aria-label="Actions">⋯</button>'
+ '</div>';
}).join('');
container.innerHTML =
'<div class="dms-list">' +
'<div class="dms-list__head">' +
'<input type="checkbox" id="dmsSelAll">' +
sortHead('title', I18N.dms_col_title || 'Title') +
'<span>' + safe(I18N.dms_col_meta || 'Category · Tags') + '</span>' +
'<span>' + safe(I18N.dms_col_author || 'Author') + '</span>' +
sortHead('updated_at', I18N.dms_col_updated || 'Updated') +
sortHead('file_size_bytes', I18N.dms_col_size || 'Size') +
'<span></span>' +
'</div>' + rows +
'</div>' +
(this.state.selected.size ? this._bulkBar() : '');
this._wire(container);
},
_bulkBar() {
const n = this.state.selected.size;
return '<div class="dms-bulk-bar">' +
'<span class="dms-bulk-bar__count">' + n + ' ' + safe(I18N.dms_selected || 'selected') + '</span>' +
'<button class="dms-bulk-bar__btn" data-bulk="move">' + safe(I18N.dms_move || 'Move') + '</button>' +
'<button class="dms-bulk-bar__btn" data-bulk="retag">' + safe(I18N.dms_tag || 'Tag') + '</button>' +
'<button class="dms-bulk-bar__btn" data-bulk="recategorize">' + safe(I18N.dms_recategorize || 'Category') + '</button>' +
'<button class="dms-bulk-bar__btn dms-bulk-bar__btn--danger" data-bulk="trash">' + safe(I18N.dms_trash || 'Trash') + '</button>' +
'<button class="dms-bulk-bar__cancel" data-bulk="cancel">' + safe(I18N.dms_cancel || 'Cancel') + '</button>' +
'</div>';
},
_wire(container) {
// Selection
container.addEventListener('change', e => {
if (e.target.id === 'dmsSelAll') {
const checked = e.target.checked;
this.state.selected.clear();
if (checked) this.state.docs.forEach(d => this.state.selected.add(d.id));
this.render(container);
return;
}
if (e.target.classList.contains('dms-sel')) {
const row = e.target.closest('[data-id]');
const id = Number(row.dataset.id);
if (e.target.checked) this.state.selected.add(id); else this.state.selected.delete(id);
row.classList.toggle('is-selected', e.target.checked);
this.render(container);
}
});
// Sort
container.addEventListener('click', e => {
const sortBtn = e.target.closest('[data-sort]');
if (sortBtn) {
const key = sortBtn.dataset.sort;
if (this.state.sort === key) {
this.state.dir = this.state.dir === 'asc' ? 'desc' : 'asc';
} else {
this.state.sort = key; this.state.dir = 'desc';
}
this.load().then(() => this.render(container));
return;
}
const more = e.target.closest('.dms-list__more');
if (more) {
e.preventDefault();
e.stopPropagation();
const id = Number(more.dataset.id);
const doc = this.state.docs.find(d => d.id === id);
if (doc) this._openCtx(more.getBoundingClientRect().left, more.getBoundingClientRect().bottom, doc, container);
return;
}
const bulk = e.target.closest('[data-bulk]');
if (bulk) {
this._bulkAction(bulk.dataset.bulk, container);
return;
}
});
// Right-click context menu
container.addEventListener('contextmenu', e => {
const row = e.target.closest('.dms-list__row');
if (!row) return;
e.preventDefault();
const id = Number(row.dataset.id);
const doc = this.state.docs.find(d => d.id === id);
if (doc) this._openCtx(e.clientX, e.clientY, doc, container);
});
// Drag to folder tree
container.addEventListener('dragstart', e => {
const row = e.target.closest('.dms-list__row');
if (!row) return;
const id = Number(row.dataset.id);
const ids = this.state.selected.size && this.state.selected.has(id)
? Array.from(this.state.selected) : [id];
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/x-dms', JSON.stringify({ ids }));
row.classList.add('is-dragging');
});
container.addEventListener('dragend', e => {
const row = e.target.closest('.dms-list__row');
if (row) row.classList.remove('is-dragging');
});
},
_openCtx(x, y, doc, container) {
openCtxMenu(x, y, [
{ label: I18N.dms_open || 'Open', icon: '📂', onclick: () => { window.location.href = '/dashboard/document.php?id=' + doc.id; } },
{ label: I18N.dms_download || 'Download', icon: '⬇',
onclick: () => { window.location.href = API + '/preview.php?id=' + doc.id + '&download=1'; } },
'-',
{ label: I18N.dms_move || 'Move…', icon: '➜', onclick: () => this._bulkAction('move', container, [doc.id]) },
{ label: I18N.dms_recategorize || 'Change category…', icon: '🏷',
onclick: () => this._bulkAction('recategorize', container, [doc.id]) },
{ label: I18N.dms_tag || 'Edit tags…', icon: '🔖', onclick: () => this._bulkAction('retag', container, [doc.id]) },
'-',
{ label: I18N.dms_trash || 'Move to trash', icon: '🗑', danger: true, onclick: () => this._bulkAction('trash', container, [doc.id]) },
]);
},
async _bulkAction(op, container, idsOverride) {
const ids = idsOverride || Array.from(this.state.selected);
if (!ids.length) return;
if (op === 'cancel') { this.state.selected.clear(); this.render(container); return; }
try {
if (op === 'move') {
const folderId = await pickFolder(I18N.dms_pick_destination || 'Pick destination folder');
if (folderId === undefined) return;
await api('/bulk.php', { method: 'POST', body: { op, ids, folder_id: folderId } });
} else if (op === 'recategorize') {
const cat = prompt(I18N.dms_prompt_category || 'New category slug:');
if (!cat) return;
await api('/bulk.php', { method: 'POST', body: { op, ids, category: cat } });
} else if (op === 'retag') {
const tags = prompt(I18N.dms_prompt_tags || 'Tags (comma-separated):');
if (tags === null) return;
await api('/bulk.php', { method: 'POST', body: { op, ids, tags, mode: 'replace' } });
} else {
if (op === 'trash' && !confirm((I18N.dms_confirm_trash || 'Move {n} document(s) to trash?').replace('{n}', ids.length))) return;
await api('/bulk.php', { method: 'POST', body: { op, ids } });
}
this.state.selected.clear();
await this.load();
this.render(container);
document.dispatchEvent(new CustomEvent('dms:reload-tree'));
} catch (err) {
alert(err.message);
}
},
};
/* ─── Folder picker prompt ─── */
async function pickFolder(title) {
try {
const data = await api('/folders.php?action=list_tree');
const flat = [];
const walk = (nodes, prefix) => {
(nodes || []).forEach(n => {
flat.push({ id: n.id, label: prefix + n.name });
if (n.children) walk(n.children, prefix + n.name + ' / ');
});
};
walk(data.tree || [], '');
return await new Promise(resolve => {
const opts = ['<option value="">' + safe(I18N.dms_unassigned || 'Unassigned') + '</option>']
.concat(flat.map(f => '<option value="' + f.id + '">' + safe(f.label) + '</option>')).join('');
openModal({
title,
body: '<div class="dms-field"><label>' + safe(I18N.dms_destination || 'Destination') + '</label>' +
'<select id="dmsPickFolder">' + opts + '</select></div>',
actions: [
{ label: I18N.dms_cancel || 'Cancel', cls: 'dash-btn', onclick: () => { closeModal(); resolve(undefined); } },
{ label: I18N.dms_move || 'Move', cls: 'dash-btn dash-btn--primary',
onclick: () => {
const v = $('#dmsPickFolder').value;
closeModal();
resolve(v ? Number(v) : null);
} },
],
});
});
} catch (err) {
alert(err.message);
return undefined;
}
}
/* ─── Smart-folder sidebar (saved searches) ─── */
const Smart = {
async load() {
try {
const data = await api('/saved-searches.php?action=list');
const items = data.items || [];
const ul = $('#dmsSmartList');
if (!ul) return;
if (!items.length) {
ul.innerHTML = '<li class="dms-tree__node" style="opacity:.5;font-size:12px;padding-left:14px">' +
safe(I18N.dms_smart_empty || '(none yet)') + '</li>';
return;
}
ul.innerHTML = items.map(s =>
'<li><div class="dms-tree__node" data-smart="' + s.id + '">' +
'<span class="dms-tree__icon">' + (s.icon || '★') + '</span>' +
'<span class="dms-tree__label">' + safe(s.name) + '</span>' +
(s.is_shared ? '<span class="dms-tree__count">∞</span>' : '') +
'</div></li>'
).join('');
ul.addEventListener('click', e => {
const node = e.target.closest('[data-smart]');
if (!node) return;
const item = items.find(i => i.id === Number(node.dataset.smart));
if (!item) return;
document.dispatchEvent(new CustomEvent('dms:apply-smart', { detail: item.query || {} }));
});
} catch (_) { /* ignored */ }
},
async save(query) {
const name = prompt(I18N.dms_smart_name_prompt || 'Name for this smart folder:');
if (!name) return;
try {
await api('/saved-searches.php?action=create', { method: 'POST', body: { name, query } });
await this.load();
} catch (err) {
alert(err.message);
}
},
};
/* ─── Drag-anywhere upload overlay ─── */
function installDropAnywhereUpload(opts) {
opts = opts || {};
const overlay = document.createElement('div');
overlay.className = 'dms-drop-overlay';
overlay.innerHTML = '<span>📥</span><span>' + safe(I18N.dms_drop_here || 'Drop files to upload') + '</span>';
document.body.appendChild(overlay);
let depth = 0;
window.addEventListener('dragenter', e => {
if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes('Files')) {
depth++;
overlay.classList.add('is-visible');
}
});
window.addEventListener('dragleave', () => {
depth = Math.max(0, depth - 1);
if (depth === 0) overlay.classList.remove('is-visible');
});
window.addEventListener('dragover', e => {
if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes('Files')) e.preventDefault();
});
window.addEventListener('drop', async e => {
depth = 0;
overlay.classList.remove('is-visible');
if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) return;
e.preventDefault();
const files = Array.from(e.dataTransfer.files);
for (const file of files) {
await uploadOne(file, opts.getFolderId ? opts.getFolderId() : null);
}
document.dispatchEvent(new CustomEvent('dms:reload-required'));
});
}
async function uploadOne(file, folderId, versionAction) {
const fd = new FormData();
fd.append('file', file);
if (folderId && folderId !== 'all') fd.append('folder_id', String(folderId));
if (versionAction) fd.append('version_action', versionAction);
try {
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 chooseCollisionAction(file.name);
if (!action) return null;
return uploadOne(file, folderId, action);
}
if (!res.ok) throw new Error(json && (json.message || json.error) || 'Upload failed');
return json;
} catch (err) {
alert(file.name + ': ' + err.message);
return null;
}
}
function chooseCollisionAction(filename) {
return new Promise(resolve => {
openModal({
title: I18N.dms_collision_title || 'Document already exists',
body: '<p>' + safe(
(I18N.dms_collision_body || 'A document with the same title already exists in this folder ({name}). What should we do?').replace('{name}', filename)
) + '</p>',
actions: [
{ label: I18N.dms_cancel || 'Cancel', cls: 'dash-btn', onclick: () => { closeModal(); resolve(null); } },
{ label: I18N.dms_keep_both || 'Keep both', cls: 'dash-btn',
onclick: () => { closeModal(); resolve('force_separate'); } },
{ label: I18N.dms_save_version || 'Save as new version', cls: 'dash-btn dash-btn--primary',
onclick: () => { closeModal(); resolve('new'); } },
],
});
});
}
/* ─── Public surface ─── */
const DMS = {
api, safe, fmtDate, fmtRelative, fmtBytes, fileIcon,
Tree, List, Smart,
openModal, closeModal, openCtxMenu, closeCtxMenu,
folderModal,
pickFolder, installDropAnywhereUpload, uploadOne, chooseCollisionAction,
};
window.DBN_DMS = DMS;
})();
+122
View File
@@ -0,0 +1,122 @@
<?php
/**
* cron/dbn-dms-trash-purge.php
*
* Daily cron that hard-deletes any document/folder soft-deleted more than
* DBN_DMS_TRASH_RETENTION_DAYS ago.
*
* Pass --dry-run for a no-op preview.
*
* Crontab (on chloe, as dobetternorge):
* 15 3 * * * /usr/bin/php /home/dobetternorge/public_html/cron/dbn-dms-trash-purge.php >> /home/dobetternorge/logs/dms-purge.log 2>&1
*/
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit("CLI only.\n");
}
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$dryRun = in_array('--dry-run', $argv ?? [], true);
$cutoff = DBN_DMS_TRASH_RETENTION_DAYS;
try {
dbnToolsBootCaveau();
$db = getDb();
} catch (Throwable $e) {
fwrite(STDERR, "[dbn-dms-purge] DB connect failed: " . $e->getMessage() . "\n");
exit(1);
}
$start = microtime(true);
$purgedDocs = 0;
$purgedFolders = 0;
$skipped = 0;
$errors = 0;
try {
$docs = $db->prepare(
"SELECT id, client_id, storage_path
FROM client_documents
WHERE deleted_at IS NOT NULL AND deleted_at < (NOW() - INTERVAL ? DAY)
LIMIT 5000"
);
$docs->execute([$cutoff]);
foreach ($docs->fetchAll(PDO::FETCH_ASSOC) as $row) {
$docId = (int)$row['id'];
$clientId = (int)$row['client_id'];
$path = $row['storage_path'] ?? null;
if ($dryRun) {
echo "DRY: would purge document {$docId} (client {$clientId})\n";
$skipped++;
continue;
}
try {
// Version-file cleanup
$vr = $db->prepare('SELECT storage_path FROM client_document_versions WHERE document_id = ? AND client_id = ?');
$vr->execute([$docId, $clientId]);
foreach ($vr->fetchAll() as $v) {
if (!empty($v['storage_path']) && is_file($v['storage_path'])) @unlink($v['storage_path']);
}
// Chunks
try {
$db->prepare('DELETE FROM client_chunks WHERE client_id = ? AND document_id = ?')->execute([$clientId, $docId]);
} catch (Throwable $e) { /* tolerated */ }
// Qdrant
try {
if (class_exists('QdrantClient')) {
$qd = new QdrantClient();
$qd->deleteByFilter('bnl_client_chunks', [
'must' => [
['key' => 'client_id', 'match' => ['value' => $clientId]],
['key' => 'document_id', 'match' => ['value' => $docId]],
],
]);
}
} catch (Throwable $e) { /* tolerated */ }
// Disk
if ($path && is_file($path)) @unlink($path);
if ($path) {
$verDir = dirname($path) . '/' . $docId . '_versions';
if (is_dir($verDir)) {
foreach (glob($verDir . '/*') ?: [] as $f) @unlink($f);
@rmdir($verDir);
}
}
// Row
$db->prepare('DELETE FROM client_documents WHERE id = ? AND client_id = ?')->execute([$docId, $clientId]);
$purgedDocs++;
} catch (Throwable $e) {
$errors++;
fwrite(STDERR, "[dbn-dms-purge] doc {$docId}: " . $e->getMessage() . "\n");
}
}
if ($dryRun) {
$foldCount = $db->prepare("SELECT COUNT(*) FROM client_folders WHERE deleted_at IS NOT NULL AND deleted_at < (NOW() - INTERVAL ? DAY)");
$foldCount->execute([$cutoff]);
$skipped += (int)$foldCount->fetchColumn();
echo "DRY: would purge {$skipped} folders\n";
} else {
$fold = $db->prepare("DELETE FROM client_folders WHERE deleted_at IS NOT NULL AND deleted_at < (NOW() - INTERVAL ? DAY)");
$fold->execute([$cutoff]);
$purgedFolders = $fold->rowCount();
}
} catch (Throwable $e) {
fwrite(STDERR, "[dbn-dms-purge] fatal: " . $e->getMessage() . "\n");
exit(1);
}
$elapsed = round(microtime(true) - $start, 2);
$mode = $dryRun ? 'DRY-RUN' : 'live';
printf("[dbn-dms-purge] %s done in %ss: docs=%d folders=%d errors=%d skipped=%d\n",
$mode, $elapsed, $purgedDocs, $purgedFolders, $errors, $skipped);
+44 -1
View File
@@ -11,6 +11,22 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
</div>
<section class="dash-card" style="display:flex; flex-direction:column; min-height:60vh;">
<div class="dms-filters" style="margin-bottom:8px;">
<label style="font-size:13px;display:inline-flex;gap:6px;align-items:center;">
📁 <span>Scope:</span>
<select id="chatFolderScope">
<option value="all">All folders (whole tenant)</option>
<option value="unassigned">Unassigned only</option>
</select>
</label>
<label style="font-size:13px;display:inline-flex;align-items:center;gap:4px;">
<input type="checkbox" id="chatIncludeSub" checked> Include subfolders
</label>
<label style="font-size:13px;display:inline-flex;align-items:center;gap:4px;">
<input type="checkbox" id="chatIncludeRelated" checked> Show related authorities (graph)
</label>
</div>
<div id="chatLog" class="chat-log" aria-live="polite">
<div class="chat-empty" id="chatEmptyMsg"></div>
</div>
@@ -113,10 +129,19 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
let answer = '';
try {
const folderScope = document.getElementById('chatFolderScope');
const includeSub = document.getElementById('chatIncludeSub');
const includeRel = document.getElementById('chatIncludeRelated');
const body = { question, history: history.slice(0, -1) };
if (folderScope && folderScope.value && folderScope.value !== 'all') {
body.folder_id = folderScope.value;
body.include_subfolders = includeSub && includeSub.checked;
}
if (includeRel && includeRel.checked) body.include_related = true;
const resp = await fetch(api + '/chat-stream.php', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, history: history.slice(0, -1) }),
body: JSON.stringify(body),
});
if (!resp.ok || !resp.body) throw new Error('HTTP ' + resp.status);
@@ -152,6 +177,7 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
} else if (evName === 'done') {
history.push({ role: 'assistant', content: answer });
renderSources(sources, payload.sources || []);
renderRelated(aiNode, payload.related_documents || []);
const chunksTmpl = (I18N.chat_passages_meta || '{n} passages').replace('{n}', payload.chunks_used || 0);
meta.hidden = false;
meta.textContent = chunksTmpl + ' · ' + (payload.model || 'auto') + ' · ' + (payload.response_time_ms || 0) + ' ms';
@@ -190,6 +216,23 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
}).join('');
}
function renderRelated(node, related) {
if (!related || !related.length) return;
let rel = node.querySelector('.chat-related');
if (!rel) {
rel = document.createElement('div');
rel.className = 'chat-related';
rel.style.cssText = 'display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.4rem;';
const sources = node.querySelector('.chat-sources');
sources.parentNode.insertBefore(rel, sources.nextSibling);
}
const label = '<small style="opacity:.6;width:100%;display:block;">↳ Related authorities (graph):</small>';
rel.innerHTML = label + related.slice(0, 6).map(r =>
'<a class="chat-source-chip" href="/dashboard/document.php?id=' + r.doc_id + '" style="background:rgba(184,138,44,0.16);color:#6c5212;border-color:rgba(184,138,44,0.4)">'
+ safe(r.title || ('doc #' + r.doc_id)) + ' · ' + safe(r.shared) + '⋆</a>'
).join(' ');
}
function wireActions(node, question, answer) {
node.querySelector('.chat-copy').addEventListener('click', () => {
navigator.clipboard.writeText(answer).then(() => {
+130 -10
View File
@@ -77,15 +77,17 @@ $docId = (int)($_GET['id'] ?? 0);
+ '</div>'
+ '</div>'
+ '<nav class="dash-tabs" role="tablist">'
+ '<button class="dash-tab is-active" data-tab="preview" role="tab">' + (I18N.tab_preview || 'Preview') + '</button>'
+ '<button class="dash-tab" data-tab="chunks" role="tab">' + (I18N.tab_chunks || 'Passages') + ' (' + fmtNum(doc.chunk_count) + ')</button>'
+ '<button class="dash-tab" data-tab="related" role="tab">' + (I18N.tab_related || 'Related') + '</button>'
+ '<button class="dash-tab" data-tab="edit" role="tab">' + (I18N.tab_edit || 'Edit') + '</button>'
+ '<nav class="dash-tabs dms-tabs" role="tablist">'
+ '<button class="dash-tab dms-tab is-active" data-tab="preview" role="tab">' + (I18N.tab_preview || 'Preview') + '</button>'
+ '<button class="dash-tab dms-tab" data-tab="chunks" role="tab">' + (I18N.tab_chunks || 'Passages') + '<span class="dms-tab__pill">' + fmtNum(doc.chunk_count) + '</span></button>'
+ '<button class="dash-tab dms-tab" data-tab="related" role="tab">' + (I18N.tab_related || 'Related') + '</button>'
+ '<button class="dash-tab dms-tab" data-tab="versions" role="tab">Versions<span class="dms-tab__pill">v' + (doc.current_version || 1) + '</span></button>'
+ '<button class="dash-tab dms-tab" data-tab="permissions" role="tab">Access</button>'
+ '<button class="dash-tab dms-tab" data-tab="edit" role="tab">' + (I18N.tab_edit || 'Edit') + '</button>'
+ '</nav>'
+ '<div class="dash-tab-panel is-active" data-panel="preview">'
+ '<div class="dash-preview">' + safe(doc.content || (I18N.content_empty || '(empty)')) + '</div>'
+ '<div class="dash-tab-panel dms-tab-panel is-active" data-panel="preview">'
+ renderPreviewPanel(doc)
+ '</div>'
+ '<div class="dash-tab-panel" data-panel="chunks">'
@@ -97,12 +99,22 @@ $docId = (int)($_GET['id'] ?? 0);
: '<div class="dash-empty">' + (I18N.no_chunks || 'No passages indexed yet.') + '</div>')
+ '</div>'
+ '<div class="dash-tab-panel" data-panel="related">'
+ '<div class="dash-tab-panel dms-tab-panel" data-panel="related">'
+ '<div class="dash-loading" id="relatedLoading">' + (I18N.loading_related || 'Loading related authorities from the graph…') + '</div>'
+ '<div class="dash-related" id="relatedList" hidden></div>'
+ '</div>'
+ '<div class="dash-tab-panel" data-panel="edit">'
+ '<div class="dash-tab-panel dms-tab-panel" data-panel="versions">'
+ '<div class="dms-loading" id="versionsLoading">Loading versions…</div>'
+ '<div id="versionsList" hidden></div>'
+ '</div>'
+ '<div class="dash-tab-panel dms-tab-panel" data-panel="permissions">'
+ '<div class="dms-loading" id="permLoading">Loading access info…</div>'
+ '<div id="permPanel" hidden></div>'
+ '</div>'
+ '<div class="dash-tab-panel dms-tab-panel" data-panel="edit">'
+ '<form id="docEditForm" style="display:grid; gap:0.85rem; max-width:560px;">'
+ '<label>' + (I18N.field_title || 'Title') + '<input name="title" value="' + safe(doc.title) + '" style="width:100%;padding:0.5rem;border:1px solid var(--dbn-line);border-radius:8px;"></label>'
+ '<label>' + (I18N.field_category || 'Category') + '<input name="category" value="' + safe(doc.category || '') + '" style="width:100%;padding:0.5rem;border:1px solid var(--dbn-line);border-radius:8px;"></label>'
@@ -117,11 +129,117 @@ $docId = (int)($_GET['id'] ?? 0);
+ '</section>';
root.innerHTML = html;
window._dmsCurrentDoc = doc;
wireTabs();
wireDelete();
wireEdit();
}
function renderPreviewPanel(doc) {
const ext = (doc.original_filename || '').split('.').pop().toLowerCase();
const previewUrl = api + '/preview.php?id=' + doc.id;
if (doc.has_storage && ext === 'pdf') {
return '<iframe class="dms-preview-frame" src="' + previewUrl + '" title="PDF preview"></iframe>';
}
if (doc.has_storage && ['png','jpg','jpeg','webp','gif'].indexOf(ext) >= 0) {
return '<div style="text-align:center;padding:12px"><img src="' + previewUrl + '" style="max-width:100%;max-height:70vh;border-radius:10px;border:1px solid var(--dms-stroke)"></div>';
}
if (doc.has_storage && ['mp3','wav','m4a','ogg','flac','webm'].indexOf(ext) >= 0) {
return '<audio class="dms-preview-audio" controls src="' + previewUrl + '"></audio>'
+ '<details><summary>Transcript</summary><div class="dash-preview">' + safe(doc.content || '') + '</div></details>';
}
if (doc.has_storage && ext === 'docx') {
return '<div id="docxPreview" class="dms-preview-frame" style="padding:16px;overflow:auto;background:#fff"></div>'
+ '<script src="https://cdn.jsdelivr.net/npm/mammoth@1.6.0/mammoth.browser.min.js"><' + '/script>'
+ '<script>setTimeout(function(){fetch("' + previewUrl + '",{credentials:"same-origin"}).then(r=>r.arrayBuffer()).then(buf=>mammoth.convertToHtml({arrayBuffer:buf})).then(res=>{document.getElementById("docxPreview").innerHTML=res.value;}).catch(e=>{document.getElementById("docxPreview").textContent="Preview failed: "+e.message;});},10);<' + '/script>';
}
// Fallback: text preview from extracted content
return '<div class="dash-preview">' + safe(doc.content || (I18N.content_empty || '(empty)')) + '</div>'
+ (doc.has_storage ? '<p style="margin-top:8px"><a href="' + previewUrl + '&download=1" class="dash-btn">⬇ Download original</a></p>' : '');
}
let versionsLoaded = false;
function loadVersions() {
if (versionsLoaded) return;
versionsLoaded = true;
const wrap = document.getElementById('versionsList');
const loading = document.getElementById('versionsLoading');
fetch(api + '/document-versions.php?action=list&document_id=' + docId, { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
loading.hidden = true; wrap.hidden = false;
const versions = data.versions || [];
const cur = window._dmsCurrentDoc || {};
let html = '<div class="dms-version dms-version--current">'
+ '<div class="dms-version__num">v' + (cur.current_version || 1) + '</div>'
+ '<div><div class="dms-version__title">' + safe(cur.title) + '</div>'
+ '<div class="dms-version__meta">Current · ' + fmtDate(cur.updated_at || cur.created_at) + '</div></div>'
+ '<div class="dms-version__actions"></div></div>';
if (!versions.length) {
html += '<div class="dash-empty">No previous versions.</div>';
} else {
html += versions.map(v =>
'<div class="dms-version">'
+ '<div class="dms-version__num">v' + v.version_number + '</div>'
+ '<div><div class="dms-version__title">' + safe(v.title) + '</div>'
+ '<div class="dms-version__meta">' + fmtDate(v.created_at)
+ (v.uploaded_email ? ' · ' + safe(v.uploaded_email) : '')
+ (v.notes ? ' · ' + safe(v.notes) : '') + '</div></div>'
+ '<div class="dms-version__actions">'
+ '<button class="dash-btn" data-restore="' + v.id + '">Restore</button> '
+ '<button class="dash-btn dash-btn--danger" data-del-ver="' + v.id + '">✕</button>'
+ '</div></div>'
).join('');
}
wrap.innerHTML = html;
wrap.querySelectorAll('[data-restore]').forEach(b => b.addEventListener('click', () => restoreVersion(Number(b.dataset.restore))));
wrap.querySelectorAll('[data-del-ver]').forEach(b => b.addEventListener('click', () => deleteVersion(Number(b.dataset['delVer']))));
}).catch(e => { loading.textContent = 'Error: ' + e.message; });
}
function restoreVersion(vid) {
if (!confirm('Restore this version? Current version will be archived first.')) return;
fetch(api + '/document-versions.php?action=restore', {
method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ document_id: docId, version_id: vid })
}).then(r => r.json()).then(d => {
if (!d.ok) throw new Error(d.message || 'Restore failed');
location.reload();
}).catch(e => alert(e.message));
}
function deleteVersion(vid) {
if (!confirm('Delete this version permanently?')) return;
fetch(api + '/document-versions.php?action=delete', {
method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ version_id: vid })
}).then(r => r.json()).then(d => { versionsLoaded = false; loadVersions(); })
.catch(e => alert(e.message));
}
let permLoaded = false;
function loadPermissions() {
if (permLoaded) return;
permLoaded = true;
const loading = document.getElementById('permLoading');
const wrap = document.getElementById('permPanel');
fetch(api + '/documents.php?action=get&id=' + docId, { credentials: 'same-origin' })
.then(r => r.json()).then(d => {
loading.hidden = true; wrap.hidden = false;
const p = d.permissions || {};
const fid = (d.document && d.document.folder_id) || null;
let html = '<div class="dms-diag"><div class="dms-diag__row"><div class="dms-diag__label">Read</div><div></div><div>' + (p.can_read ? '<span class="dms-diag__status dms-diag__status--ok">allowed</span>' : '<span class="dms-diag__status dms-diag__status--err">denied</span>') + '</div></div>'
+ '<div class="dms-diag__row"><div class="dms-diag__label">Write</div><div></div><div>' + (p.can_write ? '<span class="dms-diag__status dms-diag__status--ok">allowed</span>' : '<span class="dms-diag__status dms-diag__status--warn">read-only</span>') + '</div></div>'
+ '<div class="dms-diag__row"><div class="dms-diag__label">Manage folder</div><div></div><div>' + (p.can_manage ? '<span class="dms-diag__status dms-diag__status--ok">allowed</span>' : '<span class="dms-diag__status dms-diag__status--warn">no</span>') + '</div></div></div>';
if (fid) {
html += '<p style="margin-top:12px"><a class="dash-btn" href="/dashboard/folders.php#' + fid + '">Manage access on parent folder →</a></p>';
} else {
html += '<p style="margin-top:12px;color:rgba(22,19,15,0.6)">This document is unassigned — move it into a folder to use folder ACLs.</p>';
}
wrap.innerHTML = html;
}).catch(e => { loading.textContent = 'Error: ' + e.message; });
}
function wireTabs() {
const tabs = root.querySelectorAll('.dash-tab');
const panels = root.querySelectorAll('.dash-tab-panel');
@@ -132,6 +250,8 @@ $docId = (int)($_GET['id'] ?? 0);
const panel = root.querySelector('[data-panel="' + t.dataset.tab + '"]');
if (panel) panel.classList.add('is-active');
if (t.dataset.tab === 'related') loadRelated();
if (t.dataset.tab === 'versions') loadVersions();
if (t.dataset.tab === 'permissions') loadPermissions();
}));
}
@@ -168,7 +288,7 @@ $docId = (int)($_GET['id'] ?? 0);
const btn = document.getElementById('docDelete');
if (!btn) return;
btn.addEventListener('click', () => {
if (!confirm(I18N.delete_doc_confirm || 'Delete this document permanently?')) return;
if (!confirm('Move to trash? You can restore within 30 days.')) return;
btn.disabled = true;
fetch(api + '/documents.php?action=delete', {
method: 'POST', credentials: 'same-origin',
+172 -167
View File
@@ -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>
<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="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 id="docListWrap"><p class="dash-loading"></p></div>
<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 class="dash-pager" id="docPager" hidden>
<span id="docPagerLabel"></span>
<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="docPagerPrev"><?= htmlspecialchars(dbnToolsT('dash_prev', $uiLang)) ?></button>
<button class="dash-btn" id="docPagerNext"><?= htmlspecialchars(dbnToolsT('dash_next', $uiLang)) ?></button>
<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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[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; }
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>';
}
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>';
});
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 */ }
}
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 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>';
}
}
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');
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>';
});
} 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;
}
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;
});
tbody.appendChild(tr);
});
table.appendChild(tbody);
$wrap.innerHTML = '';
$wrap.appendChild(table);
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>
+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'; ?>
+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>
+32 -1
View File
@@ -34,10 +34,20 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
<dt style="color:rgba(22,19,15,0.55);">Search method</dt>
<dd>Hybrid (vector + keyword), reciprocal rank fusion, private boost 1.5×</dd>
<dt style="color:rgba(22,19,15,0.55);">Graph DB</dt>
<dd><code>bnl_legal</code> in FalkorDB (Colin) — citation edges</dd>
<dd><code>dbn_client_graph</code> in FalkorDB (Colin) — citation edges</dd>
</dl>
</section>
<section class="dash-card">
<div class="dash-card__head">
<h2>Live diagnostics</h2>
<div class="dash-card__actions">
<button class="dash-btn" type="button" id="diagRefresh">↻ Refresh</button>
</div>
</div>
<div id="diagPanel" class="dms-diag"><div class="dms-loading"></div></div>
</section>
<section class="dash-card">
<div class="dash-card__head">
<h2><?= htmlspecialchars(dbnToolsT('dash_section_privacy', $uiLang)) ?></h2>
@@ -59,6 +69,27 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
document.getElementById('setClientId').textContent = d.clientId || '—';
document.getElementById('setCorpusId').textContent = d.corpusId || '—';
document.getElementById('setUserId').textContent = d.clientUserId || '—';
const $diag = document.getElementById('diagPanel');
async function loadDiag() {
$diag.innerHTML = '<div class="dms-loading"></div>';
try {
const r = await fetch((d.apiBase || '/api/dashboard') + '/diagnostics.php', { credentials:'same-origin' });
const data = await r.json();
if (!data.ok) throw new Error(data.message || 'Diagnostics unavailable');
const safe = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
$diag.innerHTML = data.sections.map(s =>
'<div class="dms-diag__row" title="' + safe(s.detail || '') + '">' +
'<div class="dms-diag__label">' + safe(s.label) + '</div>' +
'<div class="dms-diag__value">' + safe(s.value) + '</div>' +
'<div><span class="dms-diag__status dms-diag__status--' + safe(s.status || 'warn') + '">' + safe(s.status) + '</span></div></div>'
).join('');
} catch (e) {
$diag.innerHTML = '<div class="dms-list__empty"><strong>Could not run diagnostics</strong><span>' + e.message + '</span></div>';
}
}
document.getElementById('diagRefresh').addEventListener('click', loadDiag);
loadDiag();
})();
</script>
+129
View File
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
$dashboardPage = 'trash';
$dashboardTitle = dbnToolsT('dash_title_trash', dbnToolsCurrentLanguage()) ?: 'Trash';
$dashboardLead = dbnToolsT('dash_lead_trash', dbnToolsCurrentLanguage()) ?: 'Items here are auto-deleted after 30 days.';
require_once __DIR__ . '/../includes/layout_dashboard.php';
?>
<section class="dms-shell dms-shell--single">
<div class="dms-main">
<div class="dms-toolbar">
<div class="dms-toolbar__crumbs"><span>🗑 Trash</span></div>
<div class="dms-toolbar__actions">
<button class="dash-btn" type="button" id="trashRestoreSel" disabled>Restore selected</button>
<button class="dash-btn dash-btn--danger" type="button" id="trashPurgeAll">Empty trash (admin)</button>
</div>
</div>
<div id="trashList"><div class="dms-loading"></div></div>
</div>
</section>
<script>
(function () {
'use strict';
const DMS = window.DBN_DMS;
const $list = document.getElementById('trashList');
const $restore = document.getElementById('trashRestoreSel');
const $purge = document.getElementById('trashPurgeAll');
const selected = new Set();
async function load() {
$list.innerHTML = '<div class="dms-loading"></div>';
try {
const data = await DMS.api('/trash.php?action=list&limit=200');
const items = data.items || [];
if (!items.length) {
$list.innerHTML = '<div class="dms-list"><div class="dms-list__empty"><strong>Trash is empty</strong><span>Nothing to restore.</span></div></div>';
return;
}
const rows = items.map(it => {
const id = (it.kind === 'document' ? 'd' : 'f') + it.id;
const expires = (it.expires_in_days ?? 0) + 'd left';
return '<div class="dms-list__row" data-key="' + id + '" data-kind="' + it.kind + '" data-id="' + it.id + '">' +
'<input type="checkbox" class="dms-trash-sel">' +
'<div class="dms-list__title">' +
'<span class="dms-list__title-icon">' + (it.kind === 'folder' ? '📁' : DMS.fileIcon(it.source_type)) + '</span>' +
DMS.safe(it.title || it.name || '(untitled)') +
'</div>' +
'<div class="dms-list__cell dms-list__cell--muted">' + DMS.safe(it.kind) + '</div>' +
'<div class="dms-list__cell dms-list__cell--muted">' + DMS.fmtRelative(it.deleted_at) + '</div>' +
'<div class="dms-list__cell dms-list__cell--muted">' + expires + '</div>' +
'<div class="dms-list__cell"></div>' +
'<button class="dms-list__more" data-id="' + it.id + '" data-kind="' + it.kind + '" type="button">⋯</button>' +
'</div>';
}).join('');
$list.innerHTML = '<div class="dms-list">' +
'<div class="dms-list__head"><span></span><span>Title</span><span>Kind</span><span>Trashed</span><span>Expires</span><span></span><span></span></div>' +
rows + '</div>';
$list.querySelectorAll('.dms-trash-sel').forEach(cb => {
cb.addEventListener('change', e => {
const row = e.target.closest('.dms-list__row');
if (e.target.checked) selected.add(row.dataset.key); else selected.delete(row.dataset.key);
$restore.disabled = selected.size === 0;
});
});
$list.querySelectorAll('.dms-list__more').forEach(btn => {
btn.addEventListener('click', e => {
e.preventDefault();
const id = Number(btn.dataset.id);
const kind = btn.dataset.kind;
DMS.openCtxMenu(btn.getBoundingClientRect().left, btn.getBoundingClientRect().bottom, [
{ label: 'Restore', icon: '↩', onclick: () => restoreItems([{kind, id}]) },
'-',
{ label: 'Delete permanently', icon: '🔥', danger: true,
onclick: () => purgeItems([{kind, id}]) },
]);
});
});
} catch (e) {
$list.innerHTML = '<div class="dms-list__empty"><strong>Error</strong><span>' + DMS.safe(e.message) + '</span></div>';
}
}
async function restoreItems(items) {
const body = { document_ids: [], folder_ids: [] };
items.forEach(i => {
if (i.kind === 'folder') body.folder_ids.push(i.id);
else body.document_ids.push(i.id);
});
try {
await DMS.api('/trash.php?action=restore', { method: 'POST', body });
selected.clear();
$restore.disabled = true;
load();
} catch (e) { alert(e.message); }
}
async function purgeItems(items) {
if (!confirm('Permanently delete? This cannot be undone.')) return;
const body = { document_ids: [], folder_ids: [] };
items.forEach(i => {
if (i.kind === 'folder') body.folder_ids.push(i.id);
else body.document_ids.push(i.id);
});
try {
await DMS.api('/trash.php?action=purge', { method: 'POST', body });
load();
} catch (e) { alert(e.message); }
}
$restore.addEventListener('click', () => {
const items = Array.from(selected).map(k => ({ kind: k[0] === 'd' ? 'document' : 'folder', id: Number(k.slice(1)) }));
restoreItems(items);
});
$purge.addEventListener('click', async () => {
if (!confirm('Permanently delete ALL items in trash? This cannot be undone.')) return;
try {
await DMS.api('/trash.php?action=purge', { method: 'POST', body: { all: true } });
load();
} catch (e) { alert(e.message); }
});
load();
})();
</script>
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
+52 -5
View File
@@ -18,10 +18,19 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
<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" hidden>
<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>
@@ -139,13 +148,51 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c])); }
forms.file.addEventListener('submit', (e) => {
e.preventDefault();
// 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…');
fetch(api + '/upload.php', { method: 'POST', credentials: 'same-origin', body: fd })
.then(r => r.json()).then(handleResult).catch(err => setStatus('❌ ' + safe(err.message), 'err'));
try {
const data = await postFile();
if (data) handleResult(data);
} catch (err) { setStatus('❌ ' + safe(err.message), 'err'); }
});
forms.text.addEventListener('submit', (e) => {
+13 -3
View File
@@ -891,7 +891,9 @@ function dbnToolsExcerpt(string $text, int $limit = 520): string
const DBN_TOOLS_EXTRACT_MAX_BYTES = 8 * 1024 * 1024;
const DBN_TOOLS_EXTRACT_TEXT_LIMIT = 128000;
const DBN_TOOLS_TIMELINE_EXTRACT_TEXT_LIMIT = 600000;
const DBN_TOOLS_EXTRACT_ALLOWED_EXTS = ['txt', 'pdf', 'docx'];
const DBN_TOOLS_EXTRACT_ALLOWED_EXTS = ['txt', 'pdf', 'docx', 'xlsx', 'pptx', 'html', 'htm', 'csv', 'md', 'json'];
const DBN_TOOLS_EXTRACT_AUDIO_EXTS = ['mp3', 'wav', 'm4a', 'ogg', 'flac', 'webm'];
const DBN_TOOLS_EXTRACT_IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'webp'];
function dbnToolsExtractUploadedFile(array $file, int $textLimit = DBN_TOOLS_EXTRACT_TEXT_LIMIT): array
{
@@ -922,13 +924,19 @@ function dbnToolsExtractUploadedFile(array $file, int $textLimit = DBN_TOOLS_EXT
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
if (!in_array($ext, DBN_TOOLS_EXTRACT_ALLOWED_EXTS, true)) {
dbnToolsAbort('Unsupported file type. Upload a .pdf, .docx, or .txt file.', 422, 'unsupported_type');
$allowed = strtoupper(implode(', .', DBN_TOOLS_EXTRACT_ALLOWED_EXTS));
dbnToolsAbort("Unsupported file type. Allowed: .{$allowed}.", 422, 'unsupported_type');
}
$text = match ($ext) {
'txt' => dbnToolsExtractTxt($tmpPath),
'txt', 'md', 'json' => dbnToolsExtractTxt($tmpPath),
'pdf' => dbnToolsExtractPdf($tmpPath),
'docx' => dbnToolsExtractDocx($tmpPath),
'html', 'htm' => dbnDmsExtractHtml($tmpPath),
'csv' => dbnDmsExtractCsv($tmpPath),
'xlsx' => dbnDmsExtractXlsx($tmpPath),
'pptx' => dbnDmsExtractPptx($tmpPath),
default => dbnToolsExtractTxt($tmpPath),
};
$text = trim($text);
@@ -1370,3 +1378,5 @@ function dbnToolsInjectDocContent(array $input, string $text): string
}
return $docText . ($text !== '' ? "\n\n---\n\n" . $text : '');
}
require_once __DIR__ . '/dms_helpers.php';
+471
View File
@@ -0,0 +1,471 @@
<?php
declare(strict_types=1);
/**
* DMS helpers: folder ACLs, storage paths, audit logging, version bookkeeping.
* Loaded from bootstrap.php so all dashboard pages + APIs get them implicitly.
*/
const DBN_DMS_MAX_FOLDER_DEPTH = 2; // Matches ai-portal app-layer cap. Raise here to unlock deeper nesting.
const DBN_DMS_MAX_VERSIONS_PER_DOC = 20; // Oldest auto-pruned beyond this.
const DBN_DMS_TRASH_RETENTION_DAYS = 30;
const DBN_DMS_DEFAULT_CATEGORIES = [
['slug' => 'uncategorized', 'label' => 'Uncategorized', 'color' => '#94a3b8', 'icon' => 'folder', 'sort_order' => 0],
['slug' => 'legal', 'label' => 'Legal', 'color' => '#1d4ed8', 'icon' => 'scale', 'sort_order' => 10],
['slug' => 'financial', 'label' => 'Financial', 'color' => '#047857', 'icon' => 'chart', 'sort_order' => 20],
['slug' => 'internal', 'label' => 'Internal', 'color' => '#7c3aed', 'icon' => 'building', 'sort_order' => 30],
['slug' => 'hr', 'label' => 'HR', 'color' => '#db2777', 'icon' => 'people', 'sort_order' => 40],
['slug' => 'marketing', 'label' => 'Marketing', 'color' => '#b88a2c', 'icon' => 'megaphone', 'sort_order' => 50],
];
/**
* Resolve the on-disk storage path for an uploaded document.
* Production: /home/dobetternorge/uploads/{client_id}/{document_id}.{ext}
* Local dev: DBN_TOOLS_UPLOAD_ROOT env override, else DBN_TOOLS_ROOT/uploads/...
*/
function dbnDmsStoragePath(int $clientId, int $documentId, string $ext, ?int $versionNumber = null): string
{
$root = dbnToolsEnv('DBN_TOOLS_UPLOAD_ROOT', '');
if ($root === '' || $root === null) {
$root = is_dir('/home/dobetternorge/uploads')
? '/home/dobetternorge/uploads'
: DBN_TOOLS_ROOT . '/uploads';
}
$ext = preg_replace('/[^a-z0-9]/', '', strtolower($ext)) ?: 'bin';
$clientDir = rtrim($root, '/') . '/' . $clientId;
if (!is_dir($clientDir)) {
@mkdir($clientDir, 0750, true);
}
if ($versionNumber !== null && $versionNumber > 0) {
$versionDir = $clientDir . '/' . $documentId . '_versions';
if (!is_dir($versionDir)) {
@mkdir($versionDir, 0750, true);
}
return $versionDir . '/v' . $versionNumber . '.' . $ext;
}
return $clientDir . '/' . $documentId . '.' . $ext;
}
/**
* Stream an uploaded file into permanent storage. Returns the storage_path string,
* or null if persistence is disabled (no upload root and not writable).
*/
function dbnDmsPersistFile(string $tmpPath, int $clientId, int $documentId, string $ext, ?int $versionNumber = null): ?string
{
$dest = dbnDmsStoragePath($clientId, $documentId, $ext, $versionNumber);
$dir = dirname($dest);
if (!is_dir($dir) || !is_writable($dir)) {
return null;
}
if (!@copy($tmpPath, $dest)) {
return null;
}
@chmod($dest, 0640);
return $dest;
}
/**
* Walk the folder tree starting at $folderId upward; returns the chain root→leaf.
* Returns [] if $folderId is null/0 (root).
*/
function dbnDmsFolderChain(?int $folderId, int $clientId): array
{
if (!$folderId) {
return [];
}
$db = dbnToolsDb();
$chain = [];
$current = $folderId;
$guard = 0;
while ($current && $guard++ < 50) {
$stmt = $db->prepare('SELECT id, name, parent_id, color FROM client_folders WHERE id = ? AND client_id = ? AND deleted_at IS NULL');
$stmt->execute([$current, $clientId]);
$row = $stmt->fetch();
if (!$row) {
break;
}
$chain[] = $row;
$current = $row['parent_id'] ? (int)$row['parent_id'] : 0;
}
return array_reverse($chain);
}
/**
* Resolve the breadcrumb for a folder as [{id, name}, …] starting at the root.
* Returns [] when at corpus root.
*/
function dbnDmsBreadcrumb(?int $folderId, int $clientId): array
{
return array_map(fn($r) => ['id' => (int)$r['id'], 'name' => (string)$r['name'], 'color' => $r['color'] ?? null],
dbnDmsFolderChain($folderId, $clientId));
}
/**
* Folder depth, where root-level = 1. Used to enforce DBN_DMS_MAX_FOLDER_DEPTH.
*/
function dbnDmsFolderDepth(?int $folderId, int $clientId): int
{
if (!$folderId) {
return 0;
}
return count(dbnDmsFolderChain($folderId, $clientId));
}
/**
* Check whether the current user can act on $folderId with $perm = 'read'|'write'|'manage'.
* Permission resolution:
* - tenant owner/admin role → always allowed
* - walk folder chain leaf→root; first matching ACL row wins
* - no ACL rows anywhere → open (default)
*/
function dbnDmsUserCanAccessFolder(?int $folderId, string $perm, int $clientId, int $userId, string $tenantRole = 'viewer'): bool
{
// Tenant root is always readable; only manage requires editor+.
if (!$folderId) {
if ($perm === 'manage' || $perm === 'write') {
return in_array($tenantRole, ['editor', 'admin', 'owner'], true);
}
return true;
}
if (in_array($tenantRole, ['admin', 'owner'], true)) {
return true;
}
$db = dbnToolsDb();
$chain = dbnDmsFolderChain($folderId, $clientId);
if (!$chain) {
return false;
}
$col = match ($perm) {
'write' => 'can_write',
'manage' => 'can_manage',
default => 'can_read',
};
$anyAcl = false;
foreach (array_reverse($chain) as $folder) {
$stmt = $db->prepare(
"SELECT min_role, user_id, can_read, can_write, can_manage
FROM client_folder_permissions
WHERE folder_id = ? AND client_id = ?"
);
$stmt->execute([(int)$folder['id'], $clientId]);
$rows = $stmt->fetchAll();
if (!$rows) {
continue;
}
$anyAcl = true;
foreach ($rows as $row) {
if ($row['user_id'] !== null && (int)$row['user_id'] === $userId) {
if ((int)$row[$col] === 1) return true;
}
if ($row['min_role'] !== null) {
if (dbnDmsRoleAtLeast($tenantRole, (string)$row['min_role']) && (int)$row[$col] === 1) {
return true;
}
}
}
// ACL at this level but user not granted — block (no inheritance past explicit restriction).
return false;
}
// No ACL rows anywhere → open per migration 119 convention.
return !$anyAcl;
}
function dbnDmsRoleAtLeast(string $userRole, string $minRole): bool
{
$rank = ['viewer' => 0, 'editor' => 1, 'admin' => 2, 'owner' => 3];
return ($rank[$userRole] ?? 0) >= ($rank[$minRole] ?? 0);
}
/**
* Append an audit row. Failure is swallowed — auditing must never break the request.
*/
function dbnDmsLogAudit(int $clientId, ?int $userId, string $action, array $details = [], ?int $documentId = null, ?int $folderId = null): void
{
try {
$db = dbnToolsDb();
$stmt = $db->prepare(
'INSERT INTO client_document_audit (client_id, user_id, document_id, folder_id, action, details, ip_addr, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())'
);
$stmt->execute([
$clientId,
$userId ?: null,
$documentId ?: null,
$folderId ?: null,
substr($action, 0, 40),
$details ? json_encode($details, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
substr((string)($_SERVER['REMOTE_ADDR'] ?? ''), 0, 45),
]);
} catch (Throwable $e) {
error_log('[dbn-dms] audit insert failed: ' . $e->getMessage());
}
}
/**
* Seed default categories for a tenant if their dictionary is empty.
* Idempotent — safe to call on every dashboard page load.
*/
function dbnDmsSeedDefaultCategoriesIfEmpty(int $clientId): void
{
try {
$db = dbnToolsDb();
$check = $db->prepare('SELECT COUNT(*) FROM client_categories WHERE client_id = ?');
$check->execute([$clientId]);
if ((int)$check->fetchColumn() > 0) {
return;
}
$ins = $db->prepare(
'INSERT INTO client_categories (client_id, slug, label, color, icon, sort_order, is_system)
VALUES (?, ?, ?, ?, ?, ?, 1)'
);
foreach (DBN_DMS_DEFAULT_CATEGORIES as $cat) {
$ins->execute([
$clientId,
$cat['slug'],
$cat['label'],
$cat['color'],
$cat['icon'],
$cat['sort_order'],
]);
}
} catch (Throwable $e) {
// Likely table doesn't exist yet (migration not applied).
error_log('[dbn-dms] seed categories failed: ' . $e->getMessage());
}
}
/**
* Snapshot a document into client_document_versions before overwriting.
* Returns the new version_number, or 0 on failure.
*/
function dbnDmsSnapshotVersion(int $documentId, int $clientId, ?int $userId, ?string $notes = null): int
{
$db = dbnToolsDb();
$doc = $db->prepare('SELECT * FROM client_documents WHERE id = ? AND client_id = ?');
$doc->execute([$documentId, $clientId]);
$row = $doc->fetch();
if (!$row) {
return 0;
}
$next = (int)($row['current_version'] ?? 1);
$ins = $db->prepare(
'INSERT INTO client_document_versions
(document_id, client_id, version_number, title, content, file_size_bytes,
original_filename, storage_path, word_count, uploaded_by, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())'
);
$ins->execute([
$documentId,
$clientId,
$next,
(string)$row['title'],
(string)($row['content'] ?? ''),
(int)($row['file_size_bytes'] ?? 0),
$row['original_filename'] ?? null,
$row['storage_path'] ?? null,
(int)($row['word_count'] ?? 0),
$userId ?: null,
$notes,
]);
// Prune oldest versions beyond cap.
$count = $db->prepare('SELECT COUNT(*) FROM client_document_versions WHERE document_id = ?');
$count->execute([$documentId]);
$total = (int)$count->fetchColumn();
if ($total > DBN_DMS_MAX_VERSIONS_PER_DOC) {
$prune = $db->prepare(
'DELETE FROM client_document_versions
WHERE document_id = ?
ORDER BY version_number ASC
LIMIT ' . ($total - DBN_DMS_MAX_VERSIONS_PER_DOC)
);
$prune->execute([$documentId]);
}
return $next;
}
/**
* Convenience: file extension from an upload array (original_filename) or filename string.
*/
function dbnDmsExtensionFromFilename(string $filename): string
{
$dot = strrpos($filename, '.');
if ($dot === false) {
return '';
}
return strtolower(substr($filename, $dot + 1));
}
/**
* Extract plain text from HTML (strip tags, decode entities).
*/
function dbnDmsExtractHtml(string $path): string
{
$raw = file_get_contents($path);
if ($raw === false) {
throw new DbnToolsHttpException('Unable to read HTML file.', 500, 'read_error');
}
$raw = mb_convert_encoding($raw, 'UTF-8', 'UTF-8, ISO-8859-1, Windows-1252');
$raw = preg_replace('#<script\b[^>]*>.*?</script>#is', '', $raw) ?? $raw;
$raw = preg_replace('#<style\b[^>]*>.*?</style>#is', '', $raw) ?? $raw;
$text = trim(html_entity_decode(strip_tags($raw), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
return preg_replace("/[\r\n]{3,}/", "\n\n", $text) ?? $text;
}
/**
* Extract CSV as readable text (header row repeated each line for context).
*/
function dbnDmsExtractCsv(string $path): string
{
$fh = @fopen($path, 'rb');
if (!$fh) {
throw new DbnToolsHttpException('Unable to read CSV file.', 500, 'read_error');
}
$lines = [];
$header = null;
$rowNum = 0;
while (($row = fgetcsv($fh, 0, ',', '"', '\\')) !== false) {
$row = array_map(fn($c) => (string)$c, $row);
if ($header === null) {
$header = $row;
$lines[] = implode(' | ', $header);
continue;
}
$pairs = [];
foreach ($row as $i => $cell) {
$col = $header[$i] ?? "col{$i}";
if ($cell !== '') {
$pairs[] = $col . ': ' . $cell;
}
}
$lines[] = '- ' . implode('; ', $pairs);
if (++$rowNum > 5000) {
$lines[] = '... (truncated)';
break;
}
}
fclose($fh);
return implode("\n", $lines);
}
/**
* Extract plain text from XLSX (concatenate sharedStrings + cell values).
* Lightweight — no PhpSpreadsheet dependency.
*/
function dbnDmsExtractXlsx(string $path): string
{
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
throw new DbnToolsHttpException('Unable to open XLSX file.', 422, 'xlsx_open_failed');
}
$shared = [];
$sharedXml = $zip->getFromName('xl/sharedStrings.xml');
if ($sharedXml !== false) {
if (preg_match_all('#<t[^>]*>(.*?)</t>#s', $sharedXml, $m)) {
foreach ($m[1] as $s) {
$shared[] = html_entity_decode(strip_tags($s), ENT_QUOTES | ENT_XML1, 'UTF-8');
}
}
}
$out = [];
for ($i = 1; $i < 100; $i++) {
$sheet = $zip->getFromName("xl/worksheets/sheet{$i}.xml");
if ($sheet === false) break;
// Inline strings + numeric/text values.
if (preg_match_all('#<c\b[^>]*?(?:\s+t="([^"]*)")?[^>]*>(.*?)</c>#s', $sheet, $m, PREG_SET_ORDER)) {
$cells = [];
foreach ($m as $cell) {
$type = $cell[1] ?? '';
$inner = $cell[2];
if ($type === 's') {
if (preg_match('#<v>(\d+)</v>#', $inner, $vm)) {
$idx = (int)$vm[1];
if (isset($shared[$idx])) $cells[] = $shared[$idx];
}
} elseif ($type === 'inlineStr') {
if (preg_match('#<t[^>]*>(.*?)</t>#s', $inner, $tm)) {
$cells[] = html_entity_decode(strip_tags($tm[1]), ENT_QUOTES | ENT_XML1, 'UTF-8');
}
} else {
if (preg_match('#<v>(.*?)</v>#', $inner, $vm)) {
$cells[] = $vm[1];
}
}
}
$out[] = "=== Sheet {$i} ===\n" . implode("\t", $cells);
}
}
$zip->close();
$text = implode("\n\n", $out);
if (trim($text) === '') {
throw new DbnToolsHttpException('No readable content in XLSX.', 422, 'xlsx_empty');
}
return $text;
}
/**
* Extract plain text from PPTX (slide notes + text frames).
*/
function dbnDmsExtractPptx(string $path): string
{
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
throw new DbnToolsHttpException('Unable to open PPTX file.', 422, 'pptx_open_failed');
}
$slides = [];
for ($i = 1; $i < 500; $i++) {
$xml = $zip->getFromName("ppt/slides/slide{$i}.xml");
if ($xml === false) break;
$text = [];
if (preg_match_all('#<a:t[^>]*>(.*?)</a:t>#s', $xml, $m)) {
foreach ($m[1] as $t) {
$text[] = html_entity_decode(strip_tags($t), ENT_QUOTES | ENT_XML1, 'UTF-8');
}
}
if ($text) {
$slides[] = "=== Slide {$i} ===\n" . implode("\n", $text);
}
}
$zip->close();
if (!$slides) {
throw new DbnToolsHttpException('No readable content in PPTX.', 422, 'pptx_empty');
}
return implode("\n\n", $slides);
}
/**
* Convenience: MIME type → safe content type for inline preview/download streaming.
*/
function dbnDmsContentTypeForExt(string $ext): string
{
return match (strtolower($ext)) {
'pdf' => 'application/pdf',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'txt' => 'text/plain; charset=utf-8',
'md' => 'text/markdown; charset=utf-8',
'csv' => 'text/csv; charset=utf-8',
'html', 'htm' => 'text/html; charset=utf-8',
'json' => 'application/json',
'mp3' => 'audio/mpeg',
'wav' => 'audio/wav',
'm4a' => 'audio/mp4',
'ogg' => 'audio/ogg',
'png' => 'image/png',
'jpg', 'jpeg' => 'image/jpeg',
'webp' => 'image/webp',
default => 'application/octet-stream',
};
}
+4
View File
@@ -45,8 +45,10 @@ if ($dashAuthUser !== null) {
$dashboardNav = [
'index' => ['url' => '/dashboard/', 'label' => dbnToolsT('dash_nav_overview', $uiLang), 'sub' => 'Overview'],
'documents' => ['url' => '/dashboard/documents.php', 'label' => dbnToolsT('dash_nav_documents', $uiLang), 'sub' => 'Documents'],
'folders' => ['url' => '/dashboard/folders.php', 'label' => dbnToolsT('dash_nav_folders', $uiLang) ?: 'Folders', 'sub' => 'Folder tree & access'],
'upload' => ['url' => '/dashboard/upload.php', 'label' => dbnToolsT('dash_nav_upload', $uiLang), 'sub' => 'Upload'],
'chat' => ['url' => '/dashboard/chat.php', 'label' => dbnToolsT('dash_nav_ask', $uiLang), 'sub' => 'Ask'],
'trash' => ['url' => '/dashboard/trash.php', 'label' => dbnToolsT('dash_nav_trash', $uiLang) ?: 'Trash', 'sub' => 'Restore or purge'],
'settings' => ['url' => '/dashboard/settings.php', 'label' => dbnToolsT('dash_nav_settings', $uiLang), 'sub' => 'Settings'],
];
?>
@@ -59,6 +61,8 @@ $dashboardNav = [
<link rel="stylesheet" href="../assets/css/tools.css">
<link rel="stylesheet" href="../assets/css/dashboard.css">
<link rel="stylesheet" href="../assets/css/dbn-tools-redesign.css">
<link rel="stylesheet" href="../assets/css/dms.css">
<script src="../assets/js/dashboard/dms.js" defer></script>
</head>
<body data-authenticated="true" data-dashboard-page="<?= htmlspecialchars($dashboardPage) ?>">
<script>
@@ -0,0 +1,49 @@
-- DBN DMS migration 001 — document versioning
-- Adds client_document_versions table + current_version/storage_path columns on client_documents.
-- Safe to re-run: uses IF NOT EXISTS / INFORMATION_SCHEMA guards.
CREATE TABLE IF NOT EXISTS client_document_versions (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
document_id INT UNSIGNED NOT NULL,
client_id INT UNSIGNED NOT NULL,
version_number INT UNSIGNED NOT NULL,
title VARCHAR(500) NOT NULL,
content LONGTEXT NOT NULL,
file_size_bytes INT UNSIGNED DEFAULT 0,
original_filename VARCHAR(255) NULL,
storage_path VARCHAR(500) NULL,
word_count INT UNSIGNED DEFAULT 0,
uploaded_by INT UNSIGNED NULL,
notes VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_doc_ver (document_id, version_number),
KEY idx_client (client_id),
KEY idx_uploaded_by (uploaded_by),
CONSTRAINT fk_cdv_doc FOREIGN KEY (document_id) REFERENCES client_documents(id) ON DELETE CASCADE,
CONSTRAINT fk_cdv_client FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Per-document version history. Latest = client_documents.current_version.';
-- current_version column (guarded against re-run)
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'client_documents'
AND COLUMN_NAME = 'current_version'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE client_documents ADD COLUMN current_version INT UNSIGNED NOT NULL DEFAULT 1 AFTER chunk_count',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- storage_path column (guarded)
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'client_documents'
AND COLUMN_NAME = 'storage_path'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE client_documents ADD COLUMN storage_path VARCHAR(500) NULL AFTER original_filename',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+34
View File
@@ -0,0 +1,34 @@
-- DBN DMS migration 002 — soft-delete trash
-- Adds deleted_at / deleted_by columns to client_documents and client_folders.
-- client_documents.deleted_at
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'client_documents' AND COLUMN_NAME = 'deleted_at'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE client_documents
ADD COLUMN deleted_at DATETIME NULL AFTER updated_at,
ADD COLUMN deleted_by INT UNSIGNED NULL AFTER deleted_at,
ADD KEY idx_deleted (deleted_at)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- client_folders.deleted_at (folders table may not exist if migration 119 hasn't run; guard table too)
SET @tbl_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'client_folders'
);
SET @col_exists := IF(@tbl_exists = 0, 1,
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'client_folders' AND COLUMN_NAME = 'deleted_at')
);
SET @sql := IF(@tbl_exists = 1 AND @col_exists = 0,
'ALTER TABLE client_folders
ADD COLUMN deleted_at DATETIME NULL,
ADD COLUMN deleted_by INT UNSIGNED NULL,
ADD KEY idx_deleted (deleted_at)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -0,0 +1,20 @@
-- DBN DMS migration 003 — saved searches / smart folders
CREATE TABLE IF NOT EXISTS client_saved_searches (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
client_id INT UNSIGNED NOT NULL,
user_id INT UNSIGNED NOT NULL,
name VARCHAR(120) NOT NULL,
icon VARCHAR(40) NULL,
color VARCHAR(7) NULL,
query_json JSON NOT NULL COMMENT '{q, category, tags[], folder_id, source_type, status, include_subfolders}',
is_shared TINYINT(1) NOT NULL DEFAULT 0,
sort_order SMALLINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_client_user (client_id, user_id),
KEY idx_shared (client_id, is_shared),
CONSTRAINT fk_css_client FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE,
CONSTRAINT fk_css_user FOREIGN KEY (user_id) REFERENCES client_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Persisted filter sets ("smart folders"). is_shared=1 = visible to whole tenant.';
@@ -0,0 +1,19 @@
-- DBN DMS migration 004 — tenant-managed categories
-- client_documents.category continues to store the slug as VARCHAR (back-compat).
-- This table just describes display metadata + lets tenants curate the list.
CREATE TABLE IF NOT EXISTS client_categories (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
client_id INT UNSIGNED NOT NULL,
slug VARCHAR(50) NOT NULL,
label VARCHAR(100) NOT NULL,
color VARCHAR(7) NULL,
icon VARCHAR(40) NULL,
sort_order SMALLINT NOT NULL DEFAULT 0,
is_system TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = seeded default, cannot be deleted',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_client_slug (client_id, slug),
KEY idx_client (client_id),
CONSTRAINT fk_cc_client FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Per-tenant category dictionary. Slug is the value used in client_documents.category.';
+19
View File
@@ -0,0 +1,19 @@
-- DBN DMS migration 005 — audit log
-- Powers the "Recent activity" widget on the dashboard overview.
CREATE TABLE IF NOT EXISTS client_document_audit (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
client_id INT UNSIGNED NOT NULL,
user_id INT UNSIGNED NULL,
document_id INT UNSIGNED NULL,
folder_id INT UNSIGNED NULL,
action VARCHAR(40) NOT NULL
COMMENT 'upload|view|edit|move|rename|version|delete|restore|share|download|search|chat|category|tag',
details JSON NULL,
ip_addr VARCHAR(45) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_client_time (client_id, created_at),
KEY idx_doc (document_id),
KEY idx_user_time (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Append-only audit trail for DMS activity. Document/folder FKs intentionally not enforced — keep history after deletes.';