From d35bda8023743fd9e480dd42ee185afb6d943bb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=ADas=20P=C3=A1ll=20Gissurarson?= Date: Thu, 5 Feb 2026 13:56:00 +0100 Subject: [PATCH] feat: rename and move in file manager (#630) ## Summary * **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.) This adds renaming and moving files to the File Manager * **What changes are included?** New `/move` and `/rename` endpoints, and corresponding modals and icons added. Uses the `file.rename()` function, after sanity checking. ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). Fixes #559, #661, #663. Only touches the File Manager, so low risk of affecting other systems. Simpler than #619, at the cost of not migrating the cache of renamed books. image image image --- ### 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? _**YES**_ I used Codex for the implementation itself, and then carefully reviewed the code myself. As this is a simple change and only to the webserver, it is low risk. --- src/network/CrossPointWebServer.cpp | 211 ++++++++++++++++++++++ src/network/CrossPointWebServer.h | 2 + src/network/html/FilesPage.html | 268 +++++++++++++++++++++++++++- 3 files changed, 473 insertions(+), 8 deletions(-) diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 7df92f26..6a0c2e86 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -44,6 +44,36 @@ void clearEpubCacheIfNeeded(const String& filePath) { Serial.printf("[%lu] [WEB] Cleared epub cache for: %s\n", millis(), filePath.c_str()); } } + +String normalizeWebPath(const String& inputPath) { + if (inputPath.isEmpty() || inputPath == "/") { + return "/"; + } + std::string normalized = FsHelpers::normalisePath(inputPath.c_str()); + String result = normalized.c_str(); + if (result.isEmpty()) { + return "/"; + } + if (!result.startsWith("/")) { + result = "/" + result; + } + if (result.length() > 1 && result.endsWith("/")) { + result = result.substring(0, result.length() - 1); + } + return result; +} + +bool isProtectedItemName(const String& name) { + if (name.startsWith(".")) { + return true; + } + for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) { + if (name.equals(HIDDEN_ITEMS[i])) { + return true; + } + } + return false; +} } // namespace // File listing page template - now using generated headers: @@ -109,6 +139,12 @@ void CrossPointWebServer::begin() { // Create folder endpoint server->on("/mkdir", HTTP_POST, [this] { handleCreateFolder(); }); + // Rename file endpoint + server->on("/rename", HTTP_POST, [this] { handleRename(); }); + + // Move file endpoint + server->on("/move", HTTP_POST, [this] { handleMove(); }); + // Delete file/folder endpoint server->on("/delete", HTTP_POST, [this] { handleDelete(); }); @@ -690,6 +726,181 @@ void CrossPointWebServer::handleCreateFolder() const { } } +void CrossPointWebServer::handleRename() const { + if (!server->hasArg("path") || !server->hasArg("name")) { + server->send(400, "text/plain", "Missing path or new name"); + return; + } + + String itemPath = normalizeWebPath(server->arg("path")); + String newName = server->arg("name"); + newName.trim(); + + if (itemPath.isEmpty() || itemPath == "/") { + server->send(400, "text/plain", "Invalid path"); + return; + } + if (newName.isEmpty()) { + server->send(400, "text/plain", "New name cannot be empty"); + return; + } + if (newName.indexOf('/') >= 0 || newName.indexOf('\\') >= 0) { + server->send(400, "text/plain", "Invalid file name"); + return; + } + if (isProtectedItemName(newName)) { + server->send(403, "text/plain", "Cannot rename to protected name"); + return; + } + + const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1); + if (isProtectedItemName(itemName)) { + server->send(403, "text/plain", "Cannot rename protected item"); + return; + } + if (newName == itemName) { + server->send(200, "text/plain", "Name unchanged"); + return; + } + + if (!SdMan.exists(itemPath.c_str())) { + server->send(404, "text/plain", "Item not found"); + return; + } + + FsFile file = SdMan.open(itemPath.c_str()); + if (!file) { + server->send(500, "text/plain", "Failed to open file"); + return; + } + if (file.isDirectory()) { + file.close(); + server->send(400, "text/plain", "Only files can be renamed"); + return; + } + + String parentPath = itemPath.substring(0, itemPath.lastIndexOf('/')); + if (parentPath.isEmpty()) { + parentPath = "/"; + } + String newPath = parentPath; + if (!newPath.endsWith("/")) { + newPath += "/"; + } + newPath += newName; + + if (SdMan.exists(newPath.c_str())) { + file.close(); + server->send(409, "text/plain", "Target already exists"); + return; + } + + clearEpubCacheIfNeeded(itemPath); + const bool success = file.rename(newPath.c_str()); + file.close(); + + if (success) { + Serial.printf("[%lu] [WEB] Renamed file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str()); + server->send(200, "text/plain", "Renamed successfully"); + } else { + Serial.printf("[%lu] [WEB] Failed to rename file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str()); + server->send(500, "text/plain", "Failed to rename file"); + } +} + +void CrossPointWebServer::handleMove() const { + if (!server->hasArg("path") || !server->hasArg("dest")) { + server->send(400, "text/plain", "Missing path or destination"); + return; + } + + String itemPath = normalizeWebPath(server->arg("path")); + String destPath = normalizeWebPath(server->arg("dest")); + + if (itemPath.isEmpty() || itemPath == "/") { + server->send(400, "text/plain", "Invalid path"); + return; + } + if (destPath.isEmpty()) { + server->send(400, "text/plain", "Invalid destination"); + return; + } + + const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1); + if (isProtectedItemName(itemName)) { + server->send(403, "text/plain", "Cannot move protected item"); + return; + } + if (destPath != "/") { + const String destName = destPath.substring(destPath.lastIndexOf('/') + 1); + if (isProtectedItemName(destName)) { + server->send(403, "text/plain", "Cannot move into protected folder"); + return; + } + } + + if (!SdMan.exists(itemPath.c_str())) { + server->send(404, "text/plain", "Item not found"); + return; + } + + FsFile file = SdMan.open(itemPath.c_str()); + if (!file) { + server->send(500, "text/plain", "Failed to open file"); + return; + } + if (file.isDirectory()) { + file.close(); + server->send(400, "text/plain", "Only files can be moved"); + return; + } + + if (!SdMan.exists(destPath.c_str())) { + file.close(); + server->send(404, "text/plain", "Destination not found"); + return; + } + FsFile destDir = SdMan.open(destPath.c_str()); + if (!destDir || !destDir.isDirectory()) { + if (destDir) { + destDir.close(); + } + file.close(); + server->send(400, "text/plain", "Destination is not a folder"); + return; + } + destDir.close(); + + String newPath = destPath; + if (!newPath.endsWith("/")) { + newPath += "/"; + } + newPath += itemName; + + if (newPath == itemPath) { + file.close(); + server->send(200, "text/plain", "Already in destination"); + return; + } + if (SdMan.exists(newPath.c_str())) { + file.close(); + server->send(409, "text/plain", "Target already exists"); + return; + } + + clearEpubCacheIfNeeded(itemPath); + const bool success = file.rename(newPath.c_str()); + file.close(); + + if (success) { + Serial.printf("[%lu] [WEB] Moved file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str()); + server->send(200, "text/plain", "Moved successfully"); + } else { + Serial.printf("[%lu] [WEB] Failed to move file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str()); + server->send(500, "text/plain", "Failed to move file"); + } +} + void CrossPointWebServer::handleDelete() const { // Get path from form data if (!server->hasArg("path")) { diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 6f33b2cf..f7bc8586 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -97,5 +97,7 @@ class CrossPointWebServer { void handleUpload(UploadState& state) const; void handleUploadPost(UploadState& state) const; void handleCreateFolder() const; + void handleRename() const; + void handleMove() const; void handleDelete() const; }; diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 95993b8e..19dd0300 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -322,25 +322,47 @@ .folder-btn:hover { background-color: #d68910; } - /* Delete button styles */ - .delete-btn { + /* Action button styles */ + .delete-btn, + .rename-btn, + .move-btn { background: none; border: none; cursor: pointer; font-size: 1.1em; padding: 4px 8px; border-radius: 4px; - color: #95a5a6; transition: all 0.15s; } + .delete-btn { + color: #95a5a6; + } .delete-btn:hover { background-color: #fee; color: #e74c3c; } + .rename-btn { + color: #2980b9; + } + .rename-btn:hover { + background-color: #e8f4fd; + } + .move-btn { + color: #16a085; + } + .move-btn:hover { + background-color: #e6f7f4; + } .actions-col { - width: 60px; + width: 140px; text-align: center; } + .action-icon-group { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + } /* Failed uploads banner */ .failed-uploads-banner { background-color: #fff3cd; @@ -463,6 +485,32 @@ .delete-btn-cancel:hover { background-color: #7f8c8d; } + .rename-btn-confirm { + background-color: #3498db; + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1em; + width: 100%; + } + .rename-btn-confirm:hover { + background-color: #2e86c1; + } + .move-btn-confirm { + background-color: #16a085; + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1em; + width: 100%; + } + .move-btn-confirm:hover { + background-color: #138d75; + } .loader-container { display: flex; justify-content: center; @@ -558,12 +606,17 @@ font-size: 1.1em; } .actions-col { - width: 40px; + width: 120px; } - .delete-btn { + .delete-btn, + .rename-btn, + .move-btn { font-size: 1em; padding: 2px 4px; } + .action-icon-group { + gap: 4px; + } .no-files { padding: 20px; font-size: 0.9em; @@ -665,6 +718,37 @@ + + + + + +