adds delete and archive abilities

This commit is contained in:
cottongin
2026-01-22 15:45:07 -05:00
parent 6b533207e1
commit d5a9873bd7
16 changed files with 1389 additions and 41 deletions

View File

@@ -9,6 +9,7 @@
#include <algorithm>
#include "BookManager.h"
#include "html/FilesPageHtml.generated.h"
#include "html/HomePageHtml.generated.h"
#include "util/StringUtils.h"
@@ -106,6 +107,11 @@ void CrossPointWebServer::begin() {
// Delete file/folder endpoint
server->on("/delete", HTTP_POST, [this] { handleDelete(); });
// Archive/Unarchive endpoints
server->on("/archive", HTTP_POST, [this] { handleArchive(); });
server->on("/unarchive", HTTP_POST, [this] { handleUnarchive(); });
server->on("/api/archived", HTTP_GET, [this] { handleArchivedList(); });
server->onNotFound([this] { handleNotFound(); });
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
@@ -602,6 +608,7 @@ void CrossPointWebServer::handleDelete() const {
String itemPath = server->arg("path");
const String itemType = server->hasArg("type") ? server->arg("type") : "file";
const bool isArchived = server->hasArg("archived") && server->arg("archived") == "true";
// Validate path
if (itemPath.isEmpty() || itemPath == "/") {
@@ -617,8 +624,8 @@ void CrossPointWebServer::handleDelete() const {
// Security check: prevent deletion of protected items
const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
// Check if item starts with a dot (hidden/system file)
if (itemName.startsWith(".")) {
// Check if item starts with a dot (hidden/system file) - but allow archived items
if (itemName.startsWith(".") && !isArchived) {
Serial.printf("[%lu] [WEB] Delete rejected - hidden/system item: %s\n", millis(), itemPath.c_str());
server->send(403, "text/plain", "Cannot delete system files");
return;
@@ -633,18 +640,19 @@ void CrossPointWebServer::handleDelete() const {
}
}
// Check if item exists
if (!SdMan.exists(itemPath.c_str())) {
Serial.printf("[%lu] [WEB] Delete failed - item not found: %s\n", millis(), itemPath.c_str());
server->send(404, "text/plain", "Item not found");
return;
}
Serial.printf("[%lu] [WEB] Attempting to delete %s: %s\n", millis(), itemType.c_str(), itemPath.c_str());
Serial.printf("[%lu] [WEB] Attempting to delete %s (archived=%d): %s\n", millis(), itemType.c_str(), isArchived,
itemPath.c_str());
bool success = false;
if (itemType == "folder") {
// Check if item exists
if (!SdMan.exists(itemPath.c_str())) {
Serial.printf("[%lu] [WEB] Delete failed - item not found: %s\n", millis(), itemPath.c_str());
server->send(404, "text/plain", "Item not found");
return;
}
// For folders, try to remove (will fail if not empty)
FsFile dir = SdMan.open(itemPath.c_str());
if (dir && dir.isDirectory()) {
@@ -662,8 +670,13 @@ void CrossPointWebServer::handleDelete() const {
}
success = SdMan.rmdir(itemPath.c_str());
} else {
// For files, use remove
success = SdMan.remove(itemPath.c_str());
// For files, use BookManager to also clean up cache and recent books
if (isArchived) {
// For archived books, just pass the filename
success = BookManager::deleteBook(itemName.c_str(), true);
} else {
success = BookManager::deleteBook(itemPath.c_str(), false);
}
}
if (success) {
@@ -675,6 +688,90 @@ void CrossPointWebServer::handleDelete() const {
}
}
void CrossPointWebServer::handleArchive() const {
if (!server->hasArg("path")) {
server->send(400, "text/plain", "Missing path");
return;
}
String bookPath = server->arg("path");
// Validate path
if (bookPath.isEmpty() || bookPath == "/") {
server->send(400, "text/plain", "Invalid path");
return;
}
// Ensure path starts with /
if (!bookPath.startsWith("/")) {
bookPath = "/" + bookPath;
}
Serial.printf("[%lu] [WEB] Archiving book: %s\n", millis(), bookPath.c_str());
if (BookManager::archiveBook(bookPath.c_str())) {
server->send(200, "text/plain", "Book archived successfully");
} else {
server->send(500, "text/plain", "Failed to archive book");
}
}
void CrossPointWebServer::handleUnarchive() const {
if (!server->hasArg("filename")) {
server->send(400, "text/plain", "Missing filename");
return;
}
const String filename = server->arg("filename");
if (filename.isEmpty()) {
server->send(400, "text/plain", "Invalid filename");
return;
}
Serial.printf("[%lu] [WEB] Unarchiving book: %s\n", millis(), filename.c_str());
// Get the original path before unarchiving (for response)
const std::string originalPath = BookManager::getArchivedBookOriginalPath(filename.c_str());
if (BookManager::unarchiveBook(filename.c_str())) {
// Return JSON with the original path
String response = "{\"success\":true,\"originalPath\":\"";
response += originalPath.c_str();
response += "\"}";
server->send(200, "application/json", response);
} else {
server->send(500, "text/plain", "Failed to unarchive book");
}
}
void CrossPointWebServer::handleArchivedList() const {
Serial.printf("[%lu] [WEB] Fetching archived books list\n", millis());
const auto archivedBooks = BookManager::listArchivedBooks();
// Build JSON response
String response = "[";
bool first = true;
for (const auto& filename : archivedBooks) {
if (!first) {
response += ",";
}
first = false;
const std::string originalPath = BookManager::getArchivedBookOriginalPath(filename);
response += "{\"filename\":\"";
response += filename.c_str();
response += "\",\"originalPath\":\"";
response += originalPath.c_str();
response += "\"}";
}
response += "]";
server->send(200, "application/json", response);
}
// WebSocket callback trampoline
void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
if (wsInstance) {

View File

@@ -60,4 +60,7 @@ class CrossPointWebServer {
void handleUploadPost() const;
void handleCreateFolder() const;
void handleDelete() const;
void handleArchive() const;
void handleUnarchive() const;
void handleArchivedList() const;
};

View File

@@ -322,8 +322,8 @@
.folder-btn:hover {
background-color: #d68910;
}
/* Delete button styles */
.delete-btn {
/* Action button styles */
.delete-btn, .archive-btn {
background: none;
border: none;
cursor: pointer;
@@ -337,10 +337,75 @@
background-color: #fee;
color: #e74c3c;
}
.archive-btn:hover {
background-color: #e8f4fd;
color: #3498db;
}
.actions-col {
width: 60px;
width: 90px;
text-align: center;
}
/* Archived files button */
.archived-action-btn {
background-color: #9b59b6;
}
.archived-action-btn:hover {
background-color: #8e44ad;
}
/* Archive modal styles */
.archive-warning {
color: #3498db;
font-weight: 600;
margin: 10px 0;
}
.archive-btn-confirm {
background-color: #3498db;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
width: 100%;
}
.archive-btn-confirm:hover {
background-color: #2980b9;
}
/* Archived files list */
.archived-file-row {
background-color: #f3e5f5 !important;
}
.archived-file-row:hover {
background-color: #e1bee7 !important;
}
.original-path {
font-size: 0.8em;
color: #7f8c8d;
margin-top: 4px;
}
.restore-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.1em;
padding: 4px 8px;
border-radius: 4px;
color: #95a5a6;
transition: all 0.15s;
}
.restore-btn:hover {
background-color: #e8f6e9;
color: #27ae60;
}
.archive-badge {
display: inline-block;
padding: 3px 8px;
background-color: #9b59b6;
color: white;
border-radius: 4px;
font-size: 0.85em;
margin-left: 10px;
}
/* Failed uploads banner */
.failed-uploads-banner {
background-color: #fff3cd;
@@ -586,6 +651,7 @@
<div class="action-buttons">
<button class="action-btn upload-action-btn" onclick="openUploadModal()">📤 Upload</button>
<button class="action-btn folder-action-btn" onclick="openFolderModal()">📁 New Folder</button>
<button class="action-btn archived-action-btn" onclick="openArchivedModal()">📦 Archived <span id="archivedCount"></span></button>
</div>
</div>
@@ -659,12 +725,58 @@
<p class="delete-item-name" id="deleteItemName"></p>
<input type="hidden" id="deleteItemPath">
<input type="hidden" id="deleteItemType">
<input type="hidden" id="deleteItemArchived" value="false">
<button class="delete-btn-confirm" onclick="confirmDelete()">Delete</button>
<button class="delete-btn-cancel" onclick="closeDeleteModal()">Cancel</button>
</div>
</div>
</div>
<!-- Archive Confirmation Modal -->
<div class="modal-overlay" id="archiveModal">
<div class="modal">
<button class="modal-close" onclick="closeArchiveModal()">&times;</button>
<h3>📦 Archive Book</h3>
<div class="folder-form">
<p class="archive-warning">📦 Book will be moved to archive</p>
<p class="file-info">Reading progress will be saved. You can restore it later.</p>
<p class="delete-item-name" id="archiveItemName"></p>
<input type="hidden" id="archiveItemPath">
<button class="archive-btn-confirm" onclick="confirmArchive()">Archive</button>
<button class="delete-btn-cancel" onclick="closeArchiveModal()">Cancel</button>
</div>
</div>
</div>
<!-- Archived Files Modal -->
<div class="modal-overlay" id="archivedModal">
<div class="modal" style="max-width: 600px;">
<button class="modal-close" onclick="closeArchivedModal()">&times;</button>
<h3>📦 Archived Books</h3>
<div id="archivedFilesList">
<div class="loader-container">
<span class="loader"></span>
</div>
</div>
</div>
</div>
<!-- Restore Confirmation Modal -->
<div class="modal-overlay" id="restoreModal">
<div class="modal">
<button class="modal-close" onclick="closeRestoreModal()">&times;</button>
<h3>📤 Restore Book</h3>
<div class="folder-form">
<p class="file-info">Restore book to its original location:</p>
<p class="delete-item-name" id="restoreItemName"></p>
<p class="original-path" id="restoreOriginalPath"></p>
<input type="hidden" id="restoreItemFilename">
<button class="archive-btn-confirm" onclick="confirmRestore()">Restore</button>
<button class="delete-btn-cancel" onclick="closeRestoreModal()">Cancel</button>
</div>
</div>
</div>
<script>
// get current path from query parameter
const currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/');
@@ -750,6 +862,12 @@
return a.name.localeCompare(b.name);
});
// Check if file is a book format (can be archived)
function isBookFormat(filename) {
const ext = filename.toLowerCase();
return ext.endsWith('.epub') || ext.endsWith('.txt') || ext.endsWith('.xtc') || ext.endsWith('.xtch');
}
sortedFiles.forEach(file => {
if (file.isDirectory) {
let folderPath = currentPath;
@@ -773,7 +891,12 @@
fileTableContent += '</td>';
fileTableContent += `<td>${file.name.split('.').pop().toUpperCase()}</td>`;
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
fileTableContent += `<td class="actions-col"><button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}', false)" title="Delete file">🗑️</button></td>`;
fileTableContent += '<td class="actions-col">';
if (isBookFormat(file.name)) {
fileTableContent += `<button class="archive-btn" onclick="openArchiveModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}')" title="Archive book">📦</button>`;
}
fileTableContent += `<button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}', false)" title="Delete file">🗑️</button>`;
fileTableContent += '</td>';
fileTableContent += '</tr>';
}
});
@@ -1176,10 +1299,11 @@ function retryAllFailedUploads() {
}
// Delete functions
function openDeleteModal(name, path, isFolder) {
function openDeleteModal(name, path, isFolder, isArchived = false) {
document.getElementById('deleteItemName').textContent = (isFolder ? '📁 ' : '📄 ') + name;
document.getElementById('deleteItemPath').value = path;
document.getElementById('deleteItemType').value = isFolder ? 'folder' : 'file';
document.getElementById('deleteItemArchived').value = isArchived ? 'true' : 'false';
document.getElementById('deleteModal').classList.add('open');
}
@@ -1190,17 +1314,26 @@ function retryAllFailedUploads() {
function confirmDelete() {
const path = document.getElementById('deleteItemPath').value;
const itemType = document.getElementById('deleteItemType').value;
const isArchived = document.getElementById('deleteItemArchived').value === 'true';
const formData = new FormData();
formData.append('path', path);
formData.append('type', itemType);
if (isArchived) {
formData.append('archived', 'true');
}
const xhr = new XMLHttpRequest();
xhr.open('POST', '/delete', true);
xhr.onload = function() {
if (xhr.status === 200) {
window.location.reload();
if (isArchived) {
closeDeleteModal();
loadArchivedFiles();
} else {
window.location.reload();
}
} else {
alert('Failed to delete: ' + xhr.responseText);
closeDeleteModal();
@@ -1215,7 +1348,166 @@ function retryAllFailedUploads() {
xhr.send(formData);
}
// Archive functions
function openArchiveModal(name, path) {
document.getElementById('archiveItemName').textContent = '📄 ' + name;
document.getElementById('archiveItemPath').value = path;
document.getElementById('archiveModal').classList.add('open');
}
function closeArchiveModal() {
document.getElementById('archiveModal').classList.remove('open');
}
function confirmArchive() {
const path = document.getElementById('archiveItemPath').value;
const formData = new FormData();
formData.append('path', path);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/archive', true);
xhr.onload = function() {
if (xhr.status === 200) {
window.location.reload();
} else {
alert('Failed to archive: ' + xhr.responseText);
closeArchiveModal();
}
};
xhr.onerror = function() {
alert('Failed to archive - network error');
closeArchiveModal();
};
xhr.send(formData);
}
// Archived files modal
function openArchivedModal() {
document.getElementById('archivedModal').classList.add('open');
loadArchivedFiles();
}
function closeArchivedModal() {
document.getElementById('archivedModal').classList.remove('open');
}
async function loadArchivedFiles() {
const container = document.getElementById('archivedFilesList');
container.innerHTML = '<div class="loader-container"><span class="loader"></span></div>';
try {
const response = await fetch('/api/archived');
if (!response.ok) {
throw new Error('Failed to load archived files');
}
const archivedFiles = await response.json();
// Update the badge count
const countSpan = document.getElementById('archivedCount');
if (archivedFiles.length > 0) {
countSpan.textContent = '(' + archivedFiles.length + ')';
} else {
countSpan.textContent = '';
}
if (archivedFiles.length === 0) {
container.innerHTML = '<div class="no-files">No archived books</div>';
return;
}
let html = '<table class="file-table">';
html += '<tr><th>Book</th><th class="actions-col">Actions</th></tr>';
archivedFiles.forEach(file => {
html += '<tr class="archived-file-row">';
html += '<td>';
html += '<span class="file-icon">📄</span>' + escapeHtml(file.filename);
html += '<div class="original-path">Original: ' + escapeHtml(file.originalPath) + '</div>';
html += '</td>';
html += '<td class="actions-col">';
html += `<button class="restore-btn" onclick="openRestoreModal('${file.filename.replaceAll("'", "\\'")}', '${file.originalPath.replaceAll("'", "\\'")}')" title="Restore book">📤</button>`;
html += `<button class="delete-btn" onclick="openDeleteModal('${file.filename.replaceAll("'", "\\'")}', '${file.filename.replaceAll("'", "\\'")}', false, true)" title="Delete permanently">🗑️</button>`;
html += '</td>';
html += '</tr>';
});
html += '</table>';
container.innerHTML = html;
} catch (e) {
console.error(e);
container.innerHTML = '<div class="no-files">Failed to load archived files</div>';
}
}
// Restore functions
function openRestoreModal(filename, originalPath) {
document.getElementById('restoreItemName').textContent = '📄 ' + filename;
document.getElementById('restoreOriginalPath').textContent = '→ ' + originalPath;
document.getElementById('restoreItemFilename').value = filename;
document.getElementById('restoreModal').classList.add('open');
}
function closeRestoreModal() {
document.getElementById('restoreModal').classList.remove('open');
}
function confirmRestore() {
const filename = document.getElementById('restoreItemFilename').value;
const formData = new FormData();
formData.append('filename', filename);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/unarchive', true);
xhr.onload = function() {
if (xhr.status === 200) {
try {
const result = JSON.parse(xhr.responseText);
alert('Book restored to: ' + result.originalPath);
} catch (e) {
// Ignore parse errors
}
closeRestoreModal();
loadArchivedFiles();
// Also refresh the main file list in case we're viewing that folder
hydrate();
} else {
alert('Failed to restore: ' + xhr.responseText);
closeRestoreModal();
}
};
xhr.onerror = function() {
alert('Failed to restore - network error');
closeRestoreModal();
};
xhr.send(formData);
}
// Load archived count on page load
async function loadArchivedCount() {
try {
const response = await fetch('/api/archived');
if (response.ok) {
const archivedFiles = await response.json();
const countSpan = document.getElementById('archivedCount');
if (archivedFiles.length > 0) {
countSpan.textContent = '(' + archivedFiles.length + ')';
}
}
} catch (e) {
// Ignore errors
}
}
hydrate();
loadArchivedCount();
</script>
</body>
</html>