diff --git a/open-x4-sdk b/open-x4-sdk index bd4e6707..fe766f15 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit bd4e6707503ab9c97d13ee0d8f8c69e9ff03cd12 +Subproject commit fe766f15cb9ff1ef8214210a8667037df4c60b81 diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 3a26a736..8307a5c6 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -72,6 +72,7 @@ void CrossPointWebServer::begin() { server->on("/api/status", HTTP_GET, [this] { handleStatus(); }); server->on("/api/files", HTTP_GET, [this] { handleFileListData(); }); + server->on("/api/folders", HTTP_GET, [this] { handleFolderList(); }); // Upload endpoint with special handling for multipart form data server->on("/upload", HTTP_POST, [this] { handleUploadPost(); }, [this] { handleUpload(); }); @@ -82,6 +83,9 @@ void CrossPointWebServer::begin() { // Delete file/folder endpoint server->on("/delete", HTTP_POST, [this] { handleDelete(); }); + // Move/rename file/folder endpoint + server->on("/move", HTTP_POST, [this] { handleMove(); }); + server->onNotFound([this] { handleNotFound(); }); Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap()); @@ -555,3 +559,152 @@ void CrossPointWebServer::handleDelete() const { server->send(500, "text/plain", "Failed to delete item"); } } + +void CrossPointWebServer::handleMove() const { + // Get source and destination paths from form data + if (!server->hasArg("sourcePath")) { + server->send(400, "text/plain", "Missing source path"); + return; + } + if (!server->hasArg("destPath")) { + server->send(400, "text/plain", "Missing destination path"); + return; + } + + String sourcePath = server->arg("sourcePath"); + String destPath = server->arg("destPath"); + + // Validate source path + if (sourcePath.isEmpty() || sourcePath == "/") { + server->send(400, "text/plain", "Cannot move root directory"); + return; + } + + // Validate destination path + if (destPath.isEmpty()) { + server->send(400, "text/plain", "Destination path cannot be empty"); + return; + } + + // Ensure paths start with / + if (!sourcePath.startsWith("/")) { + sourcePath = "/" + sourcePath; + } + if (!destPath.startsWith("/")) { + destPath = "/" + destPath; + } + + // Security check on source: prevent moving protected items + const String sourceName = sourcePath.substring(sourcePath.lastIndexOf('/') + 1); + if (sourceName.startsWith(".")) { + Serial.printf("[%lu] [WEB] Move rejected - hidden/system item: %s\n", millis(), sourcePath.c_str()); + server->send(403, "text/plain", "Cannot move system files"); + return; + } + + for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) { + if (sourceName.equals(HIDDEN_ITEMS[i])) { + Serial.printf("[%lu] [WEB] Move rejected - protected item: %s\n", millis(), sourcePath.c_str()); + server->send(403, "text/plain", "Cannot move protected items"); + return; + } + } + + // Check if source exists + if (!SdMan.exists(sourcePath.c_str())) { + Serial.printf("[%lu] [WEB] Move failed - source not found: %s\n", millis(), sourcePath.c_str()); + server->send(404, "text/plain", "Source item not found"); + return; + } + + // Check if destination already exists + if (SdMan.exists(destPath.c_str())) { + Serial.printf("[%lu] [WEB] Move failed - destination already exists: %s\n", millis(), destPath.c_str()); + server->send(400, "text/plain", "Destination already exists"); + return; + } + + // Ensure destination parent directory exists + const int lastSlash = destPath.lastIndexOf('/'); + if (lastSlash > 0) { + const String parentDir = destPath.substring(0, lastSlash); + if (!SdMan.exists(parentDir.c_str())) { + Serial.printf("[%lu] [WEB] Move failed - destination directory does not exist: %s\n", millis(), parentDir.c_str()); + server->send(400, "text/plain", "Destination directory does not exist"); + return; + } + } + + Serial.printf("[%lu] [WEB] Attempting to move: %s -> %s\n", millis(), sourcePath.c_str(), destPath.c_str()); + + // Perform the move using rename + if (SdMan.rename(sourcePath.c_str(), destPath.c_str())) { + Serial.printf("[%lu] [WEB] Successfully moved: %s -> %s\n", millis(), sourcePath.c_str(), destPath.c_str()); + server->send(200, "text/plain", "Moved successfully"); + } else { + Serial.printf("[%lu] [WEB] Failed to move: %s -> %s\n", millis(), sourcePath.c_str(), destPath.c_str()); + server->send(500, "text/plain", "Failed to move item"); + } +} + +void CrossPointWebServer::handleFolderList() const { + Serial.printf("[%lu] [WEB] Building folder list...\n", millis()); + + String jsonResponse = "["; + jsonResponse += "\"/\""; + + collectFoldersRecursively("/", jsonResponse); + + jsonResponse += "]"; + + Serial.printf("[%lu] [WEB] Served folder list\n", millis()); + server->send(200, "application/json", jsonResponse); +} + +void CrossPointWebServer::collectFoldersRecursively(const char* path, String& json) const { + FsFile dir = SdMan.open(path); + if (!dir || !dir.isDirectory()) { + if (dir) dir.close(); + return; + } + + FsFile entry = dir.openNextFile(); + char name[128]; + + while (entry) { + if (entry.isDirectory()) { + entry.getName(name, sizeof(name)); + String folderName = String(name); + + // Skip hidden folders + bool shouldSkip = folderName.startsWith("."); + + // Check against protected folders + if (!shouldSkip) { + for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) { + if (folderName.equals(HIDDEN_ITEMS[i])) { + shouldSkip = true; + break; + } + } + } + + if (!shouldSkip) { + String folderPath = String(path); + if (!folderPath.endsWith("/")) folderPath += "/"; + folderPath += folderName; + + json += ",\"" + folderPath + "\""; + + // Recurse into subfolder + collectFoldersRecursively(folderPath.c_str(), json); + } + } + + entry.close(); + yield(); // Allow other tasks to process + entry = dir.openNextFile(); + } + + dir.close(); +} diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 1be07b4a..aa405f2a 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -40,6 +40,7 @@ class CrossPointWebServer { // File scanning void scanFiles(const char* path, const std::function& callback) const; + void collectFoldersRecursively(const char* path, String& json) const; String formatFileSize(size_t bytes) const; bool isEpubFile(const String& filename) const; @@ -49,8 +50,10 @@ class CrossPointWebServer { void handleStatus() const; void handleFileList() const; void handleFileListData() const; + void handleFolderList() const; void handleUpload() const; void handleUploadPost() const; void handleCreateFolder() const; void handleDelete() const; + void handleMove() const; }; diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index a1b95663..e199f868 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -344,6 +344,38 @@ .folder-btn:hover { background-color: #d68910; } + .rename-input { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1em; + margin-bottom: 10px; + box-sizing: border-box; + font-family: monospace; + } + .rename-btn-submit { + background-color: #3498db; + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1em; + width: 100%; + } + .rename-btn-submit:hover { + background-color: #2980b9; + } + .folder-select { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1em; + margin-bottom: 10px; + box-sizing: border-box; + } /* Delete button styles */ .delete-btn { background: none; @@ -359,9 +391,24 @@ background-color: #fee; color: #e74c3c; } + .rename-btn { + background: none; + border: none; + cursor: pointer; + font-size: 1.1em; + padding: 4px 8px; + border-radius: 4px; + color: #95a5a6; + transition: all 0.15s; + } + .rename-btn:hover { + background-color: #e3f2fd; + color: #3498db; + } .actions-col { - width: 60px; + width: 80px; text-align: center; + white-space: nowrap; } /* Failed uploads banner */ .failed-uploads-banner { @@ -538,6 +585,22 @@ background-color: #95a5a6; cursor: not-allowed; } + .move-selected-btn { + background-color: #3498db; + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1em; + } + .move-selected-btn:hover { + background-color: #2980b9; + } + .move-selected-btn:disabled { + background-color: #95a5a6; + cursor: not-allowed; + } .loader-container { display: flex; justify-content: center; @@ -669,6 +732,7 @@
+
@@ -748,6 +812,41 @@ + + + + + +