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