Merge branch 'feat-kosync-onlongpress' of https://github.com/jpirnay/crosspoint-reader into mybuild

This commit is contained in:
jpirnay
2026-03-23 12:02:25 +01:00
6 changed files with 3878 additions and 85 deletions
+32 -13
View File
@@ -32,21 +32,41 @@ def minify_html(html: str) -> str:
return html.strip() return html.strip()
def sanitize_identifier(name: str) -> str:
"""Sanitize a filename to create a valid C identifier.
C identifiers must:
- Start with a letter or underscore
- Contain only letters, digits, and underscores
"""
# Replace non-alphanumeric characters (including hyphens) with underscores
sanitized = re.sub(r'[^a-zA-Z0-9_]', '_', name)
# Prefix with underscore if starts with a digit
if sanitized and sanitized[0].isdigit():
sanitized = f"_{sanitized}"
return sanitized
for root, _, files in os.walk(SRC_DIR): for root, _, files in os.walk(SRC_DIR):
for file in files: for file in files:
if file.endswith(".html"): if file.endswith(".html") or file.endswith(".js"):
html_path = os.path.join(root, file) file_path = os.path.join(root, file)
with open(html_path, "r", encoding="utf-8") as f: with open(file_path, "r", encoding="utf-8") as f:
html_content = f.read() content = f.read()
# minified = regex.sub("\g<1>", html_content) # Only minify HTML files; JS files are typically pre-minified (e.g., jszip.min.js)
minified = minify_html(html_content) if file.endswith(".html"):
processed = minify_html(content)
else:
processed = content
# Compress with gzip (compresslevel 9 is maximum compression) # Compress with gzip (compresslevel 9 is maximum compression)
# IMPORTANT: we don't use brotli because Firefox doesn't support brotli with insecured context (only supported on HTTPS) # IMPORTANT: we don't use brotli because Firefox doesn't support brotli with insecured context (only supported on HTTPS)
compressed = gzip.compress(minified.encode('utf-8'), compresslevel=9) compressed = gzip.compress(processed.encode('utf-8'), compresslevel=9)
base_name = f"{os.path.splitext(file)[0]}Html" # Create valid C identifier from filename
# Use appropriate suffix based on file type
suffix = "Html" if file.endswith(".html") else "Js"
base_name = sanitize_identifier(f"{os.path.splitext(file)[0]}{suffix}")
header_path = os.path.join(root, f"{base_name}.generated.h") header_path = os.path.join(root, f"{base_name}.generated.h")
with open(header_path, "w", encoding="utf-8") as h: with open(header_path, "w", encoding="utf-8") as h:
@@ -65,10 +85,9 @@ for root, _, files in os.walk(SRC_DIR):
h.write(f"}};\n\n") h.write(f"}};\n\n")
h.write(f"constexpr size_t {base_name}CompressedSize = {len(compressed)};\n") h.write(f"constexpr size_t {base_name}CompressedSize = {len(compressed)};\n")
h.write(f"constexpr size_t {base_name}OriginalSize = {len(minified)};\n") h.write(f"constexpr size_t {base_name}OriginalSize = {len(processed)};\n")
print(f"Generated: {header_path}") print(f"Generated: {header_path}")
print(f" Original: {len(html_content)} bytes") print(f" Original: {len(content)} bytes")
print(f" Minified: {len(minified)} bytes ({100*len(minified)/len(html_content):.1f}%)") print(f" Minified: {len(processed)} bytes ({100*len(processed)/len(content):.1f}%)")
print(f" Compressed: {len(compressed)} bytes ({100*len(compressed)/len(html_content):.1f}%)") print(f" Compressed: {len(compressed)} bytes ({100*len(compressed)/len(content):.1f}%)")
+27 -2
View File
@@ -144,8 +144,33 @@ void EpubReaderActivity::loop() {
} }
} }
// Enter reader menu activity. // Long press CONFIRM (1s+) goes directly to KOReader sync.
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
if (KOREADER_STORE.hasCredentials()) {
const int currentPage = section ? section->currentPage : 0;
const int totalPages = section ? section->pageCount : 0;
startActivityForResult(
std::make_unique<KOReaderSyncActivity>(renderer, mappedInput, epub, epub->getPath(), currentSpineIndex,
currentPage, totalPages),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
const auto& sync = std::get<SyncResult>(result.data);
if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) {
RenderLock lock(*this);
currentSpineIndex = sync.spineIndex;
nextPageNumber = sync.page;
section.reset();
}
}
});
}
return;
}
// Short press CONFIRM enters reader menu activity.
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) &&
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
const int currentPage = section ? section->currentPage + 1 : 0; const int currentPage = section ? section->currentPage + 1 : 0;
const int totalPages = section ? section->pageCount : 0; const int totalPages = section ? section->pageCount : 0;
float bookProgress = 0.0f; float bookProgress = 0.0f;
+102 -24
View File
@@ -17,6 +17,7 @@
#include "html/FilesPageHtml.generated.h" #include "html/FilesPageHtml.generated.h"
#include "html/HomePageHtml.generated.h" #include "html/HomePageHtml.generated.h"
#include "html/SettingsPageHtml.generated.h" #include "html/SettingsPageHtml.generated.h"
#include "html/js/jszip_minJs.generated.h"
namespace { namespace {
// Folders/files to hide from the web interface file browser // Folders/files to hide from the web interface file browser
@@ -37,6 +38,8 @@ size_t wsUploadSize = 0;
size_t wsUploadReceived = 0; size_t wsUploadReceived = 0;
unsigned long wsUploadStartTime = 0; unsigned long wsUploadStartTime = 0;
bool wsUploadInProgress = false; bool wsUploadInProgress = false;
uint8_t wsUploadClientNum = 255; // 255 = no active upload client
size_t wsLastProgressSent = 0;
String wsLastCompleteName; String wsLastCompleteName;
size_t wsLastCompleteSize = 0; size_t wsLastCompleteSize = 0;
unsigned long wsLastCompleteAt = 0; unsigned long wsLastCompleteAt = 0;
@@ -132,6 +135,7 @@ void CrossPointWebServer::begin() {
LOG_DBG("WEB", "Setting up routes..."); LOG_DBG("WEB", "Setting up routes...");
server->on("/", HTTP_GET, [this] { handleRoot(); }); server->on("/", HTTP_GET, [this] { handleRoot(); });
server->on("/files", HTTP_GET, [this] { handleFileList(); }); server->on("/files", HTTP_GET, [this] { handleFileList(); });
server->on("/js/jszip.min.js", HTTP_GET, [this] { handleJszip(); });
server->on("/api/status", HTTP_GET, [this] { handleStatus(); }); server->on("/api/status", HTTP_GET, [this] { handleStatus(); });
server->on("/api/files", HTTP_GET, [this] { handleFileListData(); }); server->on("/api/files", HTTP_GET, [this] { handleFileListData(); });
@@ -189,6 +193,21 @@ void CrossPointWebServer::begin() {
LOG_DBG("WEB", "[MEM] Free heap after server.begin(): %d bytes", ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap after server.begin(): %d bytes", ESP.getFreeHeap());
} }
void CrossPointWebServer::abortWsUpload(const char* tag) {
wsUploadFile.close();
String filePath = wsUploadPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += wsUploadFileName;
if (Storage.remove(filePath.c_str())) {
LOG_DBG(tag, "Deleted incomplete upload: %s", filePath.c_str());
} else {
LOG_DBG(tag, "Failed to delete incomplete upload: %s", filePath.c_str());
}
wsUploadInProgress = false;
wsUploadClientNum = 255;
wsLastProgressSent = 0;
}
void CrossPointWebServer::stop() { void CrossPointWebServer::stop() {
if (!running || !server) { if (!running || !server) {
LOG_DBG("WEB", "stop() called but already stopped (running=%d, server=%p)", running, server.get()); LOG_DBG("WEB", "stop() called but already stopped (running=%d, server=%p)", running, server.get());
@@ -200,10 +219,9 @@ void CrossPointWebServer::stop() {
LOG_DBG("WEB", "[MEM] Free heap before stop: %d bytes", ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap before stop: %d bytes", ESP.getFreeHeap());
// Close any in-progress WebSocket upload // Close any in-progress WebSocket upload and remove partial file
if (wsUploadInProgress && wsUploadFile) { if (wsUploadInProgress && wsUploadFile) {
wsUploadFile.close(); abortWsUpload("WEB");
wsUploadInProgress = false;
} }
// Stop WebSocket server // Stop WebSocket server
@@ -310,6 +328,12 @@ void CrossPointWebServer::handleRoot() const {
LOG_DBG("WEB", "Served root page"); LOG_DBG("WEB", "Served root page");
} }
void CrossPointWebServer::handleJszip() const {
server->sendHeader("Content-Encoding", "gzip");
server->send_P(200, "application/javascript", jszip_minJs, jszip_minJsCompressedSize);
LOG_DBG("WEB", "Served jszip.min.js");
}
void CrossPointWebServer::handleNotFound() const { void CrossPointWebServer::handleNotFound() const {
String message = "404 Not Found\n\n"; String message = "404 Not Found\n\n";
message += "URI: " + server->uri() + "\n"; message += "URI: " + server->uri() + "\n";
@@ -524,7 +548,26 @@ void CrossPointWebServer::handleDownload() const {
server->send(200, contentType.c_str(), ""); server->send(200, contentType.c_str(), "");
NetworkClient client = server->client(); NetworkClient client = server->client();
client.write(file); const size_t chunkSize = 4096;
uint8_t buffer[chunkSize];
bool downloadOk = true;
while (downloadOk && file.available()) {
int result = file.read(buffer, chunkSize);
if (result <= 0) break;
size_t bytesRead = static_cast<size_t>(result);
size_t totalWritten = 0;
while (totalWritten < bytesRead) {
esp_task_wdt_reset();
size_t wrote = client.write(buffer + totalWritten, bytesRead - totalWritten);
if (wrote == 0) {
downloadOk = false;
break;
}
totalWritten += wrote;
}
}
client.clear();
file.close(); file.close();
} }
@@ -1101,7 +1144,7 @@ void CrossPointWebServer::handleGetSettings() const {
doc["type"] = "string"; doc["type"] = "string";
if (s.stringGetter) { if (s.stringGetter) {
doc["value"] = s.stringGetter(); doc["value"] = s.stringGetter();
} else if (s.stringOffset > 0) { } else if (s.stringMaxLen > 0) {
doc["value"] = reinterpret_cast<const char*>(&SETTINGS) + s.stringOffset; doc["value"] = reinterpret_cast<const char*>(&SETTINGS) + s.stringOffset;
} }
break; break;
@@ -1185,7 +1228,7 @@ void CrossPointWebServer::handlePostSettings() {
const std::string val = doc[s.key].as<std::string>(); const std::string val = doc[s.key].as<std::string>();
if (s.stringSetter) { if (s.stringSetter) {
s.stringSetter(val); s.stringSetter(val);
} else if (s.stringOffset > 0 && s.stringMaxLen > 0) { } else if (s.stringMaxLen > 0) {
char* ptr = reinterpret_cast<char*>(&SETTINGS) + s.stringOffset; char* ptr = reinterpret_cast<char*>(&SETTINGS) + s.stringOffset;
strncpy(ptr, val.c_str(), s.stringMaxLen - 1); strncpy(ptr, val.c_str(), s.stringMaxLen - 1);
ptr[s.stringMaxLen - 1] = '\0'; ptr[s.stringMaxLen - 1] = '\0';
@@ -1221,17 +1264,12 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
switch (type) { switch (type) {
case WStype_DISCONNECTED: case WStype_DISCONNECTED:
LOG_DBG("WS", "Client %u disconnected", num); LOG_DBG("WS", "Client %u disconnected", num);
// Clean up any in-progress upload // Only clean up if this is the client that owns the active upload.
if (wsUploadInProgress && wsUploadFile) { // A new client may have already started a fresh upload before this
wsUploadFile.close(); // DISCONNECTED event fires (race condition on quick cancel + retry).
// Delete incomplete file if (num == wsUploadClientNum && wsUploadInProgress && wsUploadFile) {
String filePath = wsUploadPath; abortWsUpload("WS");
if (!filePath.endsWith("/")) filePath += "/";
filePath += wsUploadFileName;
Storage.remove(filePath.c_str());
LOG_DBG("WS", "Deleted incomplete upload: %s", filePath.c_str());
} }
wsUploadInProgress = false;
break; break;
case WStype_CONNECTED: { case WStype_CONNECTED: {
@@ -1245,15 +1283,35 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
LOG_DBG("WS", "Text from client %u: %s", num, msg.c_str()); LOG_DBG("WS", "Text from client %u: %s", num, msg.c_str());
if (msg.startsWith("START:")) { if (msg.startsWith("START:")) {
// Reject any START while an upload is already active to prevent
// leaking the open wsUploadFile handle (owning client re-START included)
if (wsUploadInProgress) {
wsServer->sendTXT(num, "ERROR:Upload already in progress");
break;
}
// Parse: START:<filename>:<size>:<path> // Parse: START:<filename>:<size>:<path>
int firstColon = msg.indexOf(':', 6); int firstColon = msg.indexOf(':', 6);
int secondColon = msg.indexOf(':', firstColon + 1); int secondColon = msg.indexOf(':', firstColon + 1);
if (firstColon > 0 && secondColon > 0) { if (firstColon > 0 && secondColon > 0) {
wsUploadFileName = msg.substring(6, firstColon); wsUploadFileName = msg.substring(6, firstColon);
wsUploadSize = msg.substring(firstColon + 1, secondColon).toInt(); String sizeToken = msg.substring(firstColon + 1, secondColon);
bool sizeValid = sizeToken.length() > 0;
int digitStart = (sizeValid && sizeToken[0] == '+') ? 1 : 0;
if (digitStart > 0 && sizeToken.length() < 2) sizeValid = false;
for (int i = digitStart; i < (int)sizeToken.length() && sizeValid; i++) {
if (!isdigit((unsigned char)sizeToken[i])) sizeValid = false;
}
if (!sizeValid) {
LOG_DBG("WS", "START rejected: invalid size token '%s'", sizeToken.c_str());
wsServer->sendTXT(num, "ERROR:Invalid START format");
return;
}
wsUploadSize = sizeToken.toInt();
wsUploadPath = msg.substring(secondColon + 1); wsUploadPath = msg.substring(secondColon + 1);
wsUploadReceived = 0; wsUploadReceived = 0;
wsLastProgressSent = 0;
wsUploadStartTime = millis(); wsUploadStartTime = millis();
// Ensure path is valid // Ensure path is valid
@@ -1281,10 +1339,25 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
if (!Storage.openFileForWrite("WS", filePath, wsUploadFile)) { if (!Storage.openFileForWrite("WS", filePath, wsUploadFile)) {
wsServer->sendTXT(num, "ERROR:Failed to create file"); wsServer->sendTXT(num, "ERROR:Failed to create file");
wsUploadInProgress = false; wsUploadInProgress = false;
wsUploadClientNum = 255;
return; return;
} }
esp_task_wdt_reset(); esp_task_wdt_reset();
// Zero-byte upload: complete immediately without waiting for BIN frames
if (wsUploadSize == 0) {
wsUploadFile.close();
wsLastCompleteName = wsUploadFileName;
wsLastCompleteSize = 0;
wsLastCompleteAt = millis();
LOG_DBG("WS", "Zero-byte upload complete: %s", filePath.c_str());
clearEpubCacheIfNeeded(filePath);
wsServer->sendTXT(num, "DONE");
wsLastProgressSent = 0;
break;
}
wsUploadClientNum = num;
wsUploadInProgress = true; wsUploadInProgress = true;
wsServer->sendTXT(num, "READY"); wsServer->sendTXT(num, "READY");
} else { } else {
@@ -1295,19 +1368,24 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
} }
case WStype_BIN: { case WStype_BIN: {
if (!wsUploadInProgress || !wsUploadFile) { if (!wsUploadInProgress || !wsUploadFile || num != wsUploadClientNum) {
wsServer->sendTXT(num, "ERROR:No upload in progress"); wsServer->sendTXT(num, "ERROR:No upload in progress");
return; return;
} }
// Write binary data directly to file // Write binary data directly to file
size_t remaining = wsUploadSize - wsUploadReceived;
if (length > remaining) {
abortWsUpload("WS");
wsServer->sendTXT(num, "ERROR:Upload overflow");
return;
}
esp_task_wdt_reset(); esp_task_wdt_reset();
size_t written = wsUploadFile.write(payload, length); size_t written = wsUploadFile.write(payload, length);
esp_task_wdt_reset(); esp_task_wdt_reset();
if (written != length) { if (written != length) {
wsUploadFile.close(); abortWsUpload("WS");
wsUploadInProgress = false;
wsServer->sendTXT(num, "ERROR:Write failed - disk full?"); wsServer->sendTXT(num, "ERROR:Write failed - disk full?");
return; return;
} }
@@ -1315,17 +1393,17 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
wsUploadReceived += written; wsUploadReceived += written;
// Send progress update (every 64KB or at end) // Send progress update (every 64KB or at end)
static size_t lastProgressSent = 0; if (wsUploadReceived - wsLastProgressSent >= 65536 || wsUploadReceived >= wsUploadSize) {
if (wsUploadReceived - lastProgressSent >= 65536 || wsUploadReceived >= wsUploadSize) {
String progress = "PROGRESS:" + String(wsUploadReceived) + ":" + String(wsUploadSize); String progress = "PROGRESS:" + String(wsUploadReceived) + ":" + String(wsUploadSize);
wsServer->sendTXT(num, progress); wsServer->sendTXT(num, progress);
lastProgressSent = wsUploadReceived; wsLastProgressSent = wsUploadReceived;
} }
// Check if upload complete // Check if upload complete
if (wsUploadReceived >= wsUploadSize) { if (wsUploadReceived >= wsUploadSize) {
wsUploadFile.close(); wsUploadFile.close();
wsUploadInProgress = false; wsUploadInProgress = false;
wsUploadClientNum = 255;
wsLastCompleteName = wsUploadFileName; wsLastCompleteName = wsUploadFileName;
wsLastCompleteSize = wsUploadSize; wsLastCompleteSize = wsUploadSize;
@@ -1344,7 +1422,7 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
clearEpubCacheIfNeeded(filePath); clearEpubCacheIfNeeded(filePath);
wsServer->sendTXT(num, "DONE"); wsServer->sendTXT(num, "DONE");
lastProgressSent = 0; wsLastProgressSent = 0;
} }
break; break;
} }
+2
View File
@@ -81,6 +81,7 @@ class CrossPointWebServer {
// WebSocket upload state // WebSocket upload state
void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length); void onWebSocketEvent(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); static void wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length);
void abortWsUpload(const char* tag);
// 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) const;
@@ -88,6 +89,7 @@ class CrossPointWebServer {
// Request handlers // Request handlers
void handleRoot() const; void handleRoot() const;
void handleJszip() const;
void handleNotFound() const; void handleNotFound() const;
void handleStatus() const; void handleStatus() const;
void handleFileList() const; void handleFileList() const;
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long