= 2; $can_delete = lookupUserPermission('module_support') >= 3; $folder_tree = folderTree($client_id); // The folder on show - one of this client's, or the top level $folder_id = intval($_GET['folder_id'] ?? 0); if (!isset($folder_tree[$folder_id])) { $folder_id = 0; } $folder_path = folderPath($folder_tree, $folder_id); $folder_name = $folder_id ? escapeHtml($folder_tree[$folder_id]['folder_name']) : 'Top level'; // '' everything, 'file' uploads only, 'document' created documents only, 'due' documents due a review $type_filter = $_GET['type'] ?? ''; if (!in_array($type_filter, ['file', 'document', 'due'], true)) { $type_filter = ''; } // 0 list, 1 grid $view = intval($_GET['view'] ?? 0) === 1 ? 1 : 0; // Whitelisted - anything else sorts by name $sort = in_array($_GET['sort'] ?? '', ['name', 'type', 'size', 'updated'], true) ? $_GET['sort'] : 'name'; $order = ($_GET['order'] ?? '') === 'DESC' ? 'DESC' : 'ASC'; // A search at the top level looks in every folder; inside a folder, just that one $search = trim((string) ($_GET['q'] ?? '')); $search_everywhere = $search !== '' && $folder_id === 0; // Tags - documents with any of them, and no uploads, which have none. At the // top level that is in every folder, like a search $tag_filter_ids = tagFilterIds(); $look_everywhere = $search_everywhere || ($tag_filter_ids && $folder_id === 0) || $type_filter === 'due'; /** This page's URL with some parameters changed - the rest (folder, type, view, search, archived) ride along. */ function filesUrl($changes = []) { $params = array_merge($_GET, $changes); unset($params['page']); foreach ($params as $key => $value) { if ($value === '' || $value === null || ($key !== 'client_id' && $value === 0)) { unset($params[$key]); } } return '?' . http_build_query($params); } // ---- Folder counts: one grouped query each, not one per folder ---- $folder_counts = []; foreach ([['files', 'file'], ['documents', 'document']] as [$table, $prefix]) { $sql = mysqli_query($mysqli, "SELECT {$prefix}_folder_id AS folder_id, COUNT(*) AS num FROM $table WHERE {$prefix}_client_id = $client_id AND {$prefix}_$archive_query GROUP BY {$prefix}_folder_id"); while ($row = mysqli_fetch_assoc($sql)) { $count_folder = isset($folder_tree[intval($row['folder_id'])]) ? intval($row['folder_id']) : 0; $folder_counts[$count_folder] = ($folder_counts[$count_folder] ?? 0) + intval($row['num']); } } // ---- Documents due a review, and the required templates the client has no document from ---- $due_documents = $archived ? [] : documentsReviewDue("documents.document_client_id = $client_id"); $missing_templates = $archived ? [] : documentTemplatesMissingFor($client_id); // ---- What is on show: files and documents, merged and sorted here ---- $search_sql = escapeSql($search); $file_where = "file_client_id = $client_id AND file_$archive_query" . ($look_everywhere ? '' : " AND file_folder_id = $folder_id") . ($tag_filter_ids || $type_filter === 'due' ? ' AND 0' : ''); $document_where = "document_client_id = $client_id AND document_$archive_query" . ($look_everywhere ? '' : " AND document_folder_id = $folder_id") . ' ' . tagFilterSql('document', $tag_filter_ids, 'documents.document_id') . ($type_filter === 'due' ? ' AND documents.document_id IN (' . ($due_documents ? implode(',', array_keys($due_documents)) : '0') . ')' : ''); if ($search !== '') { $file_where .= " AND (file_name LIKE '%$search_sql%' OR file_ext LIKE '%$search_sql%' OR file_description LIKE '%$search_sql%')"; $document_where .= " AND (MATCH(document_content_raw) AGAINST ('$search_sql') OR document_name LIKE '%$search_sql%' OR document_description LIKE '%$search_sql%' " . tagSearchSql('document', $search_sql, 'documents.document_id') . ")"; } $items = []; $type_counts = ['file' => 0, 'document' => 0]; $sql_files = mysqli_query($mysqli, "SELECT file_id, file_name, file_description, file_reference_name, file_ext, file_size, file_mime_type, file_created_at, file_updated_at, file_archived_at, file_folder_id, user_name FROM files LEFT JOIN users ON file_created_by = user_id WHERE $file_where"); while ($row = mysqli_fetch_assoc($sql_files)) { $type_counts['file']++; if ($type_filter === 'document') { continue; } $info = fileTypeInfo($row['file_ext']); $items[] = [ 'kind' => 'file', 'id' => intval($row['file_id']), 'name' => $row['file_name'], 'description' => $row['file_description'], 'type' => $info['label'], 'size' => intval($row['file_size']), 'updated' => $row['file_updated_at'] ?: $row['file_created_at'], 'by' => $row['user_name'], 'folder_id' => intval($row['file_folder_id']), 'archived' => $row['file_archived_at'], 'row' => $row, ]; } $sql_documents = mysqli_query($mysqli, "SELECT document_id, document_name, document_description, document_client_visible, document_created_at, document_updated_at, document_archived_at, document_folder_id, " . ($view === 1 ? 'document_content,' : '') . " COALESCE(updater.user_name, creator.user_name) AS user_name FROM documents LEFT JOIN users creator ON document_created_by = creator.user_id LEFT JOIN users updater ON document_updated_by = updater.user_id WHERE $document_where"); while ($row = mysqli_fetch_assoc($sql_documents)) { $type_counts['document']++; if ($type_filter === 'file') { continue; } $items[] = [ 'kind' => 'document', 'id' => intval($row['document_id']), 'name' => $row['document_name'], 'description' => $row['document_description'], 'type' => 'Document', 'size' => -1, 'updated' => $row['document_updated_at'] ?: $row['document_created_at'], 'by' => $row['user_name'], 'folder_id' => intval($row['document_folder_id']), 'archived' => $row['document_archived_at'], 'row' => $row, ]; } usort($items, function ($a, $b) use ($sort, $order) { if ($sort === 'type') { $result = strcasecmp($a['type'], $b['type']); } elseif ($sort === 'size') { $result = $a['size'] <=> $b['size']; } elseif ($sort === 'updated') { $result = strcmp((string) $a['updated'], (string) $b['updated']); } else { $result = 0; } // Name breaks every tie, so the order never shuffles between page loads $result = $result ?: strnatcasecmp($a['name'], $b['name']); return $order === 'DESC' ? -$result : $result; }); $num_rows = [count($items)]; $items = array_slice($items, $record_from, $record_to); // ---- For the page on show only: shares and link counts, batched ---- $page_ids = ['file' => [], 'document' => []]; foreach ($items as $item) { $page_ids[$item['kind']][] = $item['id']; } $shared = []; foreach (['file' => 'File', 'document' => 'Document'] as $kind => $share_type) { if (!$page_ids[$kind]) { continue; } $sql = mysqli_query($mysqli, "SELECT item_related_id, item_recipient, item_expire_at FROM shared_items WHERE item_client_id = $client_id AND item_type = '$share_type' AND " . shareOpenSql() . " AND item_related_id IN (" . implode(',', $page_ids[$kind]) . ")"); while ($row = mysqli_fetch_assoc($sql)) { $shared[$kind][intval($row['item_related_id'])] = $row; } } $documents_tags = tagsForRecords('document', $page_ids['document']); $link_counts = []; foreach (['file', 'document'] as $kind) { if (!$page_ids[$kind]) { continue; } $ids = implode(',', $page_ids[$kind]); foreach (linkTypesFor($kind) as $to) { $relation = linkRelation($kind, $to); $to_entity = LINK_ENTITIES[$to]; $sql = mysqli_query($mysqli, "SELECT l.{$kind}_id AS id, COUNT(*) AS num FROM {$relation['table']} l JOIN {$to_entity['table']} t ON t.{$to_entity['id']} = l.{$to_entity['id']} AND t.{$to_entity['archived']} IS NULL WHERE l.{$kind}_id IN ($ids) GROUP BY l.{$kind}_id"); while ($row = mysqli_fetch_assoc($sql)) { $link_counts[$kind][intval($row['id'])][$to] = intval($row['num']); } } } /** The linked-record chips for one row: an icon and a count per kind. */ function filesLinkChips($kind, $id) { global $link_counts; $html = ''; foreach ($link_counts[$kind][$id] ?? [] as $to => $num) { $entity = LINK_ENTITIES[$to]; $html .= '' . $num . ''; } return $html; } /** The ⋮ menu for one file or document - the same in the list and the grid. */ function filesItemMenu($item) { global $client_id, $can_edit, $can_delete; $id = $item['id']; $csrf = $_SESSION['csrf_token']; $archived = !empty($item['archived']); $html = ''; if ($item['kind'] === 'file') { $html .= "Details"; $html .= "Download"; if (!$archived && shareCanMake('File')) { $html .= "Share"; } if ($can_edit && !$archived) { $html .= '
'; $html .= "Rename"; $html .= "Move"; $html .= "Link"; } $archive_name = 'file'; } else { $html .= "Open"; $html .= "Quick view"; if (!$archived && shareCanMake('Document')) { $html .= "Share"; } if ($can_edit && !$archived) { $html .= ''; $html .= "Edit"; $html .= "Rename"; $html .= "Move"; $html .= "Link"; } $archive_name = 'document'; } if ($can_edit) { $html .= ''; if ($archived) { $html .= "Restore"; if ($can_delete) { $html .= $item['kind'] === 'file' ? "Delete" : "Delete"; } } else { $html .= "Archive"; } } return $html; } /** "in Folder / Sub" for a search result outside the folder on show. */ function filesFolderNote($item) { global $folder_tree, $folder_id, $client_id; if ($item['folder_id'] === $folder_id || !isset($folder_tree[$item['folder_id']])) { return ''; } $path = array_column(folderPath($folder_tree, $item['folder_id']), 'folder_name'); return '' . escapeHtml($path ? implode(' / ', $path) : 'Top level') . ''; } /** One folder and its subfolders in the tree. */ function filesFolderTree($parent_id, $depth = 0) { global $folder_tree, $folder_id, $folder_counts, $folder_path, $can_edit, $can_delete, $client_id; $open_path = array_column($folder_path, 'folder_id'); $html = ''; foreach ($folder_tree[$parent_id]['children'] as $id) { $folder = $folder_tree[$id]; $has_children = (bool) $folder['children']; $is_open = in_array($id, $open_path, true); $count = $folder_counts[$id] ?? 0; $name = escapeHtml($folder['folder_name']); $menu = ''; if ($can_edit) { $menu = ''; } $html .= '