A fork of https://github.com/crosspoint-reader/crosspoint-reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: enhance file deletion functionality with multi-select (#682)

## Summary

* **What is the goal of this PR?** Enhances the file manager with
multi-select deletion functionality and improved UI formatting.
* **What changes are included?**
* Added multi-select capability for file deletion in the web interface
* Fixed formatting issues in file table for folder rows
* Updated [.gitignore] to exclude additional build artifacts and cache
files
* Refactored CrossPointWebServer.cpp to support batch file deletion
* Enhanced FilesPage.html with improved UI for file selection and
deletion

## Additional Context

* The file deletion endpoint now handles multiple files in a single
request, improving efficiency when removing multiple files
* Changes are focused on the web file manager component only

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**PARTIALLY**_

---------

Co-authored-by: Jessica Harrison <jessica.harrison@entelect.co.za>
Co-authored-by: Dave Allie <dave@daveallie.com>

authored by

Jessica765
Jessica Harrison
Dave Allie
and committed by
GitHub
786b438e 6e4d0e53

+181 -104
+1
.gitignore
··· 10 10 **/__pycache__/ 11 11 /compile_commands.json 12 12 /.cache 13 + .history/ 13 14 /.venv
+81 -56
src/network/CrossPointWebServer.cpp
··· 914 914 } 915 915 916 916 void CrossPointWebServer::handleDelete() const { 917 - // Get path from form data 918 - if (!server->hasArg("path")) { 919 - server->send(400, "text/plain", "Missing path"); 917 + // Check if 'paths' argument is provided 918 + if (!server->hasArg("paths")) { 919 + server->send(400, "text/plain", "Missing paths"); 920 920 return; 921 921 } 922 922 923 - String itemPath = server->arg("path"); 924 - const String itemType = server->hasArg("type") ? server->arg("type") : "file"; 923 + // Parse paths 924 + String pathsArg = server->arg("paths"); 925 + JsonDocument doc; 926 + DeserializationError error = deserializeJson(doc, pathsArg); 927 + if (error) { 928 + server->send(400, "text/plain", "Invalid paths format"); 929 + return; 930 + } 925 931 926 - // Validate path 927 - if (itemPath.isEmpty() || itemPath == "/") { 928 - server->send(400, "text/plain", "Cannot delete root directory"); 932 + auto paths = doc.as<JsonArray>(); 933 + if (paths.isNull() || paths.size() == 0) { 934 + server->send(400, "text/plain", "No paths provided"); 929 935 return; 930 936 } 931 937 932 - // Ensure path starts with / 933 - if (!itemPath.startsWith("/")) { 934 - itemPath = "/" + itemPath; 935 - } 938 + // Iterate over paths and delete each item 939 + bool allSuccess = true; 940 + String failedItems; 936 941 937 - // Security check: prevent deletion of protected items 938 - const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1); 942 + for (const auto& p : paths) { 943 + auto itemPath = p.as<String>(); 939 944 940 - // Check if item starts with a dot (hidden/system file) 941 - if (itemName.startsWith(".")) { 942 - LOG_DBG("WEB", "Delete rejected - hidden/system item: %s", itemPath.c_str()); 943 - server->send(403, "text/plain", "Cannot delete system files"); 944 - return; 945 - } 945 + // Validate path 946 + if (itemPath.isEmpty() || itemPath == "/") { 947 + failedItems += itemPath + " (cannot delete root); "; 948 + allSuccess = false; 949 + continue; 950 + } 946 951 947 - // Check against explicitly protected items 948 - for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) { 949 - if (itemName.equals(HIDDEN_ITEMS[i])) { 950 - LOG_DBG("WEB", "Delete rejected - protected item: %s", itemPath.c_str()); 951 - server->send(403, "text/plain", "Cannot delete protected items"); 952 - return; 952 + // Ensure path starts with / 953 + if (!itemPath.startsWith("/")) { 954 + itemPath = "/" + itemPath; 953 955 } 954 - } 956 + 957 + // Security check: prevent deletion of protected items 958 + const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1); 955 959 956 - // Check if item exists 957 - if (!Storage.exists(itemPath.c_str())) { 958 - LOG_DBG("WEB", "Delete failed - item not found: %s", itemPath.c_str()); 959 - server->send(404, "text/plain", "Item not found"); 960 - return; 961 - } 960 + // Hidden/system files are protected 961 + if (itemName.startsWith(".")) { 962 + failedItems += itemPath + " (hidden/system file); "; 963 + allSuccess = false; 964 + continue; 965 + } 962 966 963 - LOG_DBG("WEB", "Attempting to delete %s: %s", itemType.c_str(), itemPath.c_str()); 967 + // Check against explicitly protected items 968 + bool isProtected = false; 969 + for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) { 970 + if (itemName.equals(HIDDEN_ITEMS[i])) { 971 + isProtected = true; 972 + break; 973 + } 974 + } 975 + if (isProtected) { 976 + failedItems += itemPath + " (protected file); "; 977 + allSuccess = false; 978 + continue; 979 + } 964 980 965 - bool success = false; 981 + // Check if item exists 982 + if (!Storage.exists(itemPath.c_str())) { 983 + failedItems += itemPath + " (not found); "; 984 + allSuccess = false; 985 + continue; 986 + } 966 987 967 - if (itemType == "folder") { 968 - // For folders, try to remove (will fail if not empty) 969 - FsFile dir = Storage.open(itemPath.c_str()); 970 - if (dir && dir.isDirectory()) { 971 - // Check if folder is empty 972 - FsFile entry = dir.openNextFile(); 988 + // Decide whether it's a directory or file by opening it 989 + bool success = false; 990 + FsFile f = Storage.open(itemPath.c_str()); 991 + if (f && f.isDirectory()) { 992 + // For folders, ensure empty before removing 993 + FsFile entry = f.openNextFile(); 973 994 if (entry) { 974 - // Folder is not empty 975 995 entry.close(); 976 - dir.close(); 977 - LOG_DBG("WEB", "Delete failed - folder not empty: %s", itemPath.c_str()); 978 - server->send(400, "text/plain", "Folder is not empty. Delete contents first."); 979 - return; 996 + f.close(); 997 + failedItems += itemPath + " (folder not empty); "; 998 + allSuccess = false; 999 + continue; 980 1000 } 981 - dir.close(); 1001 + f.close(); 1002 + success = Storage.rmdir(itemPath.c_str()); 1003 + } else { 1004 + // It's a file (or couldn't open as dir) — remove file 1005 + if (f) f.close(); 1006 + success = Storage.remove(itemPath.c_str()); 1007 + clearEpubCacheIfNeeded(itemPath); 982 1008 } 983 - success = Storage.rmdir(itemPath.c_str()); 984 - } else { 985 - // For files, use remove 986 - success = Storage.remove(itemPath.c_str()); 1009 + 1010 + if (!success) { 1011 + failedItems += itemPath + " (deletion failed); "; 1012 + allSuccess = false; 1013 + } 987 1014 } 988 1015 989 - if (success) { 990 - LOG_DBG("WEB", "Successfully deleted: %s", itemPath.c_str()); 991 - server->send(200, "text/plain", "Deleted successfully"); 1016 + if (allSuccess) { 1017 + server->send(200, "text/plain", "All items deleted successfully"); 992 1018 } else { 993 - LOG_ERR("WEB", "Failed to delete: %s", itemPath.c_str()); 994 - server->send(500, "text/plain", "Failed to delete item"); 1019 + server->send(500, "text/plain", "Failed to delete some items: " + failedItems); 995 1020 } 996 1021 } 997 1022
+99 -48
src/network/html/FilesPage.html
··· 653 653 <div class="action-buttons"> 654 654 <button class="action-btn upload-action-btn" onclick="openUploadModal()">📤 Upload</button> 655 655 <button class="action-btn folder-action-btn" onclick="openFolderModal()">📁 New Folder</button> 656 + <button class="action-btn" style="background-color:#e74c3c" onclick="openDeleteSelectedModal()">🗑️ Delete Selected</button> 656 657 </div> 657 658 </div> 658 659 ··· 719 720 <div class="modal-overlay" id="deleteModal"> 720 721 <div class="modal"> 721 722 <button class="modal-close" onclick="closeDeleteModal()">&times;</button> 722 - <h3>🗑️ Delete Item</h3> 723 + <h3>🗑️ Delete Item(s)</h3> 723 724 <div class="folder-form"> 724 725 <p class="delete-warning">⚠️ This action cannot be undone!</p> 725 - <p class="file-info">Are you sure you want to delete:</p> 726 - <p class="delete-item-name" id="deleteItemName"></p> 727 - <input type="hidden" id="deleteItemPath"> 728 - <input type="hidden" id="deleteItemType"> 726 + <p class="file-info">Are you sure you want to delete the following item(s)?</p> 727 + <div id="deleteItemList" style="max-height:240px; overflow:auto; margin-bottom:10px;"></div> 729 728 <button class="delete-btn-confirm" onclick="confirmDelete()">Delete</button> 730 729 <button class="delete-btn-cancel" onclick="closeDeleteModal()">Cancel</button> 731 730 </div> ··· 837 836 fileTable.innerHTML = '<div class="no-files">This folder is empty</div>'; 838 837 } else { 839 838 let fileTableContent = '<table class="file-table">'; 840 - fileTableContent += '<tr><th>Name</th><th>Type</th><th>Size</th><th class="actions-col">Actions</th></tr>'; 839 + 840 + // Add select-all checkbox column 841 + fileTableContent += '<tr><th style="width:40px"><input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll(this)"></th><th>Name</th><th>Type</th><th>Size</th><th class="actions-col">Actions</th></tr>'; 842 + 841 843 842 844 const sortedFiles = files.sort((a, b) => { 843 845 // Directories first, then epub files, then other files, alphabetically within each group ··· 854 856 if (!folderPath.endsWith("/")) folderPath += "/"; 855 857 folderPath += file.name; 856 858 857 - fileTableContent += '<tr class="folder-row">'; 859 + // Checkbox cell + folder row 860 + fileTableContent += `<tr class="folder-row">`; 861 + fileTableContent += `<td><input type="checkbox" class="select-item" data-path="${encodeURIComponent(folderPath)}" data-name="${escapeHtml(file.name)}" data-type="folder"></td>`; 858 862 fileTableContent += `<td><span class="file-icon">📁</span><a href="/files?path=${encodeURIComponent(folderPath)}" class="folder-link">${escapeHtml(file.name)}</a><span class="folder-badge">FOLDER</span></td>`; 859 863 fileTableContent += '<td>Folder</td>'; 860 864 fileTableContent += '<td>-</td>'; ··· 865 869 if (!filePath.endsWith("/")) filePath += "/"; 866 870 filePath += file.name; 867 871 872 + // Checkbox cell + file row 868 873 fileTableContent += `<tr class="${file.isEpub ? 'epub-file' : ''}">`; 874 + fileTableContent += `<td><input type="checkbox" class="select-item" data-path="${encodeURIComponent(filePath)}" data-name="${escapeHtml(file.name)}" data-type="file"></td>`; 869 875 fileTableContent += `<td><span class="file-icon">${file.isEpub ? '📗' : '📄'}</span>${escapeHtml(file.name)}`; 870 876 if (file.isEpub) fileTableContent += '<span class="epub-badge">EPUB</span>'; 871 877 fileTableContent += '</td>'; ··· 908 914 909 915 function closeFolderModal() { 910 916 document.getElementById('folderModal').classList.remove('open'); 917 + } 918 + 919 + // Toggle select-all checkbox 920 + function toggleSelectAll(master) { 921 + const checked = master.checked; 922 + document.querySelectorAll('.select-item').forEach(cb => { 923 + cb.checked = checked; 924 + }); 925 + } 926 + 927 + function getSelectedItems() { 928 + const items = []; 929 + document.querySelectorAll('.select-item:checked').forEach(cb => { 930 + items.push({ 931 + name: cb.dataset.name || decodeURIComponent(cb.dataset.path).split('/').pop(), 932 + path: decodeURIComponent(cb.dataset.path), 933 + isFolder: cb.dataset.type === 'folder' 934 + }); 935 + }); 936 + return items; 937 + } 938 + 939 + // Open delete modal for currently selected checkboxes 940 + function openDeleteSelectedModal() { 941 + const items = getSelectedItems(); 942 + if (items.length === 0) { 943 + alert('Please select at least one item to delete.'); 944 + return; 945 + } 946 + openDeleteModalForItems(items); 947 + } 948 + 949 + // Open delete modal for a single item (keeps backwards compatibility with per-row delete button) 950 + function openDeleteModal(name, path, isFolder) { 951 + openDeleteModalForItems([{ name: name, path: path, isFolder: !!isFolder }]); 952 + } 953 + 954 + let deleteItemsGlobal = []; 955 + 956 + function openDeleteModalForItems(items) { 957 + deleteItemsGlobal = items; 958 + const listEl = document.getElementById('deleteItemList'); 959 + listEl.innerHTML = ''; 960 + items.forEach(it => { 961 + const div = document.createElement('div'); 962 + div.style.marginBottom = '6px'; 963 + div.textContent = (it.isFolder ? '📁 ' : '📄 ') + it.path; 964 + listEl.appendChild(div); 965 + }); 966 + document.getElementById('deleteModal').classList.add('open'); 967 + } 968 + 969 + function closeDeleteModal() { 970 + document.getElementById('deleteModal').classList.remove('open'); 971 + } 972 + 973 + function confirmDelete() { 974 + if (!deleteItemsGlobal || deleteItemsGlobal.length === 0) { 975 + closeDeleteModal(); 976 + return; 977 + } 978 + 979 + const paths = deleteItemsGlobal.map(it => { 980 + // Ensure path starts with / 981 + let p = it.path; 982 + if (!p.startsWith('/')) p = '/' + p; 983 + return p; 984 + }); 985 + 986 + const body = 'paths=' + encodeURIComponent(JSON.stringify(paths)); 987 + fetch('/delete', { 988 + method: 'POST', 989 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 990 + body: body 991 + }).then(async res => { 992 + if (res.ok) { 993 + window.location.reload(); 994 + } else { 995 + const text = await res.text(); 996 + alert('Failed to delete: ' + text); 997 + closeDeleteModal(); 998 + } 999 + }).catch(() => { 1000 + alert('Failed to delete - network error'); 1001 + closeDeleteModal(); 1002 + }); 911 1003 } 912 1004 913 1005 function validateFile() { ··· 1440 1532 1441 1533 xhr.send(formData); 1442 1534 } 1443 - 1444 - // Delete functions 1445 - function openDeleteModal(name, path, isFolder) { 1446 - document.getElementById('deleteItemName').textContent = (isFolder ? '📁 ' : '📄 ') + name; 1447 - document.getElementById('deleteItemPath').value = path; 1448 - document.getElementById('deleteItemType').value = isFolder ? 'folder' : 'file'; 1449 - document.getElementById('deleteModal').classList.add('open'); 1450 - } 1451 - 1452 - function closeDeleteModal() { 1453 - document.getElementById('deleteModal').classList.remove('open'); 1454 - } 1455 - 1456 - function confirmDelete() { 1457 - const path = document.getElementById('deleteItemPath').value; 1458 - const itemType = document.getElementById('deleteItemType').value; 1459 - 1460 - const formData = new FormData(); 1461 - formData.append('path', path); 1462 - formData.append('type', itemType); 1463 - 1464 - const xhr = new XMLHttpRequest(); 1465 - xhr.open('POST', '/delete', true); 1466 - 1467 - xhr.onload = function() { 1468 - if (xhr.status === 200) { 1469 - window.location.reload(); 1470 - } else { 1471 - alert('Failed to delete: ' + xhr.responseText); 1472 - closeDeleteModal(); 1473 - } 1474 - }; 1475 - 1476 - xhr.onerror = function() { 1477 - alert('Failed to delete - network error'); 1478 - closeDeleteModal(); 1479 - }; 1480 - 1481 - xhr.send(formData); 1482 - } 1483 - 1484 1535 hydrate(); 1485 1536 </script> 1486 1537 </body>