Hide hidden folders

This commit is contained in:
Brendan O'Leary 2025-12-16 20:18:06 -05:00
parent 1bc30fbf2a
commit e384bdbfc2
2 changed files with 337 additions and 32 deletions

View File

@ -9,6 +9,14 @@
// Global instance // Global instance
CrossPointWebServer crossPointWebServer; CrossPointWebServer crossPointWebServer;
// Folders/files to hide from the web interface file browser
// Note: Items starting with "." are automatically hidden
static const char* HIDDEN_ITEMS[] = {
"System Volume Information",
"XTCache"
};
static const size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]);
// HTML page template // HTML page template
static const char* HTML_PAGE = R"rawliteral( static const char* HTML_PAGE = R"rawliteral(
<!DOCTYPE html> <!DOCTYPE html>
@ -192,6 +200,12 @@ static const char* FILES_PAGE_HEADER = R"rawliteral(
.epub-file:hover { .epub-file:hover {
background-color: #d4edda !important; background-color: #d4edda !important;
} }
.folder-row {
background-color: #fff9e6 !important;
}
.folder-row:hover {
background-color: #fff3cd !important;
}
.epub-badge { .epub-badge {
display: inline-block; display: inline-block;
padding: 2px 8px; padding: 2px 8px;
@ -201,9 +215,44 @@ static const char* FILES_PAGE_HEADER = R"rawliteral(
font-size: 0.75em; font-size: 0.75em;
margin-left: 8px; margin-left: 8px;
} }
.folder-badge {
display: inline-block;
padding: 2px 8px;
background-color: #f39c12;
color: white;
border-radius: 10px;
font-size: 0.75em;
margin-left: 8px;
}
.file-icon { .file-icon {
margin-right: 8px; margin-right: 8px;
} }
.folder-link {
color: #2c3e50;
text-decoration: none;
cursor: pointer;
}
.folder-link:hover {
color: #3498db;
text-decoration: underline;
}
.breadcrumb {
padding: 10px 15px;
background-color: #f8f9fa;
border-radius: 4px;
margin-bottom: 15px;
}
.breadcrumb a {
color: #3498db;
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.breadcrumb span {
color: #7f8c8d;
margin: 0 5px;
}
.upload-form { .upload-form {
margin-top: 15px; margin-top: 15px;
padding: 15px; padding: 15px;
@ -298,6 +347,30 @@ static const char* FILES_PAGE_HEADER = R"rawliteral(
font-size: 0.9em; font-size: 0.9em;
color: #7f8c8d; color: #7f8c8d;
} }
.folder-form {
display: flex;
gap: 10px;
margin-top: 15px;
}
.folder-input {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1em;
}
.folder-btn {
background-color: #f39c12;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
}
.folder-btn:hover {
background-color: #d68910;
}
</style> </style>
</head> </head>
<body> <body>
@ -339,6 +412,7 @@ static const char* FILES_PAGE_FOOTER = R"rawliteral(
function uploadFile() { function uploadFile() {
const fileInput = document.getElementById('fileInput'); const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0]; const file = fileInput.files[0];
const currentPath = document.getElementById('currentPath').value;
if (!file) { if (!file) {
alert('Please select a file first!'); alert('Please select a file first!');
@ -353,6 +427,7 @@ static const char* FILES_PAGE_FOOTER = R"rawliteral(
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('path', currentPath);
const progressContainer = document.getElementById('progress-container'); const progressContainer = document.getElementById('progress-container');
const progressFill = document.getElementById('progress-fill'); const progressFill = document.getElementById('progress-fill');
@ -394,6 +469,44 @@ static const char* FILES_PAGE_FOOTER = R"rawliteral(
xhr.send(formData); xhr.send(formData);
} }
function createFolder() {
const folderName = document.getElementById('folderName').value.trim();
const currentPath = document.getElementById('currentPath').value;
if (!folderName) {
alert('Please enter a folder name!');
return;
}
// Validate folder name (no special characters except underscore and hyphen)
const validName = /^[a-zA-Z0-9_\-]+$/.test(folderName);
if (!validName) {
alert('Folder name can only contain letters, numbers, underscores, and hyphens.');
return;
}
const formData = new FormData();
formData.append('name', folderName);
formData.append('path', currentPath);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/mkdir', true);
xhr.onload = function() {
if (xhr.status === 200) {
window.location.reload();
} else {
alert('Failed to create folder: ' + xhr.responseText);
}
};
xhr.onerror = function() {
alert('Failed to create folder - network error');
};
xhr.send(formData);
}
</script> </script>
</body> </body>
</html> </html>
@ -433,6 +546,9 @@ void CrossPointWebServer::begin() {
// Upload endpoint with special handling for multipart form data // Upload endpoint with special handling for multipart form data
server->on("/upload", HTTP_POST, [this]() { handleUploadPost(); }, [this]() { handleUpload(); }); server->on("/upload", HTTP_POST, [this]() { handleUploadPost(); }, [this]() { handleUpload(); });
// Create folder endpoint
server->on("/mkdir", HTTP_POST, [this]() { handleCreateFolder(); });
server->onNotFound([this]() { handleNotFound(); }); server->onNotFound([this]() { handleNotFound(); });
server->begin(); server->begin();
@ -516,19 +632,43 @@ std::vector<FileInfo> CrossPointWebServer::scanFiles(const char* path) {
File file = root.openNextFile(); File file = root.openNextFile();
while (file) { while (file) {
if (!file.isDirectory()) { String fileName = String(file.name());
// Skip hidden items (starting with ".")
bool shouldHide = fileName.startsWith(".");
// Check against explicitly hidden items list
if (!shouldHide) {
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
if (fileName.equals(HIDDEN_ITEMS[i])) {
shouldHide = true;
break;
}
}
}
if (!shouldHide) {
FileInfo info; FileInfo info;
info.name = String(file.name()); info.name = fileName;
info.isDirectory = file.isDirectory();
if (info.isDirectory) {
info.size = 0;
info.isEpub = false;
} else {
info.size = file.size(); info.size = file.size();
info.isEpub = isEpubFile(info.name); info.isEpub = isEpubFile(info.name);
}
files.push_back(info); files.push_back(info);
} }
file.close(); file.close();
file = root.openNextFile(); file = root.openNextFile();
} }
root.close(); root.close();
Serial.printf("[%lu] [WEB] Found %d files\n", millis(), files.size()); Serial.printf("[%lu] [WEB] Found %d items (files and folders)\n", millis(), files.size());
return files; return files;
} }
@ -551,6 +691,20 @@ bool CrossPointWebServer::isEpubFile(const String& filename) {
void CrossPointWebServer::handleFileList() { void CrossPointWebServer::handleFileList() {
String html = FILES_PAGE_HEADER; String html = FILES_PAGE_HEADER;
// Get current path from query string (default to root)
String currentPath = "/";
if (server->hasArg("path")) {
currentPath = server->arg("path");
// Ensure path starts with /
if (!currentPath.startsWith("/")) {
currentPath = "/" + currentPath;
}
// Remove trailing slash unless it's root
if (currentPath.length() > 1 && currentPath.endsWith("/")) {
currentPath = currentPath.substring(0, currentPath.length() - 1);
}
}
// Get message from query string if present // Get message from query string if present
if (server->hasArg("msg")) { if (server->hasArg("msg")) {
String msg = server->arg("msg"); String msg = server->arg("msg");
@ -558,68 +712,147 @@ void CrossPointWebServer::handleFileList() {
html += "<div class=\"message " + msgType + "\">" + msg + "</div>"; html += "<div class=\"message " + msgType + "\">" + msg + "</div>";
} }
// Hidden input to store current path for JavaScript
html += "<input type=\"hidden\" id=\"currentPath\" value=\"" + currentPath + "\">";
// Breadcrumb navigation
html += "<div class=\"card\">";
html += "<div class=\"breadcrumb\">";
html += "<a href=\"/files\">🏠 Root</a>";
if (currentPath != "/") {
String pathParts = currentPath.substring(1); // Remove leading /
String buildPath = "";
int start = 0;
int end = pathParts.indexOf('/');
while (start < pathParts.length()) {
String part;
if (end == -1) {
part = pathParts.substring(start);
buildPath += "/" + part;
html += "<span>/</span><strong>" + part + "</strong>";
break;
} else {
part = pathParts.substring(start, end);
buildPath += "/" + part;
html += "<span>/</span><a href=\"/files?path=" + buildPath + "\">" + part + "</a>";
start = end + 1;
end = pathParts.indexOf('/', start);
}
}
}
html += "</div>";
html += "</div>";
// Upload form // Upload form
html += "<div class=\"card\">"; html += "<div class=\"card\">";
html += "<h2>📤 Upload eBook</h2>"; html += "<h2>📤 Upload eBook to " + (currentPath == "/" ? "Root" : currentPath) + "</h2>";
html += "<div class=\"upload-form\">"; html += "<div class=\"upload-form\">";
html += "<p><strong>Select an .epub file to upload:</strong></p>"; html += "<p><strong>Select an .epub file to upload:</strong></p>";
html += "<input type=\"file\" id=\"fileInput\" accept=\".epub\" onchange=\"validateFile()\">"; html += "<input type=\"file\" id=\"fileInput\" accept=\".epub\" onchange=\"validateFile()\">";
html += "<div class=\"file-info\">Only .epub files are accepted</div>"; html += "<div class=\"file-info\">Only .epub files are accepted. File will be uploaded to: " + currentPath + "</div>";
html += "<button id=\"uploadBtn\" class=\"upload-btn\" onclick=\"uploadFile()\" disabled>Upload</button>"; html += "<button id=\"uploadBtn\" class=\"upload-btn\" onclick=\"uploadFile()\" disabled>Upload</button>";
html += "<div id=\"progress-container\">"; html += "<div id=\"progress-container\">";
html += "<div id=\"progress-bar\"><div id=\"progress-fill\"></div></div>"; html += "<div id=\"progress-bar\"><div id=\"progress-fill\"></div></div>";
html += "<div id=\"progress-text\"></div>"; html += "<div id=\"progress-text\"></div>";
html += "</div>"; html += "</div>";
html += "</div>"; html += "</div>";
// Create folder form
html += "<div class=\"folder-form\">";
html += "<input type=\"text\" id=\"folderName\" class=\"folder-input\" placeholder=\"New folder name...\">";
html += "<button class=\"folder-btn\" onclick=\"createFolder()\">📁 Create Folder</button>";
html += "</div>";
html += "</div>"; html += "</div>";
// Scan files // Scan files in current path
std::vector<FileInfo> files = scanFiles("/"); std::vector<FileInfo> files = scanFiles(currentPath.c_str());
// Count epub files // Count items
int epubCount = 0; int epubCount = 0;
int folderCount = 0;
size_t totalSize = 0; size_t totalSize = 0;
for (const auto& file : files) { for (const auto& file : files) {
if (file.isDirectory) {
folderCount++;
} else {
if (file.isEpub) epubCount++; if (file.isEpub) epubCount++;
totalSize += file.size; totalSize += file.size;
} }
}
// File listing // File listing
html += "<div class=\"card\">"; html += "<div class=\"card\">";
html += "<h2>📁 Files on SD Card</h2>"; html += "<h2>📁 Contents of " + (currentPath == "/" ? "Root" : currentPath) + "</h2>";
// Summary // Summary
html += "<div class=\"summary\">"; html += "<div class=\"summary\">";
html += "<div class=\"summary-item\"><div class=\"summary-number\">" + String(files.size()) + "</div><div class=\"summary-label\">Total Files</div></div>"; html += "<div class=\"summary-item\"><div class=\"summary-number\">" + String(folderCount) + "</div><div class=\"summary-label\">Folders</div></div>";
html += "<div class=\"summary-item\"><div class=\"summary-number\">" + String(files.size() - folderCount) + "</div><div class=\"summary-label\">Files</div></div>";
html += "<div class=\"summary-item\"><div class=\"summary-number\">" + String(epubCount) + "</div><div class=\"summary-label\">eBooks</div></div>"; html += "<div class=\"summary-item\"><div class=\"summary-number\">" + String(epubCount) + "</div><div class=\"summary-label\">eBooks</div></div>";
html += "<div class=\"summary-item\"><div class=\"summary-number\">" + formatFileSize(totalSize) + "</div><div class=\"summary-label\">Total Size</div></div>"; html += "<div class=\"summary-item\"><div class=\"summary-number\">" + formatFileSize(totalSize) + "</div><div class=\"summary-label\">Total Size</div></div>";
html += "</div>"; html += "</div>";
if (files.empty()) { if (files.empty()) {
html += "<div class=\"no-files\">No files found on SD card</div>"; html += "<div class=\"no-files\">This folder is empty</div>";
} else { } else {
html += "<table class=\"file-table\">"; html += "<table class=\"file-table\">";
html += "<tr><th>Filename</th><th>Type</th><th>Size</th></tr>"; html += "<tr><th>Name</th><th>Type</th><th>Size</th></tr>";
// Sort files: epub files first, then alphabetically // Sort files: folders first, then epub files, then other files, alphabetically within each group
std::sort(files.begin(), files.end(), [](const FileInfo& a, const FileInfo& b) { std::sort(files.begin(), files.end(), [](const FileInfo& a, const FileInfo& b) {
// Folders come first
if (a.isDirectory != b.isDirectory) return a.isDirectory > b.isDirectory;
// Then sort by epub status (epubs first among files)
if (!a.isDirectory && !b.isDirectory) {
if (a.isEpub != b.isEpub) return a.isEpub > b.isEpub; if (a.isEpub != b.isEpub) return a.isEpub > b.isEpub;
}
// Then alphabetically
return a.name < b.name; return a.name < b.name;
}); });
for (const auto& file : files) { for (const auto& file : files) {
String rowClass = file.isEpub ? "epub-file" : ""; String rowClass;
String icon = file.isEpub ? "📗" : "📄"; String icon;
String badge = file.isEpub ? "<span class=\"epub-badge\">EPUB</span>" : ""; String badge;
String typeStr;
String sizeStr;
if (file.isDirectory) {
rowClass = "folder-row";
icon = "📁";
badge = "<span class=\"folder-badge\">FOLDER</span>";
typeStr = "Folder";
sizeStr = "-";
// Build the path to this folder
String folderPath = currentPath;
if (!folderPath.endsWith("/")) folderPath += "/";
folderPath += file.name;
html += "<tr class=\"" + rowClass + "\">";
html += "<td><span class=\"file-icon\">" + icon + "</span>";
html += "<a href=\"/files?path=" + folderPath + "\" class=\"folder-link\">" + file.name + "</a>" + badge + "</td>";
html += "<td>" + typeStr + "</td>";
html += "<td>" + sizeStr + "</td>";
html += "</tr>";
} else {
rowClass = file.isEpub ? "epub-file" : "";
icon = file.isEpub ? "📗" : "📄";
badge = file.isEpub ? "<span class=\"epub-badge\">EPUB</span>" : "";
String ext = file.name.substring(file.name.lastIndexOf('.') + 1); String ext = file.name.substring(file.name.lastIndexOf('.') + 1);
ext.toUpperCase(); ext.toUpperCase();
typeStr = ext;
sizeStr = formatFileSize(file.size);
html += "<tr class=\"" + rowClass + "\">"; html += "<tr class=\"" + rowClass + "\">";
html += "<td><span class=\"file-icon\">" + icon + "</span>" + file.name + badge + "</td>"; html += "<td><span class=\"file-icon\">" + icon + "</span>" + file.name + badge + "</td>";
html += "<td>" + ext + "</td>"; html += "<td>" + typeStr + "</td>";
html += "<td>" + formatFileSize(file.size) + "</td>"; html += "<td>" + sizeStr + "</td>";
html += "</tr>"; html += "</tr>";
} }
}
html += "</table>"; html += "</table>";
} }
@ -629,12 +862,13 @@ void CrossPointWebServer::handleFileList() {
html += FILES_PAGE_FOOTER; html += FILES_PAGE_FOOTER;
server->send(200, "text/html", html); server->send(200, "text/html", html);
Serial.printf("[%lu] [WEB] Served file listing page\n", millis()); Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str());
} }
// Static variables for upload handling // Static variables for upload handling
static File uploadFile; static File uploadFile;
static String uploadFileName; static String uploadFileName;
static String uploadPath = "/";
static size_t uploadSize = 0; static size_t uploadSize = 0;
static bool uploadSuccess = false; static bool uploadSuccess = false;
static String uploadError = ""; static String uploadError = "";
@ -648,7 +882,22 @@ void CrossPointWebServer::handleUpload() {
uploadSuccess = false; uploadSuccess = false;
uploadError = ""; uploadError = "";
Serial.printf("[%lu] [WEB] Upload start: %s\n", millis(), uploadFileName.c_str()); // Get upload path from form data (defaults to root if not specified)
if (server->hasArg("path")) {
uploadPath = server->arg("path");
// Ensure path starts with /
if (!uploadPath.startsWith("/")) {
uploadPath = "/" + uploadPath;
}
// Remove trailing slash unless it's root
if (uploadPath.length() > 1 && uploadPath.endsWith("/")) {
uploadPath = uploadPath.substring(0, uploadPath.length() - 1);
}
} else {
uploadPath = "/";
}
Serial.printf("[%lu] [WEB] Upload start: %s to path: %s\n", millis(), uploadFileName.c_str(), uploadPath.c_str());
// Validate file extension // Validate file extension
if (!isEpubFile(uploadFileName)) { if (!isEpubFile(uploadFileName)) {
@ -658,7 +907,9 @@ void CrossPointWebServer::handleUpload() {
} }
// Create file path // Create file path
String filePath = "/" + uploadFileName; String filePath = uploadPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += uploadFileName;
// Check if file already exists // Check if file already exists
if (SD.exists(filePath.c_str())) { if (SD.exists(filePath.c_str())) {
@ -702,7 +953,9 @@ void CrossPointWebServer::handleUpload() {
if (uploadFile) { if (uploadFile) {
uploadFile.close(); uploadFile.close();
// Try to delete the incomplete file // Try to delete the incomplete file
String filePath = "/" + uploadFileName; String filePath = uploadPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += uploadFileName;
SD.remove(filePath.c_str()); SD.remove(filePath.c_str());
} }
uploadError = "Upload aborted"; uploadError = "Upload aborted";
@ -718,3 +971,53 @@ void CrossPointWebServer::handleUploadPost() {
server->send(400, "text/plain", error); server->send(400, "text/plain", error);
} }
} }
void CrossPointWebServer::handleCreateFolder() {
// Get folder name from form data
if (!server->hasArg("name")) {
server->send(400, "text/plain", "Missing folder name");
return;
}
String folderName = server->arg("name");
// Validate folder name
if (folderName.isEmpty()) {
server->send(400, "text/plain", "Folder name cannot be empty");
return;
}
// Get parent path
String parentPath = "/";
if (server->hasArg("path")) {
parentPath = server->arg("path");
if (!parentPath.startsWith("/")) {
parentPath = "/" + parentPath;
}
if (parentPath.length() > 1 && parentPath.endsWith("/")) {
parentPath = parentPath.substring(0, parentPath.length() - 1);
}
}
// Build full folder path
String folderPath = parentPath;
if (!folderPath.endsWith("/")) folderPath += "/";
folderPath += folderName;
Serial.printf("[%lu] [WEB] Creating folder: %s\n", millis(), folderPath.c_str());
// Check if already exists
if (SD.exists(folderPath.c_str())) {
server->send(400, "text/plain", "Folder already exists");
return;
}
// Create the folder
if (SD.mkdir(folderPath.c_str())) {
Serial.printf("[%lu] [WEB] Folder created successfully: %s\n", millis(), folderPath.c_str());
server->send(200, "text/plain", "Folder created: " + folderName);
} else {
Serial.printf("[%lu] [WEB] Failed to create folder: %s\n", millis(), folderPath.c_str());
server->send(500, "text/plain", "Failed to create folder");
}
}

View File

@ -10,6 +10,7 @@ struct FileInfo {
String name; String name;
size_t size; size_t size;
bool isEpub; bool isEpub;
bool isDirectory;
}; };
class CrossPointWebServer { class CrossPointWebServer {
@ -49,6 +50,7 @@ class CrossPointWebServer {
void handleFileList(); void handleFileList();
void handleUpload(); void handleUpload();
void handleUploadPost(); void handleUploadPost();
void handleCreateFolder();
}; };
// Global instance // Global instance