diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index 4e0a08d2..677f9cac 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -1,5 +1,6 @@ #include "OpdsBookBrowserActivity.h" +#include #include #include #include @@ -355,6 +356,12 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { if (result == HttpDownloader::OK) { Serial.printf("[%lu] [OPDS] Download complete: %s\n", millis(), filename.c_str()); + + // Invalidate any existing cache for this file to prevent stale metadata issues + Epub epub(filename, "/.crosspoint"); + epub.clearCache(); + Serial.printf("[%lu] [OPDS] Cleared cache for: %s\n", millis(), filename.c_str()); + state = BrowserState::BROWSING; updateRequired = true; } else { diff --git a/src/activities/settings/CategorySettingsActivity.cpp b/src/activities/settings/CategorySettingsActivity.cpp index 77ff0b03..a6182b5c 100644 --- a/src/activities/settings/CategorySettingsActivity.cpp +++ b/src/activities/settings/CategorySettingsActivity.cpp @@ -6,6 +6,7 @@ #include #include "CalibreSettingsActivity.h" +#include "ClearCacheActivity.h" #include "CrossPointSettings.h" #include "KOReaderSettingsActivity.h" #include "MappedInputManager.h" @@ -110,6 +111,14 @@ void CategorySettingsActivity::toggleCurrentSetting() { updateRequired = true; })); xSemaphoreGive(renderingMutex); + } else if (strcmp(setting.name, "Clear Cache") == 0) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new ClearCacheActivity(renderer, mappedInput, [this] { + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); } else if (strcmp(setting.name, "Check for updates") == 0) { xSemaphoreTake(renderingMutex, portMAX_DELAY); exitActivity(); diff --git a/src/activities/settings/ClearCacheActivity.cpp b/src/activities/settings/ClearCacheActivity.cpp new file mode 100644 index 00000000..1e10c14b --- /dev/null +++ b/src/activities/settings/ClearCacheActivity.cpp @@ -0,0 +1,178 @@ +#include "ClearCacheActivity.h" + +#include +#include +#include + +#include "MappedInputManager.h" +#include "fontIds.h" + +void ClearCacheActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void ClearCacheActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + state = WARNING; + updateRequired = true; + + xTaskCreate(&ClearCacheActivity::taskTrampoline, "ClearCacheActivityTask", + 4096, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void ClearCacheActivity::onExit() { + ActivityWithSubactivity::onExit(); + + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void ClearCacheActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void ClearCacheActivity::render() { + const auto pageHeight = renderer.getScreenHeight(); + + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 15, "Clear Cache", true, EpdFontFamily::BOLD); + + if (state == WARNING) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 60, "This will clear all cached book data.", true); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 30, "All reading progress will be lost!", true, + EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, "Books will need to be re-indexed", true); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 30, "when opened again.", true); + + const auto labels = mappedInput.mapLabels("« Cancel", "Clear", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == CLEARING) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "Clearing cache...", true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + + if (state == SUCCESS) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, "Cache Cleared", true, EpdFontFamily::BOLD); + String resultText = String(clearedCount) + " items removed"; + if (failedCount > 0) { + resultText += ", " + String(failedCount) + " failed"; + } + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, resultText.c_str()); + + const auto labels = mappedInput.mapLabels("« Back", "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == FAILED) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, "Failed to clear cache", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, "Check serial output for details"); + + const auto labels = mappedInput.mapLabels("« Back", "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } +} + +void ClearCacheActivity::clearCache() { + Serial.printf("[%lu] [CLEAR_CACHE] Clearing cache...\n", millis()); + + // Open .crosspoint directory + auto root = SdMan.open("/.crosspoint"); + if (!root || !root.isDirectory()) { + Serial.printf("[%lu] [CLEAR_CACHE] Failed to open cache directory\n", millis()); + if (root) root.close(); + state = FAILED; + updateRequired = true; + return; + } + + clearedCount = 0; + failedCount = 0; + char name[128]; + + // Iterate through all entries in the directory + for (auto file = root.openNextFile(); file; file = root.openNextFile()) { + file.getName(name, sizeof(name)); + String itemName(name); + + // Only delete directories starting with epub_ or xtc_ + if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("xtc_"))) { + String fullPath = "/.crosspoint/" + itemName; + Serial.printf("[%lu] [CLEAR_CACHE] Removing cache: %s\n", millis(), fullPath.c_str()); + + file.close(); // Close before attempting to delete + + if (SdMan.removeDir(fullPath.c_str())) { + clearedCount++; + } else { + Serial.printf("[%lu] [CLEAR_CACHE] Failed to remove: %s\n", millis(), fullPath.c_str()); + failedCount++; + } + } else { + file.close(); + } + } + root.close(); + + Serial.printf("[%lu] [CLEAR_CACHE] Cache cleared: %d removed, %d failed\n", millis(), clearedCount, failedCount); + + state = SUCCESS; + updateRequired = true; +} + +void ClearCacheActivity::loop() { + if (state == WARNING) { + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + Serial.printf("[%lu] [CLEAR_CACHE] User confirmed, starting cache clear\n", millis()); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = CLEARING; + xSemaphoreGive(renderingMutex); + updateRequired = true; + vTaskDelay(10 / portTICK_PERIOD_MS); + + clearCache(); + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + Serial.printf("[%lu] [CLEAR_CACHE] User cancelled\n", millis()); + goBack(); + } + return; + } + + if (state == SUCCESS || state == FAILED) { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + goBack(); + } + return; + } +} diff --git a/src/activities/settings/ClearCacheActivity.h b/src/activities/settings/ClearCacheActivity.h new file mode 100644 index 00000000..31795a95 --- /dev/null +++ b/src/activities/settings/ClearCacheActivity.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include + +#include + +#include "activities/ActivityWithSubactivity.h" + +class ClearCacheActivity final : public ActivityWithSubactivity { + public: + explicit ClearCacheActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& goBack) + : ActivityWithSubactivity("ClearCache", renderer, mappedInput), goBack(goBack) {} + + void onEnter() override; + void onExit() override; + void loop() override; + + private: + enum State { WARNING, CLEARING, SUCCESS, FAILED }; + + State state = WARNING; + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + const std::function goBack; + + int clearedCount = 0; + int failedCount = 0; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render(); + void clearCache(); +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 7e67809b..2c5c3619 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -45,11 +45,11 @@ const SettingInfo controlsSettings[controlsSettingsCount] = { SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip), SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"})}; -constexpr int systemSettingsCount = 4; +constexpr int systemSettingsCount = 5; const SettingInfo systemSettings[systemSettingsCount] = { SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout, {"1 min", "5 min", "10 min", "15 min", "30 min"}), - SettingInfo::Action("KOReader Sync"), SettingInfo::Action("Calibre Settings"), + SettingInfo::Action("KOReader Sync"), SettingInfo::Action("Calibre Settings"), SettingInfo::Action("Clear Cache"), SettingInfo::Action("Check for updates")}; } // namespace diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 23ba36ba..90dfed7b 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -1,6 +1,7 @@ #include "CrossPointWebServer.h" #include +#include #include #include #include @@ -10,6 +11,7 @@ #include "html/FilesPageHtml.generated.h" #include "html/HomePageHtml.generated.h" +#include "util/StringUtils.h" namespace { // Folders/files to hide from the web interface file browser @@ -28,6 +30,15 @@ size_t wsUploadSize = 0; size_t wsUploadReceived = 0; unsigned long wsUploadStartTime = 0; bool wsUploadInProgress = false; + +// Helper function to clear epub cache after upload +void clearEpubCacheIfNeeded(const String& filePath) { + // Only clear cache for .epub files + if (StringUtils::checkFileExtension(filePath, ".epub")) { + Epub(filePath.c_str(), "/.crosspoint").clearCache(); + Serial.printf("[%lu] [WEB] Cleared epub cache for: %s\n", millis(), filePath.c_str()); + } +} } // namespace // File listing page template - now using generated headers: @@ -500,6 +511,12 @@ void CrossPointWebServer::handleUpload() const { uploadFileName.c_str(), uploadSize, elapsed, avgKbps); Serial.printf("[%lu] [WEB] [UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)\n", millis(), writeCount, totalWriteTime, writePercent); + + // Clear epub cache to prevent stale metadata issues when overwriting files + String filePath = uploadPath; + if (!filePath.endsWith("/")) filePath += "/"; + filePath += uploadFileName; + clearEpubCacheIfNeeded(filePath); } } } else if (upload.status == UPLOAD_FILE_ABORTED) { @@ -787,6 +804,12 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* Serial.printf("[%lu] [WS] Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(), wsUploadFileName.c_str(), wsUploadSize, elapsed, kbps); + // Clear epub cache to prevent stale metadata issues when overwriting files + String filePath = wsUploadPath; + if (!filePath.endsWith("/")) filePath += "/"; + filePath += wsUploadFileName; + clearEpubCacheIfNeeded(filePath); + wsServer->sendTXT(num, "DONE"); lastProgressSent = 0; } diff --git a/src/util/StringUtils.cpp b/src/util/StringUtils.cpp index e56bc9df..2426b687 100644 --- a/src/util/StringUtils.cpp +++ b/src/util/StringUtils.cpp @@ -49,6 +49,18 @@ bool checkFileExtension(const std::string& fileName, const char* extension) { return true; } +bool checkFileExtension(const String& fileName, const char* extension) { + if (fileName.length() < strlen(extension)) { + return false; + } + + String localFile(fileName); + String localExtension(extension); + localFile.toLowerCase(); + localExtension.toLowerCase(); + return localFile.endsWith(localExtension); +} + size_t utf8RemoveLastChar(std::string& str) { if (str.empty()) return 0; size_t pos = str.size() - 1; diff --git a/src/util/StringUtils.h b/src/util/StringUtils.h index e001d7b3..5c8332f0 100644 --- a/src/util/StringUtils.h +++ b/src/util/StringUtils.h @@ -1,5 +1,7 @@ #pragma once +#include + #include namespace StringUtils { @@ -15,6 +17,7 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100); * Check if the given filename ends with the specified extension (case-insensitive). */ bool checkFileExtension(const std::string& fileName, const char* extension); +bool checkFileExtension(const String& fileName, const char* extension); // UTF-8 safe string truncation - removes one character from the end // Returns the new size after removing one UTF-8 character