From 2e2b0b45fa3111e497c02f9cf25d0fc5439a710e Mon Sep 17 00:00:00 2001 From: davegilligan Date: Tue, 26 May 2026 22:24:56 +0200 Subject: [PATCH] Full DMS: folders + ACLs, versioning, trash, bulk ops, preview, smart folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/dashboard/bulk.php | 183 +++++ api/dashboard/categories.php | 209 +++++ api/dashboard/chat-stream.php | 56 ++ api/dashboard/diagnostics.php | 165 ++++ api/dashboard/document-versions.php | 206 +++++ api/dashboard/documents.php | 462 ++++++++--- api/dashboard/folders.php | 493 ++++++++++++ api/dashboard/preview.php | 118 +++ api/dashboard/saved-searches.php | 222 ++++++ api/dashboard/trash.php | 257 +++++++ api/dashboard/upload.php | 252 +++++- assets/css/dms.css | 719 ++++++++++++++++++ assets/js/dashboard/dms.js | 696 +++++++++++++++++ cron/dbn-dms-trash-purge.php | 122 +++ dashboard/chat.php | 45 +- dashboard/document.php | 140 +++- dashboard/documents.php | 349 ++++----- dashboard/folders.php | 164 ++++ dashboard/index.php | 60 ++ dashboard/settings.php | 33 +- dashboard/trash.php | 129 ++++ dashboard/upload.php | 57 +- includes/bootstrap.php | 20 +- includes/dms_helpers.php | 471 ++++++++++++ includes/layout_dashboard.php | 4 + migrations/dbn-dms/001_dbn_dms_versions.sql | 49 ++ migrations/dbn-dms/002_dbn_dms_trash.sql | 34 + .../dbn-dms/003_dbn_dms_saved_searches.sql | 20 + migrations/dbn-dms/004_dbn_dms_categories.sql | 19 + migrations/dbn-dms/005_dbn_dms_audit.sql | 19 + 30 files changed, 5438 insertions(+), 335 deletions(-) create mode 100644 api/dashboard/bulk.php create mode 100644 api/dashboard/categories.php create mode 100644 api/dashboard/diagnostics.php create mode 100644 api/dashboard/document-versions.php create mode 100644 api/dashboard/folders.php create mode 100644 api/dashboard/preview.php create mode 100644 api/dashboard/saved-searches.php create mode 100644 api/dashboard/trash.php create mode 100644 assets/css/dms.css create mode 100644 assets/js/dashboard/dms.js create mode 100644 cron/dbn-dms-trash-purge.php create mode 100644 dashboard/folders.php create mode 100644 dashboard/trash.php create mode 100644 includes/dms_helpers.php create mode 100644 migrations/dbn-dms/001_dbn_dms_versions.sql create mode 100644 migrations/dbn-dms/002_dbn_dms_trash.sql create mode 100644 migrations/dbn-dms/003_dbn_dms_saved_searches.sql create mode 100644 migrations/dbn-dms/004_dbn_dms_categories.sql create mode 100644 migrations/dbn-dms/005_dbn_dms_audit.sql diff --git a/api/dashboard/bulk.php b/api/dashboard/bulk.php new file mode 100644 index 0000000..f892dcd --- /dev/null +++ b/api/dashboard/bulk.php @@ -0,0 +1,183 @@ +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()]; +} diff --git a/api/dashboard/categories.php b/api/dashboard/categories.php new file mode 100644 index 0000000..6a941ba --- /dev/null +++ b/api/dashboard/categories.php @@ -0,0 +1,209 @@ +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, 1–50, [a-z0-9-_]).', 422, 'invalid_slug'); + } + if ($label === '' || mb_strlen($label, 'UTF-8') > 100) { + dbnToolsError('Label is required (1–100).', 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)]); +} diff --git a/api/dashboard/chat-stream.php b/api/dashboard/chat-stream.php index 23b1516..c4e6749 100644 --- a/api/dashboard/chat-stream.php +++ b/api/dashboard/chat-stream.php @@ -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()]); diff --git a/api/dashboard/diagnostics.php b/api/dashboard/diagnostics.php new file mode 100644 index 0000000..79b9620 --- /dev/null +++ b/api/dashboard/diagnostics.php @@ -0,0 +1,165 @@ +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, +]); diff --git a/api/dashboard/document-versions.php b/api/dashboard/document-versions.php new file mode 100644 index 0000000..4149390 --- /dev/null +++ b/api/dashboard/document-versions.php @@ -0,0 +1,206 @@ +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]); +} diff --git a/api/dashboard/documents.php b/api/dashboard/documents.php index fd82423..47783c7 100644 --- a/api/dashboard/documents.php +++ b/api/dashboard/documents.php @@ -1,17 +1,25 @@ getMessage(), $e->status, $e->errorCode); } -$clientId = (int)$tenant['client_id']; + +$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(); -switch ($action) { - case 'list': - dbnToolsRequireMethod('GET'); - respondList($db, $clientId); - break; - case 'get': - dbnToolsRequireMethod('GET'); - respondGet($db, $clientId); - break; - case 'update': - dbnToolsRequireMethod('POST'); - respondUpdate($db, $clientId); - break; - case 'delete': - dbnToolsRequireMethod('POST'); - respondDelete($db, $clientId); - break; - default: - dbnToolsError('Unknown action.', 400, 'unknown_action'); +try { + switch ($action) { + case 'list': + dbnToolsRequireMethod('GET'); + respondList($db, $clientId, $userId, $tenantRole); + break; + case 'get': + dbnToolsRequireMethod('GET'); + respondGet($db, $clientId, $userId, $tenantRole); + break; + case 'update': + dbnToolsRequireMethod('POST'); + respondUpdate($db, $clientId, $userId, $tenantRole); + break; + case 'delete': + dbnToolsRequireMethod('POST'); + 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))); - $q = trim((string)($_GET['q'] ?? '')); - $status = trim((string)($_GET['status'] ?? '')); + $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) . '%'; + $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'); } - - $chunks = $db->prepare( - 'SELECT id, content, section_title - FROM client_chunks - WHERE client_id = ? AND document_id = ? - ORDER BY id ASC - LIMIT 200' - ); - try { - $chunks->execute([$clientId, $id]); - $chunkRows = $chunks->fetchAll(PDO::FETCH_ASSOC); - } catch (Throwable $e) { - $chunkRows = []; + $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' + ); + $chunks->execute([$clientId, $id]); + $chunkRows = $chunks->fetchAll(); + } catch (Throwable $e) { + // 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']], - 'chunks' => $chunkRows, + 'ok' => true, + '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,48 +426,78 @@ 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), - 'title' => (string)($row['title'] ?? ''), - 'source_type' => (string)($row['source_type'] ?? ''), - 'language' => (string)($row['language'] ?? ''), - 'category' => (string)($row['category'] ?? ''), - 'tags' => (string)($row['tags'] ?? ''), - 'author' => $row['author'] ?? null, - 'source_url' => $row['source_url'] ?? null, - 'source_tool' => $row['source_tool'] ?? null, - 'import_method' => (string)($row['import_method'] ?? ''), - 'status' => (string)($row['status'] ?? ''), - 'word_count' => (int)($row['word_count'] ?? 0), - 'chunk_count' => (int)($row['chunk_count'] ?? 0), - 'file_size_bytes'=> (int)($row['file_size_bytes'] ?? 0), - 'error_message' => $row['error_message'] ?? null, - 'created_at' => (string)($row['created_at'] ?? ''), - 'updated_at' => (string)($row['updated_at'] ?? ''), + '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'] ?? ''), + 'category' => (string)($row['category'] ?? ''), + 'tags' => (string)($row['tags'] ?? ''), + 'author' => $row['author'] ?? null, + 'source_url' => $row['source_url'] ?? null, + 'source_tool' => $row['source_tool'] ?? null, + 'import_method' => (string)($row['import_method'] ?? ''), + 'status' => (string)($row['status'] ?? ''), + '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; +} diff --git a/api/dashboard/folders.php b/api/dashboard/folders.php new file mode 100644 index 0000000..4877106 --- /dev/null +++ b/api/dashboard/folders.php @@ -0,0 +1,493 @@ +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 (1–200 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 (1–200) 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); + } + } +} diff --git a/api/dashboard/preview.php b/api/dashboard/preview.php new file mode 100644 index 0000000..089e731 --- /dev/null +++ b/api/dashboard/preview.php @@ -0,0 +1,118 @@ + 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; diff --git a/api/dashboard/saved-searches.php b/api/dashboard/saved-searches.php new file mode 100644 index 0000000..3e206b7 --- /dev/null +++ b/api/dashboard/saved-searches.php @@ -0,0 +1,222 @@ +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 (1–120 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; +} diff --git a/api/dashboard/trash.php b/api/dashboard/trash.php new file mode 100644 index 0000000..a0654b2 --- /dev/null +++ b/api/dashboard/trash.php @@ -0,0 +1,257 @@ +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); +} diff --git a/api/dashboard/upload.php b/api/dashboard/upload.php index d96521a..06f842d 100644 --- a/api/dashboard/upload.php +++ b/api/dashboard/upload.php @@ -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']; + +$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; + $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,18 +119,29 @@ 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'] ?? '')); - if ($title === '') dbnToolsError('title is required.', 400, 'missing_title'); - if (mb_strlen($content, 'UTF-8') < 30) dbnToolsError('content too short (min 30 chars).', 400, 'content_too_short'); + if ($title === '') dbnToolsError('title is required.', 400, 'missing_title'); + 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)); diff --git a/assets/css/dms.css b/assets/css/dms.css new file mode 100644 index 0000000..cc37544 --- /dev/null +++ b/assets/css/dms.css @@ -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; + } +} diff --git a/assets/js/dashboard/dms.js b/assets/js/dashboard/dms.js new file mode 100644 index 0000000..08a2c97 --- /dev/null +++ b/assets/js/dashboard/dms.js @@ -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 => ({ '&':'&','<':'<','>':'>','"':'"' }[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('
' + safe(I18N.dms_folders || 'Folders') + '
'); + html.push(''); + + html.push('
' + safe(I18N.dms_smart || 'Smart folders') + '
'); + html.push(''); + + html.push(''); + html.push(''); + + html.push('
' + safe(I18N.dms_system || 'System') + '
'); + html.push(''); + + 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 ? ('' + node.doc_count + '') : ''; + const dot = node.color ? ('') : ''; + const icon = node.icon || dot || 'πŸ“'; + const tag = opts.href ? 'a' : 'div'; + const href = opts.href ? (' href="' + safe(opts.href) + '"') : ''; + return '
  • <'+tag+' class="dms-tree__node ' + (isActive ? 'is-active' : '') + '"' + + ' data-folder-id="' + safe(node.id) + '"' + href + '>' + + '' + (typeof icon === 'string' && icon.length < 3 ? icon : dot) + '' + + '' + safe(node.name) + '' + + count + '
  • '; + }, + + _renderNode(node, active, depth) { + const hasChildren = node.children && node.children.length; + const isActive = String(node.id) === active; + const html = []; + html.push('
  • '); + html.push('
    '); + html.push('β–Έ'); + html.push(''); + html.push('' + safe(node.name) + ''); + if (node.doc_count) html.push('' + node.doc_count + ''); + html.push('
    '); + if (hasChildren) { + html.push('
      '); + node.children.forEach(c => html.push(this._renderNode(c, active, depth + 1))); + html.push('
    '); + } + html.push('
  • '); + 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: + '
    ' + + '
    ' + + '
    ' + + '
    ', + 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) => + '' + ).join(''); + _modalRoot.innerHTML = + '
    ' + + '
    ' + + '

    ' + safe(opts.title || '') + '

    ' + + '' + + '
    ' + + '
    ' + (opts.body || '') + '
    ' + + (opts.actions ? '
    ' + actionsHtml + '
    ' : '') + + '
    '; + 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 '
    '; + return '
    ' + + (it.icon ? '' + it.icon + '' : '') + + safe(it.label) + '
    '; + }).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 = + '
    ' + + '
    ' + + '' + safe(I18N.dms_empty_title || 'No documents here yet') + '' + + '' + safe(I18N.dms_empty_hint || 'Drag files anywhere, or click Upload.') + '' + + '
    '; + return; + } + const sortHead = (key, label) => { + const active = this.state.sort === key; + const arrow = active ? (this.state.dir === 'asc' ? ' ↑' : ' ↓') : ''; + return ''; + }; + + const rows = docs.map(d => { + const isSel = this.state.selected.has(d.id); + const cat = d.category ? '' + safe(d.category) + '' : ''; + const tags = (d.tags || '').split(',').filter(Boolean).slice(0,3) + .map(t => '' + safe(t.trim()) + '').join(' '); + return '
    ' + + '' + + '
    ' + fileIcon(d.source_type) + '' + + '' + safe(d.title || '(untitled)') + '
    ' + + '
    ' + cat + ' ' + tags + '
    ' + + '
    ' + safe(d.author || '') + '
    ' + + '
    ' + fmtRelative(d.updated_at || d.created_at) + '
    ' + + '
    ' + (d.file_size_bytes ? fmtBytes(d.file_size_bytes) : 'β€”') + '
    ' + + '' + + '
    '; + }).join(''); + + container.innerHTML = + '
    ' + + '
    ' + + '' + + sortHead('title', I18N.dms_col_title || 'Title') + + '' + safe(I18N.dms_col_meta || 'Category Β· Tags') + '' + + '' + safe(I18N.dms_col_author || 'Author') + '' + + sortHead('updated_at', I18N.dms_col_updated || 'Updated') + + sortHead('file_size_bytes', I18N.dms_col_size || 'Size') + + '' + + '
    ' + rows + + '
    ' + + (this.state.selected.size ? this._bulkBar() : ''); + + this._wire(container); + }, + + _bulkBar() { + const n = this.state.selected.size; + return '
    ' + + '' + n + ' ' + safe(I18N.dms_selected || 'selected') + '' + + '' + + '' + + '' + + '' + + '' + + '
    '; + }, + + _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 = [''] + .concat(flat.map(f => '')).join(''); + openModal({ + title, + body: '
    ' + + '
    ', + 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 = '
  • ' + + safe(I18N.dms_smart_empty || '(none yet)') + '
  • '; + return; + } + ul.innerHTML = items.map(s => + '
  • ' + + '' + (s.icon || 'β˜…') + '' + + '' + safe(s.name) + '' + + (s.is_shared ? '∞' : '') + + '
  • ' + ).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 = 'πŸ“₯' + safe(I18N.dms_drop_here || 'Drop files to upload') + ''; + 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: '

    ' + safe( + (I18N.dms_collision_body || 'A document with the same title already exists in this folder ({name}). What should we do?').replace('{name}', filename) + ) + '

    ', + 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; +})(); diff --git a/cron/dbn-dms-trash-purge.php b/cron/dbn-dms-trash-purge.php new file mode 100644 index 0000000..0a5b850 --- /dev/null +++ b/cron/dbn-dms-trash-purge.php @@ -0,0 +1,122 @@ +> /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); diff --git a/dashboard/chat.php b/dashboard/chat.php index a13d3a0..d7fcecd 100644 --- a/dashboard/chat.php +++ b/dashboard/chat.php @@ -11,6 +11,22 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
    +
    + + + +
    +
    @@ -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 = '↳ Related authorities (graph):'; + rel.innerHTML = label + related.slice(0, 6).map(r => + '' + + safe(r.title || ('doc #' + r.doc_id)) + ' Β· ' + safe(r.shared) + '⋆' + ).join(' '); + } + function wireActions(node, question, answer) { node.querySelector('.chat-copy').addEventListener('click', () => { navigator.clipboard.writeText(answer).then(() => { diff --git a/dashboard/document.php b/dashboard/document.php index ba094a7..dfc6b42 100644 --- a/dashboard/document.php +++ b/dashboard/document.php @@ -77,15 +77,17 @@ $docId = (int)($_GET['id'] ?? 0); + '' + '' - + '
    '; 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 ''; + } + if (doc.has_storage && ['png','jpg','jpeg','webp','gif'].indexOf(ext) >= 0) { + return '
    '; + } + if (doc.has_storage && ['mp3','wav','m4a','ogg','flac','webm'].indexOf(ext) >= 0) { + return '' + + '
    Transcript
    ' + safe(doc.content || '') + '
    '; + } + if (doc.has_storage && ext === 'docx') { + return '
    ' + + ' diff --git a/dashboard/folders.php b/dashboard/folders.php new file mode 100644 index 0000000..6e62de2 --- /dev/null +++ b/dashboard/folders.php @@ -0,0 +1,164 @@ + +
    + +
    +
    +
    Pick a folder on the left
    +
    + +
    +
    +
    +
    + Select a folder + Use the tree on the left, or create a new folder. +
    +
    +
    +
    + + + + diff --git a/dashboard/index.php b/dashboard/index.php index 882a6c8..6fdb818 100644 --- a/dashboard/index.php +++ b/dashboard/index.php @@ -40,6 +40,13 @@ require_once __DIR__ . '/../includes/layout_dashboard.php'; +
    +

    Storage used

    β€”

    across all documents

    +

    Folders

    β€”

    organising your library

    +

    In trash

    β€”

    auto-purges after 30d

    +

    Smart folders

    β€”

    saved views

    +
    +

    @@ -50,6 +57,11 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
    +
    +

    Recent activity

    +
    +
    + diff --git a/dashboard/settings.php b/dashboard/settings.php index dce840e..e90f71a 100644 --- a/dashboard/settings.php +++ b/dashboard/settings.php @@ -34,10 +34,20 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
    Search method
    Hybrid (vector + keyword), reciprocal rank fusion, private boost 1.5Γ—
    Graph DB
    -
    bnl_legal in FalkorDB (Colin) β€” citation edges
    +
    dbn_client_graph in FalkorDB (Colin) β€” citation edges
    +
    +
    +

    Live diagnostics

    +
    + +
    +
    +
    +
    +

    @@ -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 = '
    '; + 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 => ({ '&':'&','<':'<','>':'>','"':'"' }[c])); + $diag.innerHTML = data.sections.map(s => + '
    ' + + '
    ' + safe(s.label) + '
    ' + + '
    ' + safe(s.value) + '
    ' + + '
    ' + safe(s.status) + '
    ' + ).join(''); + } catch (e) { + $diag.innerHTML = '
    Could not run diagnostics' + e.message + '
    '; + } + } + document.getElementById('diagRefresh').addEventListener('click', loadDiag); + loadDiag(); })(); diff --git a/dashboard/trash.php b/dashboard/trash.php new file mode 100644 index 0000000..f7e1e4e --- /dev/null +++ b/dashboard/trash.php @@ -0,0 +1,129 @@ + +
    +
    +
    +
    πŸ—‘ Trash
    +
    + + +
    +
    + +
    +
    +
    + + + + diff --git a/dashboard/upload.php b/dashboard/upload.php index 3e0cf62..16efedb 100644 --- a/dashboard/upload.php +++ b/dashboard/upload.php @@ -18,10 +18,19 @@ require_once __DIR__ . '/../includes/layout_dashboard.php'; +
    @@ -139,13 +148,51 @@ require_once __DIR__ . '/../includes/layout_dashboard.php'; function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[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 = [''].concat( + flat.map(f => '') + ); + 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) => { diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 016a836..2f7c12d 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -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), - 'pdf' => dbnToolsExtractPdf($tmpPath), - 'docx' => dbnToolsExtractDocx($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'; diff --git a/includes/dms_helpers.php b/includes/dms_helpers.php new file mode 100644 index 0000000..c6820b0 --- /dev/null +++ b/includes/dms_helpers.php @@ -0,0 +1,471 @@ + '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('#]*>.*?#is', '', $raw) ?? $raw; + $raw = preg_replace('#]*>.*?#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('#]*>(.*?)#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('#]*?(?:\s+t="([^"]*)")?[^>]*>(.*?)#s', $sheet, $m, PREG_SET_ORDER)) { + $cells = []; + foreach ($m as $cell) { + $type = $cell[1] ?? ''; + $inner = $cell[2]; + if ($type === 's') { + if (preg_match('#(\d+)#', $inner, $vm)) { + $idx = (int)$vm[1]; + if (isset($shared[$idx])) $cells[] = $shared[$idx]; + } + } elseif ($type === 'inlineStr') { + if (preg_match('#]*>(.*?)#s', $inner, $tm)) { + $cells[] = html_entity_decode(strip_tags($tm[1]), ENT_QUOTES | ENT_XML1, 'UTF-8'); + } + } else { + if (preg_match('#(.*?)#', $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('#]*>(.*?)#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', + }; +} diff --git a/includes/layout_dashboard.php b/includes/layout_dashboard.php index 2c8ae43..ef673e0 100644 --- a/includes/layout_dashboard.php +++ b/includes/layout_dashboard.php @@ -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 = [ + +