improve file manager

This commit is contained in:
gebeto 2025-12-23 14:34:40 +02:00
parent 14972b34cb
commit ec82ff209d
4 changed files with 228 additions and 61 deletions

View File

@ -3,6 +3,38 @@ import re
SRC_DIR = "src"
def bytes_to_cpp_byte_array(input_file_bytes: bytes, input_file_path: str, output_header_file: str, variable_name: str):
# Format bytes into C++ byte array initialiser format (hex values)
hex_values = [f"0x{b:02x}" for b in input_file_bytes]
bytes_array_declaration = ", ".join(hex_values)
data_length = len(input_file_bytes)
# Generate the C++ header file content
header_content = f"""
#ifndef {variable_name.upper()}_H
#define {variable_name.upper()}_H
#include <cstddef>
#include <cstdint>
// Embedded file: {os.path.basename(input_file_path)}
constexpr uint8_t {variable_name}_data[] = {{
{bytes_array_declaration}
}};
constexpr size_t {variable_name}_size = {data_length};
#endif // {variable_name.upper()}_H
"""
try:
with open(output_header_file, 'w') as f:
f.write(header_content)
print(f"Successfully generated C++ header file: {output_header_file}")
except IOError as e:
print(f"Error writing header file: {e}")
exit(1)
def minify_html(html: str) -> str:
# Tags where whitespace should be preserved
preserve_tags = ['pre', 'code', 'textarea', 'script', 'style']
@ -31,21 +63,37 @@ def minify_html(html: str) -> str:
return html.strip()
def read_src_file(root: str, file: str) -> str:
file_path = os.path.join(root, file)
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
def build_static(src_path: str, dest_dir: str):
filename = os.path.basename(src_path)
base_name, extension = os.path.splitext(filename)
postfix = extension[1:].capitalize()
base_name = f"{os.path.splitext(filename)[0]}{postfix}"
output_cpp_header = os.path.join(dest_dir, f"{base_name}.generated.h")
cpp_variable_name = base_name
if not os.path.exists(dest_dir):
os.makedirs(dest_dir)
if extension == ".html":
# minify HTML content
src_string = open(src_path, "r").read()
minified_str = minify_html(src_string)
src_bytes = minified_str.encode("utf-8")
else:
src_bytes = open(src_path, "rb").read()
bytes_to_cpp_byte_array(
input_file_bytes=src_bytes,
input_file_path=src_path,
output_header_file=output_cpp_header,
variable_name=cpp_variable_name
)
for root, _, files in os.walk(SRC_DIR):
for file in files:
if file.endswith(".html"):
html_path = os.path.join(root, file)
with open(html_path, "r", encoding="utf-8") as f:
html_content = f.read()
# minified = regex.sub("\g<1>", html_content)
minified = minify_html(html_content)
base_name = f"{os.path.splitext(file)[0]}Html"
header_path = os.path.join(root, f"{base_name}.generated.h")
with open(header_path, "w", encoding="utf-8") as h:
h.write(f"// THIS FILE IS AUTOGENERATED, DO NOT EDIT MANUALLY\n\n")
h.write(f"#pragma once\n")
h.write(f'constexpr char {base_name}[] PROGMEM = R"rawliteral({minified})rawliteral";\n')
print(f"Generated: {header_path}")
if file.endswith((".html", ".css", ".js")):
build_static(os.path.join(root, file), root)

View File

@ -67,20 +67,23 @@ void CrossPointWebServer::begin() {
// Setup routes
Serial.printf("[%lu] [WEB] Setting up routes...\n", millis());
server->on("/", HTTP_GET, [this] { handleRoot(); });
server->on("/", HTTP_GET, [this] { handleStatusPage(); });
server->on("/files", HTTP_GET, [this] { handleFileList(); });
server->on("/api/status", HTTP_GET, [this] { handleStatus(); });
server->on("/api/files", HTTP_GET, [this] { handleFileListData(); });
// Upload endpoint with special handling for multipart form data
server->on("/upload", HTTP_POST, [this] { handleUploadPost(); }, [this] { handleUpload(); });
server->on("/api/upload", HTTP_POST, [this] { handleUploadPost(); }, [this] { handleUpload(); });
// Create folder endpoint
server->on("/mkdir", HTTP_POST, [this] { handleCreateFolder(); });
server->on("/api/mkdir", HTTP_POST, [this] { handleCreateFolder(); });
// Move file/folder endpoint
server->on("/api/move", HTTP_POST, [this] { handleMove(); });
// Delete file/folder endpoint
server->on("/delete", HTTP_POST, [this] { handleDelete(); });
server->on("/api/delete", HTTP_POST, [this] { handleDelete(); });
server->onNotFound([this] { handleNotFound(); });
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
@ -150,8 +153,10 @@ void CrossPointWebServer::handleClient() const {
server->handleClient();
}
void CrossPointWebServer::handleRoot() const {
server->send(200, "text/html", HomePageHtml);
void CrossPointWebServer::handleStatusPage() const {
server->setContentLength(HomePageHtml_size);
server->send(200, "text/html", "");
server->sendContent((char*)HomePageHtml_data, HomePageHtml_size);
Serial.printf("[%lu] [WEB] Served root page\n", millis());
}
@ -241,7 +246,11 @@ bool CrossPointWebServer::isEpubFile(const String& filename) const {
return lower.endsWith(".epub");
}
void CrossPointWebServer::handleFileList() const { server->send(200, "text/html", FilesPageHtml); }
void CrossPointWebServer::handleFileList() const {
server->setContentLength(FilesPageHtml_size);
server->send(200, "text/html", "");
server->sendContent((char*)FilesPageHtml_data, FilesPageHtml_size);
}
void CrossPointWebServer::handleFileListData() const {
// Get current path from query string (default to root)
@ -555,3 +564,69 @@ void CrossPointWebServer::handleDelete() const {
server->send(500, "text/plain", "Failed to delete item");
}
}
void CrossPointWebServer::handleMove() const {
// Get path from form data
if (!server->hasArg("path")) {
server->send(400, "text/plain", "Missing path");
return;
}
if (!server->hasArg("new_path")) {
server->send(400, "text/plain", "Missing new path");
return;
}
String itemPath = server->arg("path");
String newItemPath = server->arg("new_path");
const String itemType = server->hasArg("type") ? server->arg("type") : "file";
// Validate path
if (itemPath.isEmpty() || itemPath == "/") {
server->send(400, "text/plain", "Cannot move root directory");
return;
}
// Ensure path starts with /
if (!itemPath.startsWith("/")) {
itemPath = "/" + itemPath;
}
// Security check: prevent renaming of protected items
const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
// Check if item starts with a dot (hidden/system file)
if (itemName.startsWith(".")) {
Serial.printf("[%lu] [WEB] Move rejected - hidden/system item: %s\n", millis(), itemPath.c_str());
server->send(403, "text/plain", "Cannot move system files");
return;
}
// Check against explicitly protected items
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
if (itemName.equals(HIDDEN_ITEMS[i])) {
Serial.printf("[%lu] [WEB] Move rejected - protected item: %s\n", millis(), itemPath.c_str());
server->send(403, "text/plain", "Cannot move protected items");
return;
}
}
// Check if item exists
if (!SdMan.exists(itemPath.c_str())) {
Serial.printf("[%lu] [WEB] Move failed - item not found: %s\n", millis(), itemPath.c_str());
server->send(404, "text/plain", "Item not found");
return;
}
Serial.printf("[%lu] [WEB] Attempting to move %s: %s\n", millis(), itemType.c_str(), itemPath.c_str());
bool success = SdMan.rename(itemPath.c_str(), newItemPath.c_str());
if (success) {
Serial.printf("[%lu] [WEB] Successfully moved: %s\n", millis(), itemPath.c_str());
server->send(200, "text/plain", "Moved successfully");
} else {
Serial.printf("[%lu] [WEB] Failed to move: %s\n", millis(), itemPath.c_str());
server->send(500, "text/plain", "Failed to move item");
}
}

View File

@ -44,7 +44,7 @@ class CrossPointWebServer {
bool isEpubFile(const String& filename) const;
// Request handlers
void handleRoot() const;
void handleStatusPage() const;
void handleNotFound() const;
void handleStatus() const;
void handleFileList() const;
@ -53,4 +53,5 @@ class CrossPointWebServer {
void handleUploadPost() const;
void handleCreateFolder() const;
void handleDelete() const;
void handleMove() const;
};

View File

@ -52,6 +52,7 @@
.breadcrumb-inline a {
color: #3498db;
text-decoration: none;
cursor: pointer;
}
.breadcrumb-inline a:hover {
text-decoration: underline;
@ -667,7 +668,7 @@
<script>
// get current path from query parameter
const currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/');
let currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/');
function escapeHtml(unsafe) {
return unsafe
@ -686,32 +687,45 @@
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)).toLocaleString() + ' ' + sizes[i];
}
async function hydrate() {
// Close modals when clicking overlay
document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
overlay.addEventListener('click', function(e) {
if (e.target === overlay) {
overlay.classList.remove('open');
}
});
});
async function goToPath(path) {
window.history.pushState({}, '', '?path=' + encodeURIComponent(path))
currentPath = path;
updateBreadcrumbs();
fillFilesTable();
}
function reloadContent() {
goToPath(currentPath);
}
async function updateBreadcrumbs() {
const breadcrumbs = document.getElementById('directory-breadcrumbs');
const fileTable = document.getElementById('file-table');
let breadcrumbContent = '<span class="sep">/</span>';
if (currentPath === '/') {
breadcrumbContent += '<span class="current">🏠</span>';
} else {
breadcrumbContent += '<a href="/files">🏠</a>';
breadcrumbContent += `<a onclick="goToPath('/')">🏠</a>`;
const pathSegments = currentPath.split('/');
pathSegments.slice(1, pathSegments.length - 1).forEach(function(segment, index) {
breadcrumbContent += '<span class="sep">/</span><a href="/files?path=' + encodeURIComponent(pathSegments.slice(0, index + 2).join('/')) + '">' + escapeHtml(segment) + '</a>';
breadcrumbContent += `<span class="sep">/</span><a onclick="goToPath('${pathSegments.slice(0, index + 2).join('/')}')">${escapeHtml(segment)}</a>`;
});
breadcrumbContent += '<span class="sep">/</span>';
breadcrumbContent += '<span class="current">' + escapeHtml(pathSegments[pathSegments.length - 1]) + '</span>';
}
breadcrumbs.innerHTML = breadcrumbContent;
}
async function fillFilesTable() {
const fileTable = document.getElementById('file-table');
const folderSummary = document.getElementById('folder-summary');
folderSummary.innerHTML = "";
fileTable.innerHTML = `
<div class="loader-container">
<span class="loader"></span>
</div>
`;
let files = [];
try {
@ -733,7 +747,7 @@
totalSize += file.size;
});
document.getElementById('folder-summary').innerHTML = `${folderCount} folders, ${files.length - folderCount} files, ${formatFileSize(totalSize)}`;
folderSummary.innerHTML = `${folderCount} folders, ${files.length - folderCount} files, ${formatFileSize(totalSize)}`;
if (files.length === 0) {
fileTable.innerHTML = '<div class="no-files">This folder is empty</div>';
@ -756,25 +770,37 @@
if (!folderPath.endsWith("/")) folderPath += "/";
folderPath += file.name;
fileTableContent += '<tr class="folder-row">';
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>`;
fileTableContent += '<td>Folder</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 += '</tr>';
fileTableContent += `
<tr class="folder-row">
<td>
<span class="file-icon">📁</span><a onclick="goToPath('${folderPath}')" class="folder-link">${escapeHtml(file.name)}</a><span class="folder-badge">FOLDER</span>
</td>
<td>Folder</td>
<td>-</td>
<td class="actions-col">
<button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${folderPath.replaceAll("'", "\\'")}', true)" title="Delete folder">🗑️</button>
</td>
</tr>
`;
} else {
let filePath = currentPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += file.name;
fileTableContent += `<tr class="${file.isEpub ? 'epub-file' : ''}">`;
fileTableContent += `<td><span class="file-icon">${file.isEpub ? '📗' : '📄'}</span>${escapeHtml(file.name)}`;
if (file.isEpub) fileTableContent += '<span class="epub-badge">EPUB</span>';
fileTableContent += '</td>';
fileTableContent += `<td>${file.name.split('.').pop().toUpperCase()}</td>`;
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
fileTableContent += `<td class="actions-col"><button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}', false)" title="Delete file">🗑️</button></td>`;
fileTableContent += '</tr>';
fileTableContent += `
<tr class="${file.isEpub ? 'epub-file' : ''}">
<td>
<span class="file-icon">${file.isEpub ? '📗' : '📄'}</span>${escapeHtml(file.name)}
${file.isEpub ? '<span class="epub-badge">EPUB</span>' : ''}
</td>
<td>${file.name.split('.').pop().toUpperCase()}</td>
<td>${formatFileSize(file.size)}</td>
<td class="actions-col">
<button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}', false)" title="Delete file">🗑️</button>
</td>
</tr>
`;
}
});
@ -783,6 +809,20 @@
}
}
async function hydrate() {
// Close modals when clicking overlay
document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
overlay.addEventListener('click', function(e) {
if (e.target === overlay) {
overlay.classList.remove('open');
}
});
});
await updateBreadcrumbs();
await fillFilesTable();
}
// Modal functions
function openUploadModal() {
document.getElementById('uploadPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath;
@ -845,7 +885,8 @@ function uploadFile() {
progressText.textContent = 'All uploads complete!';
setTimeout(() => {
closeUploadModal();
hydrate(); // Refresh file list instead of reloading
// hydrate(); // Refresh file list instead of reloading
reloadContent();
}, 1000);
} else {
progressFill.style.backgroundColor = '#e74c3c';
@ -858,7 +899,8 @@ function uploadFile() {
setTimeout(() => {
closeUploadModal();
showFailedUploadsBanner();
hydrate(); // Refresh file list to show successfully uploaded files
// hydrate(); // Refresh file list to show successfully uploaded files
reloadContent();
}, 2000);
}
return;
@ -871,7 +913,7 @@ function uploadFile() {
const xhr = new XMLHttpRequest();
// Include path as query parameter since multipart form data doesn't make
// form fields available until after file upload completes
xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
xhr.open('POST', '/api/upload?path=' + encodeURIComponent(currentPath), true);
progressFill.style.width = '0%';
progressFill.style.backgroundColor = '#4caf50';
@ -1008,11 +1050,12 @@ function retryAllFailedUploads() {
formData.append('path', currentPath);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/mkdir', true);
xhr.open('POST', '/api/mkdir', true);
xhr.onload = function() {
if (xhr.status === 200) {
window.location.reload();
reloadContent();
closeFolderModal();
} else {
alert('Failed to create folder: ' + xhr.responseText);
}
@ -1046,15 +1089,15 @@ function retryAllFailedUploads() {
formData.append('type', itemType);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/delete', true);
xhr.open('POST', '/api/delete', true);
xhr.onload = function() {
if (xhr.status === 200) {
window.location.reload();
reloadContent();
} else {
alert('Failed to delete: ' + xhr.responseText);
closeDeleteModal();
}
closeDeleteModal();
};
xhr.onerror = function() {