add basic file management to web server
This commit is contained in:
parent
c87a06edb8
commit
ecff988a29
@ -115,6 +115,15 @@ void CrossPointWebServer::begin() {
|
|||||||
// Download endpoint
|
// Download endpoint
|
||||||
server->on("/download", HTTP_GET, [this] { handleDownload(); });
|
server->on("/download", HTTP_GET, [this] { handleDownload(); });
|
||||||
|
|
||||||
|
// Rename endpoint
|
||||||
|
server->on("/rename", HTTP_POST, [this] { handleRename(); });
|
||||||
|
|
||||||
|
// Copy endpoint
|
||||||
|
server->on("/copy", HTTP_POST, [this] { handleCopy(); });
|
||||||
|
|
||||||
|
// Move endpoint
|
||||||
|
server->on("/move", HTTP_POST, [this] { handleMove(); });
|
||||||
|
|
||||||
server->onNotFound([this] { handleNotFound(); });
|
server->onNotFound([this] { handleNotFound(); });
|
||||||
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
|
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
|
||||||
|
|
||||||
@ -239,7 +248,8 @@ void CrossPointWebServer::handleStatus() const {
|
|||||||
server->send(200, "application/json", json);
|
server->send(200, "application/json", json);
|
||||||
}
|
}
|
||||||
|
|
||||||
void CrossPointWebServer::scanFiles(const char* path, const std::function<void(FileInfo)>& callback) const {
|
void CrossPointWebServer::scanFiles(const char* path, const std::function<void(FileInfo)>& callback,
|
||||||
|
bool showHidden) const {
|
||||||
FsFile root = SdMan.open(path);
|
FsFile root = SdMan.open(path);
|
||||||
if (!root) {
|
if (!root) {
|
||||||
Serial.printf("[%lu] [WEB] Failed to open directory: %s\n", millis(), path);
|
Serial.printf("[%lu] [WEB] Failed to open directory: %s\n", millis(), path);
|
||||||
@ -252,7 +262,7 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function<void(F
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Serial.printf("[%lu] [WEB] Scanning files in: %s\n", millis(), path);
|
Serial.printf("[%lu] [WEB] Scanning files in: %s (showHidden=%d)\n", millis(), path, showHidden);
|
||||||
|
|
||||||
FsFile file = root.openNextFile();
|
FsFile file = root.openNextFile();
|
||||||
char name[500];
|
char name[500];
|
||||||
@ -260,10 +270,16 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function<void(F
|
|||||||
file.getName(name, sizeof(name));
|
file.getName(name, sizeof(name));
|
||||||
auto fileName = String(name);
|
auto fileName = String(name);
|
||||||
|
|
||||||
// Skip hidden items (starting with ".")
|
// Skip hidden items (starting with ".") unless showHidden is true
|
||||||
bool shouldHide = fileName.startsWith(".");
|
// Always skip .crosspoint folder (internal cache) even when showing hidden
|
||||||
|
bool shouldHide = false;
|
||||||
|
if (fileName.startsWith(".")) {
|
||||||
|
if (!showHidden || fileName.equals(".crosspoint")) {
|
||||||
|
shouldHide = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check against explicitly hidden items list
|
// Check against explicitly hidden items list (always hidden)
|
||||||
if (!shouldHide) {
|
if (!shouldHide) {
|
||||||
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
|
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
|
||||||
if (fileName.equals(HIDDEN_ITEMS[i])) {
|
if (fileName.equals(HIDDEN_ITEMS[i])) {
|
||||||
@ -320,6 +336,12 @@ void CrossPointWebServer::handleFileListData() const {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if we should show hidden files
|
||||||
|
bool showHidden = false;
|
||||||
|
if (server->hasArg("showHidden")) {
|
||||||
|
showHidden = server->arg("showHidden") == "true";
|
||||||
|
}
|
||||||
|
|
||||||
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
|
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||||
server->send(200, "application/json", "");
|
server->send(200, "application/json", "");
|
||||||
server->sendContent("[");
|
server->sendContent("[");
|
||||||
@ -328,27 +350,31 @@ void CrossPointWebServer::handleFileListData() const {
|
|||||||
bool seenFirst = false;
|
bool seenFirst = false;
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
|
|
||||||
scanFiles(currentPath.c_str(), [this, &output, &doc, seenFirst](const FileInfo& info) mutable {
|
scanFiles(
|
||||||
doc.clear();
|
currentPath.c_str(),
|
||||||
doc["name"] = info.name;
|
[this, &output, &doc, seenFirst](const FileInfo& info) mutable {
|
||||||
doc["size"] = info.size;
|
doc.clear();
|
||||||
doc["isDirectory"] = info.isDirectory;
|
doc["name"] = info.name;
|
||||||
doc["isEpub"] = info.isEpub;
|
doc["size"] = info.size;
|
||||||
|
doc["isDirectory"] = info.isDirectory;
|
||||||
|
doc["isEpub"] = info.isEpub;
|
||||||
|
|
||||||
const size_t written = serializeJson(doc, output, outputSize);
|
const size_t written = serializeJson(doc, output, outputSize);
|
||||||
if (written >= outputSize) {
|
if (written >= outputSize) {
|
||||||
// JSON output truncated; skip this entry to avoid sending malformed JSON
|
// JSON output truncated; skip this entry to avoid sending malformed JSON
|
||||||
Serial.printf("[%lu] [WEB] Skipping file entry with oversized JSON for name: %s\n", millis(), info.name.c_str());
|
Serial.printf("[%lu] [WEB] Skipping file entry with oversized JSON for name: %s\n", millis(),
|
||||||
return;
|
info.name.c_str());
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (seenFirst) {
|
if (seenFirst) {
|
||||||
server->sendContent(",");
|
server->sendContent(",");
|
||||||
} else {
|
} else {
|
||||||
seenFirst = true;
|
seenFirst = true;
|
||||||
}
|
}
|
||||||
server->sendContent(output);
|
server->sendContent(output);
|
||||||
});
|
},
|
||||||
|
showHidden);
|
||||||
server->sendContent("]");
|
server->sendContent("]");
|
||||||
// End of streamed response, empty chunk to signal client
|
// End of streamed response, empty chunk to signal client
|
||||||
server->sendContent("");
|
server->sendContent("");
|
||||||
@ -1017,3 +1043,413 @@ void CrossPointWebServer::handleDownload() const {
|
|||||||
Serial.printf("[%lu] [WEB] Download complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(), filename.c_str(),
|
Serial.printf("[%lu] [WEB] Download complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(), filename.c_str(),
|
||||||
totalSent, elapsed, kbps);
|
totalSent, elapsed, kbps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CrossPointWebServer::handleRename() const {
|
||||||
|
// Get path and new name from form data
|
||||||
|
if (!server->hasArg("path") || !server->hasArg("newName")) {
|
||||||
|
server->send(400, "text/plain", "Missing path or newName parameter");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String itemPath = server->arg("path");
|
||||||
|
const String newName = server->arg("newName");
|
||||||
|
|
||||||
|
// Validate new name
|
||||||
|
if (newName.isEmpty()) {
|
||||||
|
server->send(400, "text/plain", "New name cannot be empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject names containing path separators
|
||||||
|
if (newName.indexOf('/') >= 0 || newName.indexOf('\\') >= 0) {
|
||||||
|
server->send(400, "text/plain", "Name cannot contain path separators");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure path starts with /
|
||||||
|
if (!itemPath.startsWith("/")) {
|
||||||
|
itemPath = "/" + itemPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate path
|
||||||
|
if (itemPath.isEmpty() || itemPath == "/") {
|
||||||
|
server->send(400, "text/plain", "Cannot rename root directory");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security check: prevent renaming protected items
|
||||||
|
const String oldName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
|
||||||
|
if (oldName.startsWith(".")) {
|
||||||
|
server->send(403, "text/plain", "Cannot rename system files");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
|
||||||
|
if (oldName.equals(HIDDEN_ITEMS[i])) {
|
||||||
|
server->send(403, "text/plain", "Cannot rename protected items");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if source exists
|
||||||
|
if (!SdMan.exists(itemPath.c_str())) {
|
||||||
|
server->send(404, "text/plain", "Item not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new path (same directory, new name)
|
||||||
|
const int lastSlash = itemPath.lastIndexOf('/');
|
||||||
|
String newPath;
|
||||||
|
if (lastSlash == 0) {
|
||||||
|
newPath = "/" + newName;
|
||||||
|
} else {
|
||||||
|
newPath = itemPath.substring(0, lastSlash + 1) + newName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if destination already exists
|
||||||
|
if (SdMan.exists(newPath.c_str())) {
|
||||||
|
server->send(400, "text/plain", "An item with that name already exists");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [WEB] Renaming: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str());
|
||||||
|
|
||||||
|
// Perform the rename
|
||||||
|
esp_task_wdt_reset();
|
||||||
|
if (SdMan.rename(itemPath.c_str(), newPath.c_str())) {
|
||||||
|
Serial.printf("[%lu] [WEB] Rename successful\n", millis());
|
||||||
|
server->send(200, "text/plain", "Renamed successfully");
|
||||||
|
} else {
|
||||||
|
Serial.printf("[%lu] [WEB] Rename failed\n", millis());
|
||||||
|
server->send(500, "text/plain", "Failed to rename item");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CrossPointWebServer::copyFile(const String& srcPath, const String& destPath) const {
|
||||||
|
FsFile srcFile;
|
||||||
|
FsFile destFile;
|
||||||
|
|
||||||
|
// Open source file
|
||||||
|
if (!SdMan.openFileForRead("COPY", srcPath, srcFile)) {
|
||||||
|
Serial.printf("[%lu] [WEB] Copy failed - cannot open source: %s\n", millis(), srcPath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if destination exists and remove it
|
||||||
|
if (SdMan.exists(destPath.c_str())) {
|
||||||
|
SdMan.remove(destPath.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open destination file
|
||||||
|
if (!SdMan.openFileForWrite("COPY", destPath, destFile)) {
|
||||||
|
Serial.printf("[%lu] [WEB] Copy failed - cannot create dest: %s\n", millis(), destPath.c_str());
|
||||||
|
srcFile.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy in chunks
|
||||||
|
constexpr size_t COPY_BUFFER_SIZE = 4096;
|
||||||
|
uint8_t buffer[COPY_BUFFER_SIZE];
|
||||||
|
size_t totalCopied = 0;
|
||||||
|
const size_t fileSize = srcFile.size();
|
||||||
|
|
||||||
|
while (srcFile.available()) {
|
||||||
|
esp_task_wdt_reset();
|
||||||
|
yield();
|
||||||
|
|
||||||
|
const size_t bytesRead = srcFile.read(buffer, COPY_BUFFER_SIZE);
|
||||||
|
if (bytesRead == 0) break;
|
||||||
|
|
||||||
|
const size_t bytesWritten = destFile.write(buffer, bytesRead);
|
||||||
|
if (bytesWritten != bytesRead) {
|
||||||
|
Serial.printf("[%lu] [WEB] Copy failed - write error at %d bytes\n", millis(), totalCopied);
|
||||||
|
srcFile.close();
|
||||||
|
destFile.close();
|
||||||
|
SdMan.remove(destPath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCopied += bytesWritten;
|
||||||
|
}
|
||||||
|
|
||||||
|
srcFile.close();
|
||||||
|
destFile.close();
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [WEB] Copy complete: %s -> %s (%d bytes)\n", millis(), srcPath.c_str(), destPath.c_str(),
|
||||||
|
totalCopied);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CrossPointWebServer::copyFolder(const String& srcPath, const String& destPath) const {
|
||||||
|
// Create destination directory
|
||||||
|
if (!SdMan.exists(destPath.c_str())) {
|
||||||
|
if (!SdMan.mkdir(destPath.c_str())) {
|
||||||
|
Serial.printf("[%lu] [WEB] Copy folder failed - cannot create dest dir: %s\n", millis(), destPath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open source directory
|
||||||
|
FsFile srcDir = SdMan.open(srcPath.c_str());
|
||||||
|
if (!srcDir || !srcDir.isDirectory()) {
|
||||||
|
Serial.printf("[%lu] [WEB] Copy folder failed - cannot open source dir: %s\n", millis(), srcPath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate through source directory
|
||||||
|
FsFile entry = srcDir.openNextFile();
|
||||||
|
char name[256];
|
||||||
|
bool success = true;
|
||||||
|
|
||||||
|
while (entry && success) {
|
||||||
|
esp_task_wdt_reset();
|
||||||
|
yield();
|
||||||
|
|
||||||
|
entry.getName(name, sizeof(name));
|
||||||
|
const String entryName = String(name);
|
||||||
|
|
||||||
|
// Skip hidden files
|
||||||
|
if (!entryName.startsWith(".")) {
|
||||||
|
String srcEntryPath = srcPath;
|
||||||
|
if (!srcEntryPath.endsWith("/")) srcEntryPath += "/";
|
||||||
|
srcEntryPath += entryName;
|
||||||
|
|
||||||
|
String destEntryPath = destPath;
|
||||||
|
if (!destEntryPath.endsWith("/")) destEntryPath += "/";
|
||||||
|
destEntryPath += entryName;
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
success = copyFolder(srcEntryPath, destEntryPath);
|
||||||
|
} else {
|
||||||
|
success = copyFile(srcEntryPath, destEntryPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.close();
|
||||||
|
entry = srcDir.openNextFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
srcDir.close();
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CrossPointWebServer::handleCopy() const {
|
||||||
|
// Get source and destination paths
|
||||||
|
if (!server->hasArg("srcPath") || !server->hasArg("destPath")) {
|
||||||
|
server->send(400, "text/plain", "Missing srcPath or destPath parameter");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String srcPath = server->arg("srcPath");
|
||||||
|
String destPath = server->arg("destPath");
|
||||||
|
|
||||||
|
// Ensure paths start with /
|
||||||
|
if (!srcPath.startsWith("/")) srcPath = "/" + srcPath;
|
||||||
|
if (!destPath.startsWith("/")) destPath = "/" + destPath;
|
||||||
|
|
||||||
|
// Validate paths
|
||||||
|
if (srcPath.isEmpty() || srcPath == "/") {
|
||||||
|
server->send(400, "text/plain", "Cannot copy root directory");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security check: prevent copying protected items
|
||||||
|
const String srcName = srcPath.substring(srcPath.lastIndexOf('/') + 1);
|
||||||
|
if (srcName.startsWith(".")) {
|
||||||
|
server->send(403, "text/plain", "Cannot copy system files");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
|
||||||
|
if (srcName.equals(HIDDEN_ITEMS[i])) {
|
||||||
|
server->send(403, "text/plain", "Cannot copy protected items");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if source exists
|
||||||
|
if (!SdMan.exists(srcPath.c_str())) {
|
||||||
|
server->send(404, "text/plain", "Source item not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if destination already exists
|
||||||
|
if (SdMan.exists(destPath.c_str())) {
|
||||||
|
server->send(400, "text/plain", "Destination already exists");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent copying a folder into itself
|
||||||
|
if (destPath.startsWith(srcPath + "/")) {
|
||||||
|
server->send(400, "text/plain", "Cannot copy a folder into itself");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [WEB] Copying: %s -> %s\n", millis(), srcPath.c_str(), destPath.c_str());
|
||||||
|
|
||||||
|
// Check if source is a file or directory
|
||||||
|
FsFile srcFile = SdMan.open(srcPath.c_str());
|
||||||
|
const bool isDirectory = srcFile.isDirectory();
|
||||||
|
srcFile.close();
|
||||||
|
|
||||||
|
bool success;
|
||||||
|
if (isDirectory) {
|
||||||
|
success = copyFolder(srcPath, destPath);
|
||||||
|
} else {
|
||||||
|
success = copyFile(srcPath, destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
Serial.printf("[%lu] [WEB] Copy successful\n", millis());
|
||||||
|
server->send(200, "text/plain", "Copied successfully");
|
||||||
|
} else {
|
||||||
|
Serial.printf("[%lu] [WEB] Copy failed\n", millis());
|
||||||
|
server->send(500, "text/plain", "Failed to copy item");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to recursively delete a folder
|
||||||
|
static bool deleteFolderRecursive(const String& path) {
|
||||||
|
FsFile dir = SdMan.open(path.c_str());
|
||||||
|
if (!dir || !dir.isDirectory()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FsFile entry = dir.openNextFile();
|
||||||
|
char name[256];
|
||||||
|
bool success = true;
|
||||||
|
|
||||||
|
while (entry && success) {
|
||||||
|
esp_task_wdt_reset();
|
||||||
|
yield();
|
||||||
|
|
||||||
|
entry.getName(name, sizeof(name));
|
||||||
|
const String entryName = String(name);
|
||||||
|
|
||||||
|
String entryPath = path;
|
||||||
|
if (!entryPath.endsWith("/")) entryPath += "/";
|
||||||
|
entryPath += entryName;
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
entry.close();
|
||||||
|
success = deleteFolderRecursive(entryPath);
|
||||||
|
} else {
|
||||||
|
entry.close();
|
||||||
|
success = SdMan.remove(entryPath.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
entry = dir.openNextFile();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dir.close();
|
||||||
|
|
||||||
|
// Now remove the empty directory
|
||||||
|
if (success) {
|
||||||
|
success = SdMan.rmdir(path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CrossPointWebServer::handleMove() const {
|
||||||
|
// Get source and destination paths
|
||||||
|
if (!server->hasArg("srcPath") || !server->hasArg("destPath")) {
|
||||||
|
server->send(400, "text/plain", "Missing srcPath or destPath parameter");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String srcPath = server->arg("srcPath");
|
||||||
|
String destPath = server->arg("destPath");
|
||||||
|
|
||||||
|
// Ensure paths start with /
|
||||||
|
if (!srcPath.startsWith("/")) srcPath = "/" + srcPath;
|
||||||
|
if (!destPath.startsWith("/")) destPath = "/" + destPath;
|
||||||
|
|
||||||
|
// Validate paths
|
||||||
|
if (srcPath.isEmpty() || srcPath == "/") {
|
||||||
|
server->send(400, "text/plain", "Cannot move root directory");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security check: prevent moving protected items
|
||||||
|
const String srcName = srcPath.substring(srcPath.lastIndexOf('/') + 1);
|
||||||
|
if (srcName.startsWith(".")) {
|
||||||
|
server->send(403, "text/plain", "Cannot move system files");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
|
||||||
|
if (srcName.equals(HIDDEN_ITEMS[i])) {
|
||||||
|
server->send(403, "text/plain", "Cannot move protected items");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if source exists
|
||||||
|
if (!SdMan.exists(srcPath.c_str())) {
|
||||||
|
server->send(404, "text/plain", "Source item not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if destination already exists
|
||||||
|
if (SdMan.exists(destPath.c_str())) {
|
||||||
|
server->send(400, "text/plain", "Destination already exists");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent moving a folder into itself
|
||||||
|
if (destPath.startsWith(srcPath + "/")) {
|
||||||
|
server->send(400, "text/plain", "Cannot move a folder into itself");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [WEB] Moving: %s -> %s\n", millis(), srcPath.c_str(), destPath.c_str());
|
||||||
|
|
||||||
|
// First, try atomic rename (fast, works on same filesystem)
|
||||||
|
esp_task_wdt_reset();
|
||||||
|
if (SdMan.rename(srcPath.c_str(), destPath.c_str())) {
|
||||||
|
Serial.printf("[%lu] [WEB] Move successful (via rename)\n", millis());
|
||||||
|
server->send(200, "text/plain", "Moved successfully");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: copy + delete (for cross-directory moves that rename doesn't support)
|
||||||
|
Serial.printf("[%lu] [WEB] Rename failed, trying copy+delete fallback\n", millis());
|
||||||
|
|
||||||
|
// Check if source is a file or directory
|
||||||
|
FsFile srcFile = SdMan.open(srcPath.c_str());
|
||||||
|
const bool isDirectory = srcFile.isDirectory();
|
||||||
|
srcFile.close();
|
||||||
|
|
||||||
|
bool copySuccess;
|
||||||
|
if (isDirectory) {
|
||||||
|
copySuccess = copyFolder(srcPath, destPath);
|
||||||
|
} else {
|
||||||
|
copySuccess = copyFile(srcPath, destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!copySuccess) {
|
||||||
|
Serial.printf("[%lu] [WEB] Move failed - copy step failed\n", millis());
|
||||||
|
server->send(500, "text/plain", "Failed to move item");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete source
|
||||||
|
bool deleteSuccess;
|
||||||
|
if (isDirectory) {
|
||||||
|
deleteSuccess = deleteFolderRecursive(srcPath);
|
||||||
|
} else {
|
||||||
|
deleteSuccess = SdMan.remove(srcPath.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteSuccess) {
|
||||||
|
Serial.printf("[%lu] [WEB] Move successful (via copy+delete)\n", millis());
|
||||||
|
server->send(200, "text/plain", "Moved successfully");
|
||||||
|
} else {
|
||||||
|
// Copy succeeded but delete failed - warn but don't fail completely
|
||||||
|
Serial.printf("[%lu] [WEB] Move partial - copied but failed to delete source\n", millis());
|
||||||
|
server->send(200, "text/plain", "Moved (but source may still exist)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -46,7 +46,7 @@ class CrossPointWebServer {
|
|||||||
static void wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length);
|
static void wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length);
|
||||||
|
|
||||||
// File scanning
|
// File scanning
|
||||||
void scanFiles(const char* path, const std::function<void(FileInfo)>& callback) const;
|
void scanFiles(const char* path, const std::function<void(FileInfo)>& callback, bool showHidden = false) const;
|
||||||
String formatFileSize(size_t bytes) const;
|
String formatFileSize(size_t bytes) const;
|
||||||
bool isEpubFile(const String& filename) const;
|
bool isEpubFile(const String& filename) const;
|
||||||
|
|
||||||
@ -64,4 +64,11 @@ class CrossPointWebServer {
|
|||||||
void handleUnarchive() const;
|
void handleUnarchive() const;
|
||||||
void handleArchivedList() const;
|
void handleArchivedList() const;
|
||||||
void handleDownload() const;
|
void handleDownload() const;
|
||||||
|
void handleRename() const;
|
||||||
|
void handleCopy() const;
|
||||||
|
void handleMove() const;
|
||||||
|
|
||||||
|
// Helper for copy operations
|
||||||
|
bool copyFile(const String& srcPath, const String& destPath) const;
|
||||||
|
bool copyFolder(const String& srcPath, const String& destPath) const;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -348,9 +348,196 @@
|
|||||||
color: #27ae60;
|
color: #27ae60;
|
||||||
}
|
}
|
||||||
.actions-col {
|
.actions-col {
|
||||||
width: 120px;
|
width: 60px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.checkbox-col {
|
||||||
|
width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.checkbox-col input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
/* Selection toolbar */
|
||||||
|
.selection-toolbar {
|
||||||
|
display: none;
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.selection-toolbar.show {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.selection-toolbar .selection-count {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.selection-toolbar .toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.selection-toolbar button {
|
||||||
|
background-color: rgba(255,255,255,0.2);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.selection-toolbar button:hover {
|
||||||
|
background-color: rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
.selection-toolbar .clear-selection {
|
||||||
|
margin-left: auto;
|
||||||
|
background-color: transparent;
|
||||||
|
font-size: 1.2em;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
/* Row selected state */
|
||||||
|
.row-selected {
|
||||||
|
background-color: #d6eaf8 !important;
|
||||||
|
}
|
||||||
|
.row-selected:hover {
|
||||||
|
background-color: #aed6f1 !important;
|
||||||
|
}
|
||||||
|
/* Action dropdown */
|
||||||
|
.action-dropdown {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.action-dropdown-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.3em;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #7f8c8d;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.action-dropdown-btn:hover {
|
||||||
|
background-color: #ecf0f1;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
.action-dropdown-menu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 100%;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
min-width: 150px;
|
||||||
|
z-index: 100;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.action-dropdown-menu.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.action-dropdown-menu button,
|
||||||
|
.action-dropdown-menu a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #2c3e50;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
.action-dropdown-menu button:hover,
|
||||||
|
.action-dropdown-menu a:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
.action-dropdown-menu .danger {
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
.action-dropdown-menu .danger:hover {
|
||||||
|
background-color: #fee;
|
||||||
|
}
|
||||||
|
.action-dropdown-menu .divider {
|
||||||
|
height: 1px;
|
||||||
|
background-color: #eee;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
/* Hidden toggle button */
|
||||||
|
.hidden-toggle {
|
||||||
|
background-color: #95a5a6;
|
||||||
|
}
|
||||||
|
.hidden-toggle:hover {
|
||||||
|
background-color: #7f8c8d;
|
||||||
|
}
|
||||||
|
.hidden-toggle.active {
|
||||||
|
background-color: #9b59b6;
|
||||||
|
}
|
||||||
|
.hidden-toggle.active:hover {
|
||||||
|
background-color: #8e44ad;
|
||||||
|
}
|
||||||
|
/* Hidden file styling */
|
||||||
|
.hidden-file {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
/* Copy/Move modal folder browser */
|
||||||
|
.copy-move-breadcrumb {
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-size: 0.95em;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.copy-move-breadcrumb a {
|
||||||
|
color: #3498db;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.copy-move-breadcrumb a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.copy-move-breadcrumb .sep {
|
||||||
|
margin: 0 6px;
|
||||||
|
color: #bdc3c7;
|
||||||
|
}
|
||||||
|
.copy-move-breadcrumb .current {
|
||||||
|
color: #2c3e50;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.folder-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
.folder-list-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.folder-list-item:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
.folder-list-item .folder-icon {
|
||||||
|
margin-right: 10px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
.folder-list-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #95a5a6;
|
||||||
|
padding: 20px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
/* Archived files button */
|
/* Archived files button */
|
||||||
.archived-action-btn {
|
.archived-action-btn {
|
||||||
background-color: #9b59b6;
|
background-color: #9b59b6;
|
||||||
@ -629,11 +816,31 @@
|
|||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
}
|
}
|
||||||
.actions-col {
|
.actions-col {
|
||||||
width: 70px;
|
width: 50px;
|
||||||
}
|
}
|
||||||
.delete-btn, .archive-btn, .download-btn {
|
.checkbox-col {
|
||||||
font-size: 1em;
|
width: 32px;
|
||||||
padding: 2px 4px;
|
}
|
||||||
|
.action-dropdown-btn {
|
||||||
|
font-size: 1.1em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
.action-dropdown-menu {
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
|
.action-dropdown-menu button,
|
||||||
|
.action-dropdown-menu a {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.selection-toolbar {
|
||||||
|
padding: 10px 12px;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.selection-toolbar button {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
.no-files {
|
.no-files {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@ -658,6 +865,7 @@
|
|||||||
<button class="action-btn upload-action-btn" onclick="openUploadModal()">📤 Upload</button>
|
<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 folder-action-btn" onclick="openFolderModal()">📁 New Folder</button>
|
||||||
<button class="action-btn archived-action-btn" onclick="openArchivedModal()">📦 Archived <span id="archivedCount"></span></button>
|
<button class="action-btn archived-action-btn" onclick="openArchivedModal()">📦 Archived <span id="archivedCount"></span></button>
|
||||||
|
<button class="action-btn hidden-toggle" id="hiddenToggle" onclick="toggleHiddenFiles()">👁 Hidden</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -677,6 +885,17 @@
|
|||||||
<span class="summary-inline" id="folder-summary"></span>
|
<span class="summary-inline" id="folder-summary"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Selection Toolbar -->
|
||||||
|
<div class="selection-toolbar" id="selectionToolbar">
|
||||||
|
<span class="selection-count"><span id="selectedCount">0</span> selected</span>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<button onclick="bulkCopy()">📋 Copy</button>
|
||||||
|
<button onclick="bulkMove()">📁 Move</button>
|
||||||
|
<button onclick="bulkDelete()">🗑️ Delete</button>
|
||||||
|
</div>
|
||||||
|
<button class="clear-selection" onclick="clearSelection()" title="Clear selection">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="file-table">
|
<div id="file-table">
|
||||||
<div class="loader-container">
|
<div class="loader-container">
|
||||||
<span class="loader"></span>
|
<span class="loader"></span>
|
||||||
@ -783,10 +1002,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Rename Modal -->
|
||||||
|
<div class="modal-overlay" id="renameModal">
|
||||||
|
<div class="modal">
|
||||||
|
<button class="modal-close" onclick="closeRenameModal()">×</button>
|
||||||
|
<h3>✏️ Rename</h3>
|
||||||
|
<div class="folder-form">
|
||||||
|
<p class="file-info">Enter a new name:</p>
|
||||||
|
<input type="text" id="renameNewName" class="folder-input" placeholder="New name...">
|
||||||
|
<input type="hidden" id="renameItemPath">
|
||||||
|
<input type="hidden" id="renameItemIsFolder">
|
||||||
|
<button class="folder-btn" onclick="confirmRename()">Rename</button>
|
||||||
|
<button class="delete-btn-cancel" onclick="closeRenameModal()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Copy/Move Destination Modal -->
|
||||||
|
<div class="modal-overlay" id="copyMoveModal">
|
||||||
|
<div class="modal" style="max-width: 500px;">
|
||||||
|
<button class="modal-close" onclick="closeCopyMoveModal()">×</button>
|
||||||
|
<h3 id="copyMoveTitle">📋 Copy to...</h3>
|
||||||
|
<div class="folder-form">
|
||||||
|
<p class="file-info">Select destination folder:</p>
|
||||||
|
<div class="copy-move-breadcrumb" id="copyMoveBreadcrumb"></div>
|
||||||
|
<div id="copyMoveFolderList" style="max-height: 250px; overflow-y: auto; border: 1px solid #ddd; border-radius: 4px; margin: 10px 0;">
|
||||||
|
<div class="loader-container"><span class="loader"></span></div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="copyMoveOperation">
|
||||||
|
<input type="hidden" id="copyMoveSourcePaths">
|
||||||
|
<input type="hidden" id="copyMoveDestPath" value="/">
|
||||||
|
<button class="archive-btn-confirm" onclick="confirmCopyMove()">
|
||||||
|
<span id="copyMoveConfirmText">Copy Here</span>
|
||||||
|
</button>
|
||||||
|
<button class="delete-btn-cancel" onclick="closeCopyMoveModal()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// get current path from query parameter
|
// get current path from query parameter
|
||||||
const currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/');
|
const currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/');
|
||||||
|
|
||||||
|
// Selection state
|
||||||
|
let selectedItems = [];
|
||||||
|
let allFiles = [];
|
||||||
|
|
||||||
|
// Hidden files toggle state (persisted in localStorage)
|
||||||
|
let showHidden = localStorage.getItem('showHiddenFiles') === 'true';
|
||||||
|
|
||||||
function escapeHtml(unsafe) {
|
function escapeHtml(unsafe) {
|
||||||
return unsafe
|
return unsafe
|
||||||
.replaceAll("&", "&")
|
.replaceAll("&", "&")
|
||||||
@ -814,6 +1078,21 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Close dropdowns when clicking outside
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!e.target.closest('.action-dropdown')) {
|
||||||
|
document.querySelectorAll('.action-dropdown-menu.show').forEach(m => m.classList.remove('show'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update hidden toggle button state
|
||||||
|
const hiddenToggle = document.getElementById('hiddenToggle');
|
||||||
|
if (showHidden) {
|
||||||
|
hiddenToggle.classList.add('active');
|
||||||
|
} else {
|
||||||
|
hiddenToggle.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
const breadcrumbs = document.getElementById('directory-breadcrumbs');
|
const breadcrumbs = document.getElementById('directory-breadcrumbs');
|
||||||
const fileTable = document.getElementById('file-table');
|
const fileTable = document.getElementById('file-table');
|
||||||
|
|
||||||
@ -833,7 +1112,8 @@
|
|||||||
|
|
||||||
let files = [];
|
let files = [];
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/files?path=' + encodeURIComponent(currentPath));
|
const url = '/api/files?path=' + encodeURIComponent(currentPath) + (showHidden ? '&showHidden=true' : '');
|
||||||
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to load files: ' + response.status + ' ' + response.statusText);
|
throw new Error('Failed to load files: ' + response.status + ' ' + response.statusText);
|
||||||
}
|
}
|
||||||
@ -844,6 +1124,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store files for selection management
|
||||||
|
allFiles = files;
|
||||||
|
selectedItems = [];
|
||||||
|
updateSelectionUI();
|
||||||
|
|
||||||
let folderCount = 0;
|
let folderCount = 0;
|
||||||
let totalSize = 0;
|
let totalSize = 0;
|
||||||
files.forEach(file => {
|
files.forEach(file => {
|
||||||
@ -857,7 +1142,7 @@
|
|||||||
fileTable.innerHTML = '<div class="no-files">This folder is empty</div>';
|
fileTable.innerHTML = '<div class="no-files">This folder is empty</div>';
|
||||||
} else {
|
} else {
|
||||||
let fileTableContent = '<table class="file-table">';
|
let fileTableContent = '<table class="file-table">';
|
||||||
fileTableContent += '<tr><th>Name</th><th>Type</th><th>Size</th><th class="actions-col">Actions</th></tr>';
|
fileTableContent += '<tr><th class="checkbox-col"><input type="checkbox" id="selectAll" onchange="toggleSelectAll(this)"></th><th>Name</th><th>Type</th><th>Size</th><th class="actions-col">Actions</th></tr>';
|
||||||
|
|
||||||
const sortedFiles = files.sort((a, b) => {
|
const sortedFiles = files.sort((a, b) => {
|
||||||
// Directories first, then epub files, then other files, alphabetically within each group
|
// Directories first, then epub files, then other files, alphabetically within each group
|
||||||
@ -874,36 +1159,31 @@
|
|||||||
return ext.endsWith('.epub') || ext.endsWith('.txt') || ext.endsWith('.xtc') || ext.endsWith('.xtch');
|
return ext.endsWith('.epub') || ext.endsWith('.txt') || ext.endsWith('.xtc') || ext.endsWith('.xtch');
|
||||||
}
|
}
|
||||||
|
|
||||||
sortedFiles.forEach(file => {
|
sortedFiles.forEach((file, index) => {
|
||||||
if (file.isDirectory) {
|
let itemPath = currentPath;
|
||||||
let folderPath = currentPath;
|
if (!itemPath.endsWith("/")) itemPath += "/";
|
||||||
if (!folderPath.endsWith("/")) folderPath += "/";
|
itemPath += file.name;
|
||||||
folderPath += file.name;
|
|
||||||
|
|
||||||
fileTableContent += '<tr class="folder-row">';
|
const isHiddenItem = file.name.startsWith('.');
|
||||||
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>`;
|
const hiddenClass = isHiddenItem ? ' hidden-file' : '';
|
||||||
|
|
||||||
|
if (file.isDirectory) {
|
||||||
|
fileTableContent += `<tr class="folder-row${hiddenClass}" data-path="${escapeHtml(itemPath)}" data-name="${escapeHtml(file.name)}" data-is-folder="true">`;
|
||||||
|
fileTableContent += `<td class="checkbox-col"><input type="checkbox" class="row-checkbox" data-index="${index}" onchange="toggleRowSelection(${index})"></td>`;
|
||||||
|
fileTableContent += `<td><span class="file-icon">📁</span><a href="/files?path=${encodeURIComponent(itemPath)}" class="folder-link">${escapeHtml(file.name)}</a><span class="folder-badge">FOLDER</span></td>`;
|
||||||
fileTableContent += '<td>Folder</td>';
|
fileTableContent += '<td>Folder</td>';
|
||||||
fileTableContent += '<td>-</td>';
|
fileTableContent += '<td>-</td>';
|
||||||
fileTableContent += `<td class="actions-col"><button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${folderPath.replaceAll("'", "\\'")}', true)" title="Delete folder">🗑️</button></td>`;
|
fileTableContent += `<td class="actions-col">${buildActionDropdown(file.name, itemPath, true, false)}</td>`;
|
||||||
fileTableContent += '</tr>';
|
fileTableContent += '</tr>';
|
||||||
} else {
|
} else {
|
||||||
let filePath = currentPath;
|
fileTableContent += `<tr class="${file.isEpub ? 'epub-file' : ''}${hiddenClass}" data-path="${escapeHtml(itemPath)}" data-name="${escapeHtml(file.name)}" data-is-folder="false">`;
|
||||||
if (!filePath.endsWith("/")) filePath += "/";
|
fileTableContent += `<td class="checkbox-col"><input type="checkbox" class="row-checkbox" data-index="${index}" onchange="toggleRowSelection(${index})"></td>`;
|
||||||
filePath += file.name;
|
|
||||||
|
|
||||||
fileTableContent += `<tr class="${file.isEpub ? 'epub-file' : ''}">`;
|
|
||||||
fileTableContent += `<td><span class="file-icon">${file.isEpub ? '📗' : '📄'}</span>${escapeHtml(file.name)}`;
|
fileTableContent += `<td><span class="file-icon">${file.isEpub ? '📗' : '📄'}</span>${escapeHtml(file.name)}`;
|
||||||
if (file.isEpub) fileTableContent += '<span class="epub-badge">EPUB</span>';
|
if (file.isEpub) fileTableContent += '<span class="epub-badge">EPUB</span>';
|
||||||
fileTableContent += '</td>';
|
fileTableContent += '</td>';
|
||||||
fileTableContent += `<td>${file.name.split('.').pop().toUpperCase()}</td>`;
|
fileTableContent += `<td>${file.name.split('.').pop().toUpperCase()}</td>`;
|
||||||
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
|
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
|
||||||
fileTableContent += '<td class="actions-col">';
|
fileTableContent += `<td class="actions-col">${buildActionDropdown(file.name, itemPath, false, isBookFormat(file.name))}</td>`;
|
||||||
fileTableContent += `<a href="/download?path=${encodeURIComponent(filePath)}" class="download-btn" title="Download file">⬇️</a>`;
|
|
||||||
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>';
|
fileTableContent += '</tr>';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -913,6 +1193,364 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildActionDropdown(name, path, isFolder, isBook) {
|
||||||
|
const escapedName = name.replaceAll("'", "\\'").replaceAll('"', '"');
|
||||||
|
const escapedPath = path.replaceAll("'", "\\'").replaceAll('"', '"');
|
||||||
|
|
||||||
|
let html = '<div class="action-dropdown">';
|
||||||
|
html += '<button class="action-dropdown-btn" onclick="toggleDropdown(event, this)" title="Actions">⋮</button>';
|
||||||
|
html += '<div class="action-dropdown-menu">';
|
||||||
|
|
||||||
|
if (!isFolder) {
|
||||||
|
html += `<a href="/download?path=${encodeURIComponent(path)}">⬇️ Download</a>`;
|
||||||
|
}
|
||||||
|
html += `<button onclick="openRenameModal('${escapedName}', '${escapedPath}', ${isFolder})">✏️ Rename</button>`;
|
||||||
|
html += `<button onclick="openCopyModal('${escapedPath}')">📋 Copy to...</button>`;
|
||||||
|
html += `<button onclick="openMoveModal('${escapedPath}')">📁 Move to...</button>`;
|
||||||
|
|
||||||
|
if (isBook) {
|
||||||
|
html += '<div class="divider"></div>';
|
||||||
|
html += `<button onclick="openArchiveModal('${escapedName}', '${escapedPath}')">📦 Archive</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<div class="divider"></div>';
|
||||||
|
html += `<button class="danger" onclick="openDeleteModal('${escapedName}', '${escapedPath}', ${isFolder})">🗑️ Delete</button>`;
|
||||||
|
html += '</div></div>';
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDropdown(event, btn) {
|
||||||
|
event.stopPropagation();
|
||||||
|
const menu = btn.nextElementSibling;
|
||||||
|
const wasOpen = menu.classList.contains('show');
|
||||||
|
|
||||||
|
// Close all other dropdowns
|
||||||
|
document.querySelectorAll('.action-dropdown-menu.show').forEach(m => m.classList.remove('show'));
|
||||||
|
|
||||||
|
if (!wasOpen) {
|
||||||
|
menu.classList.add('show');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection management
|
||||||
|
function toggleSelectAll(checkbox) {
|
||||||
|
const rowCheckboxes = document.querySelectorAll('.row-checkbox');
|
||||||
|
if (checkbox.checked) {
|
||||||
|
selectedItems = allFiles.map((f, i) => i);
|
||||||
|
rowCheckboxes.forEach(cb => {
|
||||||
|
cb.checked = true;
|
||||||
|
cb.closest('tr').classList.add('row-selected');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
selectedItems = [];
|
||||||
|
rowCheckboxes.forEach(cb => {
|
||||||
|
cb.checked = false;
|
||||||
|
cb.closest('tr').classList.remove('row-selected');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateSelectionUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRowSelection(index) {
|
||||||
|
const checkbox = document.querySelector(`.row-checkbox[data-index="${index}"]`);
|
||||||
|
const row = checkbox.closest('tr');
|
||||||
|
|
||||||
|
if (checkbox.checked) {
|
||||||
|
if (!selectedItems.includes(index)) {
|
||||||
|
selectedItems.push(index);
|
||||||
|
}
|
||||||
|
row.classList.add('row-selected');
|
||||||
|
} else {
|
||||||
|
selectedItems = selectedItems.filter(i => i !== index);
|
||||||
|
row.classList.remove('row-selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update select all checkbox
|
||||||
|
const selectAll = document.getElementById('selectAll');
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.checked = selectedItems.length === allFiles.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelectionUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedItems = [];
|
||||||
|
document.querySelectorAll('.row-checkbox').forEach(cb => {
|
||||||
|
cb.checked = false;
|
||||||
|
cb.closest('tr').classList.remove('row-selected');
|
||||||
|
});
|
||||||
|
const selectAll = document.getElementById('selectAll');
|
||||||
|
if (selectAll) selectAll.checked = false;
|
||||||
|
updateSelectionUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectionUI() {
|
||||||
|
const toolbar = document.getElementById('selectionToolbar');
|
||||||
|
const countSpan = document.getElementById('selectedCount');
|
||||||
|
|
||||||
|
if (selectedItems.length > 0) {
|
||||||
|
toolbar.classList.add('show');
|
||||||
|
countSpan.textContent = selectedItems.length;
|
||||||
|
} else {
|
||||||
|
toolbar.classList.remove('show');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedPaths() {
|
||||||
|
return selectedItems.map(index => {
|
||||||
|
const file = allFiles[index];
|
||||||
|
let path = currentPath;
|
||||||
|
if (!path.endsWith('/')) path += '/';
|
||||||
|
return path + file.name;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hidden files toggle
|
||||||
|
function toggleHiddenFiles() {
|
||||||
|
showHidden = !showHidden;
|
||||||
|
localStorage.setItem('showHiddenFiles', showHidden);
|
||||||
|
hydrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename modal functions
|
||||||
|
function openRenameModal(name, path, isFolder) {
|
||||||
|
document.getElementById('renameNewName').value = name;
|
||||||
|
document.getElementById('renameItemPath').value = path;
|
||||||
|
document.getElementById('renameItemIsFolder').value = isFolder ? 'true' : 'false';
|
||||||
|
document.getElementById('renameModal').classList.add('open');
|
||||||
|
document.getElementById('renameNewName').focus();
|
||||||
|
document.getElementById('renameNewName').select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRenameModal() {
|
||||||
|
document.getElementById('renameModal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRename() {
|
||||||
|
const path = document.getElementById('renameItemPath').value;
|
||||||
|
const newName = document.getElementById('renameNewName').value.trim();
|
||||||
|
|
||||||
|
if (!newName) {
|
||||||
|
alert('Please enter a name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newName.includes('/') || newName.includes('\\')) {
|
||||||
|
alert('Name cannot contain path separators');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('path', path);
|
||||||
|
formData.append('newName', newName);
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/rename', true);
|
||||||
|
|
||||||
|
xhr.onload = function() {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
closeRenameModal();
|
||||||
|
hydrate();
|
||||||
|
} else {
|
||||||
|
alert('Failed to rename: ' + xhr.responseText);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = function() {
|
||||||
|
alert('Failed to rename - network error');
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy/Move modal functions
|
||||||
|
let copyMoveCurrentPath = '/';
|
||||||
|
|
||||||
|
function openCopyModal(sourcePath) {
|
||||||
|
openCopyMoveModal('copy', [sourcePath]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMoveModal(sourcePath) {
|
||||||
|
openCopyMoveModal('move', [sourcePath]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCopyMoveModal(operation, sourcePaths) {
|
||||||
|
document.getElementById('copyMoveOperation').value = operation;
|
||||||
|
document.getElementById('copyMoveSourcePaths').value = JSON.stringify(sourcePaths);
|
||||||
|
document.getElementById('copyMoveTitle').textContent = operation === 'copy' ? '📋 Copy to...' : '📁 Move to...';
|
||||||
|
document.getElementById('copyMoveConfirmText').textContent = operation === 'copy' ? 'Copy Here' : 'Move Here';
|
||||||
|
|
||||||
|
copyMoveCurrentPath = '/';
|
||||||
|
document.getElementById('copyMoveDestPath').value = '/';
|
||||||
|
document.getElementById('copyMoveModal').classList.add('open');
|
||||||
|
loadCopyMoveFolders();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCopyMoveModal() {
|
||||||
|
document.getElementById('copyMoveModal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCopyMoveFolders() {
|
||||||
|
const folderList = document.getElementById('copyMoveFolderList');
|
||||||
|
const breadcrumb = document.getElementById('copyMoveBreadcrumb');
|
||||||
|
|
||||||
|
folderList.innerHTML = '<div class="loader-container"><span class="loader"></span></div>';
|
||||||
|
|
||||||
|
// Build breadcrumb
|
||||||
|
let breadcrumbHtml = '';
|
||||||
|
if (copyMoveCurrentPath === '/') {
|
||||||
|
breadcrumbHtml = '<span class="current">🏠 Root</span>';
|
||||||
|
} else {
|
||||||
|
breadcrumbHtml = '<a onclick="navigateCopyMove(\'/\')">🏠 Root</a>';
|
||||||
|
const parts = copyMoveCurrentPath.split('/').filter(p => p);
|
||||||
|
parts.forEach((part, index) => {
|
||||||
|
const partPath = '/' + parts.slice(0, index + 1).join('/');
|
||||||
|
if (index === parts.length - 1) {
|
||||||
|
breadcrumbHtml += '<span class="sep">/</span><span class="current">' + escapeHtml(part) + '</span>';
|
||||||
|
} else {
|
||||||
|
breadcrumbHtml += '<span class="sep">/</span><a onclick="navigateCopyMove(\'' + partPath.replaceAll("'", "\\'") + '\')">' + escapeHtml(part) + '</a>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
breadcrumb.innerHTML = breadcrumbHtml;
|
||||||
|
|
||||||
|
// Fetch folders only (include hidden folders if showHidden is enabled)
|
||||||
|
try {
|
||||||
|
const url = '/api/files?path=' + encodeURIComponent(copyMoveCurrentPath) + (showHidden ? '&showHidden=true' : '');
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error('Failed to load folders');
|
||||||
|
|
||||||
|
const files = await response.json();
|
||||||
|
const folders = files.filter(f => f.isDirectory).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
if (folders.length === 0) {
|
||||||
|
folderList.innerHTML = '<div class="folder-list-empty">No subfolders</div>';
|
||||||
|
} else {
|
||||||
|
let html = '';
|
||||||
|
folders.forEach(folder => {
|
||||||
|
let folderPath = copyMoveCurrentPath;
|
||||||
|
if (!folderPath.endsWith('/')) folderPath += '/';
|
||||||
|
folderPath += folder.name;
|
||||||
|
|
||||||
|
const isHidden = folder.name.startsWith('.');
|
||||||
|
html += `<div class="folder-list-item${isHidden ? ' hidden-file' : ''}" onclick="navigateCopyMove('${folderPath.replaceAll("'", "\\'")}')">`;
|
||||||
|
html += '<span class="folder-icon">📁</span>';
|
||||||
|
html += escapeHtml(folder.name);
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
folderList.innerHTML = html;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
folderList.innerHTML = '<div class="folder-list-empty">Error loading folders</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateCopyMove(path) {
|
||||||
|
copyMoveCurrentPath = path;
|
||||||
|
document.getElementById('copyMoveDestPath').value = path;
|
||||||
|
loadCopyMoveFolders();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmCopyMove() {
|
||||||
|
const operation = document.getElementById('copyMoveOperation').value;
|
||||||
|
const sourcePaths = JSON.parse(document.getElementById('copyMoveSourcePaths').value);
|
||||||
|
const destFolder = document.getElementById('copyMoveDestPath').value;
|
||||||
|
|
||||||
|
const endpoint = operation === 'copy' ? '/copy' : '/move';
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
for (const srcPath of sourcePaths) {
|
||||||
|
const srcName = srcPath.substring(srcPath.lastIndexOf('/') + 1);
|
||||||
|
let destPath = destFolder;
|
||||||
|
if (!destPath.endsWith('/')) destPath += '/';
|
||||||
|
destPath += srcName;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('srcPath', srcPath);
|
||||||
|
formData.append('destPath', destPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, { method: 'POST', body: formData });
|
||||||
|
if (response.ok) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
console.error(`${operation} failed for ${srcPath}: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
failCount++;
|
||||||
|
console.error(`${operation} failed for ${srcPath}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeCopyMoveModal();
|
||||||
|
clearSelection();
|
||||||
|
|
||||||
|
if (failCount > 0) {
|
||||||
|
alert(`${operation === 'copy' ? 'Copied' : 'Moved'} ${successCount} items, ${failCount} failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk operations
|
||||||
|
function bulkCopy() {
|
||||||
|
const paths = getSelectedPaths();
|
||||||
|
if (paths.length === 0) return;
|
||||||
|
openCopyMoveModal('copy', paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bulkMove() {
|
||||||
|
const paths = getSelectedPaths();
|
||||||
|
if (paths.length === 0) return;
|
||||||
|
openCopyMoveModal('move', paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bulkDelete() {
|
||||||
|
const paths = getSelectedPaths();
|
||||||
|
if (paths.length === 0) return;
|
||||||
|
|
||||||
|
if (!confirm(`Delete ${paths.length} item(s)? This cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
Promise.all(paths.map(async (path) => {
|
||||||
|
const isFolder = allFiles.find(f => {
|
||||||
|
let itemPath = currentPath;
|
||||||
|
if (!itemPath.endsWith('/')) itemPath += '/';
|
||||||
|
return itemPath + f.name === path;
|
||||||
|
})?.isDirectory || false;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('path', path);
|
||||||
|
formData.append('type', isFolder ? 'folder' : 'file');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/delete', { method: 'POST', body: formData });
|
||||||
|
if (response.ok) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
})).then(() => {
|
||||||
|
clearSelection();
|
||||||
|
if (failCount > 0) {
|
||||||
|
alert(`Deleted ${successCount} items, ${failCount} failed`);
|
||||||
|
}
|
||||||
|
hydrate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Modal functions
|
// Modal functions
|
||||||
function openUploadModal() {
|
function openUploadModal() {
|
||||||
document.getElementById('uploadPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath;
|
document.getElementById('uploadPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user