diff --git a/.gitignore b/.gitignore index 5dc4671..7523976 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ lib/EpdFont/fontsrc *.generated.h build **/__pycache__/ -test/epubs/ \ No newline at end of file +test/epubs/ +TODO.md \ No newline at end of file diff --git a/src/BookManager.cpp b/src/BookManager.cpp new file mode 100644 index 0000000..326dc8c --- /dev/null +++ b/src/BookManager.cpp @@ -0,0 +1,349 @@ +#include "BookManager.h" + +#include +#include + +#include +#include + +#include "RecentBooksStore.h" + +namespace { +constexpr const char* LOG_TAG = "BM"; + +// Supported book extensions +const char* SUPPORTED_EXTENSIONS[] = {".epub", ".txt", ".xtc", ".xtch"}; +constexpr size_t SUPPORTED_EXTENSIONS_COUNT = sizeof(SUPPORTED_EXTENSIONS) / sizeof(SUPPORTED_EXTENSIONS[0]); +} // namespace + +std::string BookManager::getFilename(const std::string& path) { + const size_t lastSlash = path.find_last_of('/'); + if (lastSlash == std::string::npos) { + return path; + } + return path.substr(lastSlash + 1); +} + +std::string BookManager::getExtension(const std::string& path) { + const size_t lastDot = path.find_last_of('.'); + if (lastDot == std::string::npos) { + return ""; + } + std::string ext = path.substr(lastDot); + // Convert to lowercase + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + return ext; +} + +size_t BookManager::computePathHash(const std::string& path) { + return std::hash{}(path); +} + +std::string BookManager::getCachePrefix(const std::string& path) { + const std::string ext = getExtension(path); + if (ext == ".epub") { + return "epub_"; + } else if (ext == ".txt") { + return "txt_"; + } else if (ext == ".xtc" || ext == ".xtch") { + return "xtc_"; + } + return ""; +} + +std::string BookManager::getCacheDir(const std::string& bookPath) { + const std::string prefix = getCachePrefix(bookPath); + if (prefix.empty()) { + return ""; + } + const size_t hash = computePathHash(bookPath); + return std::string(CROSSPOINT_DIR) + "/" + prefix + std::to_string(hash); +} + +bool BookManager::writeMetaFile(const std::string& archivedPath, const std::string& originalPath) { + const std::string metaPath = archivedPath + ".meta"; + FsFile metaFile; + if (!SdMan.openFileForWrite(LOG_TAG, metaPath, metaFile)) { + Serial.printf("[%lu] [%s] Failed to create meta file: %s\n", millis(), LOG_TAG, metaPath.c_str()); + return false; + } + metaFile.print(originalPath.c_str()); + metaFile.close(); + return true; +} + +std::string BookManager::readMetaFile(const std::string& archivedPath) { + const std::string metaPath = archivedPath + ".meta"; + FsFile metaFile; + if (!SdMan.openFileForRead(LOG_TAG, metaPath, metaFile)) { + Serial.printf("[%lu] [%s] Failed to read meta file: %s\n", millis(), LOG_TAG, metaPath.c_str()); + return ""; + } + + std::string originalPath; + originalPath.reserve(256); + while (metaFile.available()) { + char c = metaFile.read(); + if (c == '\n' || c == '\r') { + break; // Stop at newline + } + originalPath += c; + } + metaFile.close(); + return originalPath; +} + +bool BookManager::ensureParentDirExists(const std::string& path) { + // Find the last slash to get the parent directory + const size_t lastSlash = path.find_last_of('/'); + if (lastSlash == std::string::npos || lastSlash == 0) { + return true; // Root or no parent directory + } + + const std::string parentDir = path.substr(0, lastSlash); + if (SdMan.exists(parentDir.c_str())) { + return true; + } + + // Create parent directories recursively + return SdMan.mkdir(parentDir.c_str()); +} + +bool BookManager::isSupportedBookFormat(const std::string& path) { + const std::string ext = getExtension(path); + for (size_t i = 0; i < SUPPORTED_EXTENSIONS_COUNT; i++) { + if (ext == SUPPORTED_EXTENSIONS[i]) { + return true; + } + } + return false; +} + +bool BookManager::archiveBook(const std::string& bookPath) { + Serial.printf("[%lu] [%s] Archiving book: %s\n", millis(), LOG_TAG, bookPath.c_str()); + + // Validate the book exists + if (!SdMan.exists(bookPath.c_str())) { + Serial.printf("[%lu] [%s] Book not found: %s\n", millis(), LOG_TAG, bookPath.c_str()); + return false; + } + + // Create archive directories if needed + SdMan.mkdir(ARCHIVE_DIR); + SdMan.mkdir(ARCHIVE_CACHE_DIR); + + const std::string filename = getFilename(bookPath); + const std::string archivedBookPath = std::string(ARCHIVE_DIR) + "/" + filename; + + // Check if already archived (same filename exists) + if (SdMan.exists(archivedBookPath.c_str())) { + Serial.printf("[%lu] [%s] A book with this name is already archived: %s\n", millis(), LOG_TAG, filename.c_str()); + return false; + } + + // Write meta file with original path + if (!writeMetaFile(archivedBookPath, bookPath)) { + Serial.printf("[%lu] [%s] Failed to write meta file\n", millis(), LOG_TAG); + return false; + } + + // Move the book file + if (!SdMan.rename(bookPath.c_str(), archivedBookPath.c_str())) { + Serial.printf("[%lu] [%s] Failed to move book file\n", millis(), LOG_TAG); + // Clean up meta file + SdMan.remove((archivedBookPath + ".meta").c_str()); + return false; + } + + // Move cache directory if it exists + const std::string cacheDir = getCacheDir(bookPath); + if (!cacheDir.empty() && SdMan.exists(cacheDir.c_str())) { + const std::string prefix = getCachePrefix(bookPath); + const size_t hash = computePathHash(bookPath); + const std::string archivedCacheDir = std::string(ARCHIVE_CACHE_DIR) + "/" + prefix + std::to_string(hash); + + if (!SdMan.rename(cacheDir.c_str(), archivedCacheDir.c_str())) { + Serial.printf("[%lu] [%s] Warning: Failed to move cache directory (book still archived)\n", millis(), LOG_TAG); + // Don't fail the archive operation, the cache can be regenerated + } else { + Serial.printf("[%lu] [%s] Moved cache to: %s\n", millis(), LOG_TAG, archivedCacheDir.c_str()); + } + } + + // Remove from recent books + RECENT_BOOKS.removeBook(bookPath); + + Serial.printf("[%lu] [%s] Successfully archived: %s\n", millis(), LOG_TAG, bookPath.c_str()); + return true; +} + +bool BookManager::unarchiveBook(const std::string& archivedFilename) { + Serial.printf("[%lu] [%s] Unarchiving book: %s\n", millis(), LOG_TAG, archivedFilename.c_str()); + + const std::string archivedBookPath = std::string(ARCHIVE_DIR) + "/" + archivedFilename; + + // Validate archived book exists + if (!SdMan.exists(archivedBookPath.c_str())) { + Serial.printf("[%lu] [%s] Archived book not found: %s\n", millis(), LOG_TAG, archivedBookPath.c_str()); + return false; + } + + // Read original path from meta file + const std::string originalPath = readMetaFile(archivedBookPath); + if (originalPath.empty()) { + Serial.printf("[%lu] [%s] Failed to read original path from meta file\n", millis(), LOG_TAG); + return false; + } + + // Check if original location already has a file + if (SdMan.exists(originalPath.c_str())) { + Serial.printf("[%lu] [%s] A file already exists at original location: %s\n", millis(), LOG_TAG, + originalPath.c_str()); + return false; + } + + // Ensure parent directory exists + if (!ensureParentDirExists(originalPath)) { + Serial.printf("[%lu] [%s] Failed to create parent directory for: %s\n", millis(), LOG_TAG, originalPath.c_str()); + return false; + } + + // Move the book file back + if (!SdMan.rename(archivedBookPath.c_str(), originalPath.c_str())) { + Serial.printf("[%lu] [%s] Failed to move book file back\n", millis(), LOG_TAG); + return false; + } + + // Move cache directory back if it exists + const std::string prefix = getCachePrefix(archivedFilename); + if (!prefix.empty()) { + const size_t hash = computePathHash(originalPath); + const std::string archivedCacheDir = std::string(ARCHIVE_CACHE_DIR) + "/" + prefix + std::to_string(hash); + const std::string originalCacheDir = std::string(CROSSPOINT_DIR) + "/" + prefix + std::to_string(hash); + + if (SdMan.exists(archivedCacheDir.c_str())) { + if (!SdMan.rename(archivedCacheDir.c_str(), originalCacheDir.c_str())) { + Serial.printf("[%lu] [%s] Warning: Failed to restore cache directory\n", millis(), LOG_TAG); + // Don't fail, cache can be regenerated + } else { + Serial.printf("[%lu] [%s] Restored cache to: %s\n", millis(), LOG_TAG, originalCacheDir.c_str()); + } + } + } + + // Delete the meta file + SdMan.remove((archivedBookPath + ".meta").c_str()); + + Serial.printf("[%lu] [%s] Successfully unarchived to: %s\n", millis(), LOG_TAG, originalPath.c_str()); + return true; +} + +bool BookManager::deleteBook(const std::string& bookPath, bool isArchived) { + Serial.printf("[%lu] [%s] Deleting book (archived=%d): %s\n", millis(), LOG_TAG, isArchived, bookPath.c_str()); + + std::string actualPath; + std::string originalPath; + + if (isArchived) { + // bookPath is just the filename in /.archive/ + actualPath = std::string(ARCHIVE_DIR) + "/" + bookPath; + // Read original path to compute correct cache hash + originalPath = readMetaFile(actualPath); + } else { + actualPath = bookPath; + originalPath = bookPath; + } + + // Validate the book exists + if (!SdMan.exists(actualPath.c_str())) { + Serial.printf("[%lu] [%s] Book not found: %s\n", millis(), LOG_TAG, actualPath.c_str()); + return false; + } + + // Delete the book file + if (!SdMan.remove(actualPath.c_str())) { + Serial.printf("[%lu] [%s] Failed to delete book file\n", millis(), LOG_TAG); + return false; + } + + // Delete cache directory + // Use originalPath for hash calculation (for archived books, this is the path before archiving) + const std::string pathForCache = originalPath.empty() ? actualPath : originalPath; + std::string cacheDir; + + if (isArchived && !originalPath.empty()) { + // For archived books, cache is in /.archive/.cache/ + const std::string prefix = getCachePrefix(pathForCache); + if (!prefix.empty()) { + const size_t hash = computePathHash(originalPath); + cacheDir = std::string(ARCHIVE_CACHE_DIR) + "/" + prefix + std::to_string(hash); + } + } else { + // For regular books, cache is in /.crosspoint/ + cacheDir = getCacheDir(pathForCache); + } + + if (!cacheDir.empty() && SdMan.exists(cacheDir.c_str())) { + if (SdMan.removeDir(cacheDir.c_str())) { + Serial.printf("[%lu] [%s] Deleted cache directory: %s\n", millis(), LOG_TAG, cacheDir.c_str()); + } else { + Serial.printf("[%lu] [%s] Warning: Failed to delete cache directory\n", millis(), LOG_TAG); + } + } + + // Delete meta file if archived + if (isArchived) { + SdMan.remove((actualPath + ".meta").c_str()); + } + + // Remove from recent books (use original path) + if (!originalPath.empty()) { + RECENT_BOOKS.removeBook(originalPath); + } else { + RECENT_BOOKS.removeBook(bookPath); + } + + Serial.printf("[%lu] [%s] Successfully deleted book\n", millis(), LOG_TAG); + return true; +} + +std::vector BookManager::listArchivedBooks() { + std::vector archivedBooks; + + FsFile archiveDir = SdMan.open(ARCHIVE_DIR); + if (!archiveDir || !archiveDir.isDirectory()) { + if (archiveDir) { + archiveDir.close(); + } + return archivedBooks; + } + + char name[128]; + FsFile entry; + while ((entry = archiveDir.openNextFile())) { + if (!entry.isDirectory()) { + entry.getName(name, sizeof(name)); + const std::string filename(name); + + // Skip .meta files and hidden files + if (filename[0] != '.' && filename.find(".meta") == std::string::npos) { + // Only include supported book formats + if (isSupportedBookFormat(filename)) { + archivedBooks.push_back(filename); + } + } + } + entry.close(); + } + archiveDir.close(); + + // Sort alphabetically + std::sort(archivedBooks.begin(), archivedBooks.end()); + + return archivedBooks; +} + +std::string BookManager::getArchivedBookOriginalPath(const std::string& archivedFilename) { + const std::string archivedBookPath = std::string(ARCHIVE_DIR) + "/" + archivedFilename; + return readMetaFile(archivedBookPath); +} diff --git a/src/BookManager.h b/src/BookManager.h new file mode 100644 index 0000000..bb7bffc --- /dev/null +++ b/src/BookManager.h @@ -0,0 +1,80 @@ +#pragma once +#include +#include + +/** + * BookManager - Handles book archiving, unarchiving, and deletion + * + * Archive: Moves book and its cache to /.archive/, preserving reading progress + * Unarchive: Restores book and cache to original location + * Delete: Permanently removes book and its cache + */ +class BookManager { + public: + static constexpr const char* ARCHIVE_DIR = "/.archive"; + static constexpr const char* ARCHIVE_CACHE_DIR = "/.archive/.cache"; + static constexpr const char* CROSSPOINT_DIR = "/.crosspoint"; + + /** + * Archive a book - moves file and cache to /.archive/ + * @param bookPath Full path to the book file (e.g., "/Books/mybook.epub") + * @return true if successful + */ + static bool archiveBook(const std::string& bookPath); + + /** + * Unarchive a book - restores file and cache to original location + * @param archivedFilename Filename in /.archive/ (e.g., "mybook.epub") + * @return true if successful + */ + static bool unarchiveBook(const std::string& archivedFilename); + + /** + * Delete a book permanently - removes file and cache + * @param bookPath Full path to the book file + * @param isArchived If true, treats path as archived filename and also deletes .meta file + * @return true if successful + */ + static bool deleteBook(const std::string& bookPath, bool isArchived = false); + + /** + * List archived books + * @return Vector of archived filenames (without path) + */ + static std::vector listArchivedBooks(); + + /** + * Get the original path of an archived book + * @param archivedFilename Filename in /.archive/ + * @return Original path, or empty string if not found + */ + static std::string getArchivedBookOriginalPath(const std::string& archivedFilename); + + private: + // Extract filename from a full path + static std::string getFilename(const std::string& path); + + // Get the file extension (lowercase, including the dot) + static std::string getExtension(const std::string& path); + + // Compute the hash used for cache directory naming + static size_t computePathHash(const std::string& path); + + // Get cache directory prefix for a file type (epub_, txt_, xtc_) + static std::string getCachePrefix(const std::string& path); + + // Get the full cache directory path for a book + static std::string getCacheDir(const std::string& bookPath); + + // Write the .meta file for an archived book + static bool writeMetaFile(const std::string& archivedPath, const std::string& originalPath); + + // Read the original path from a .meta file + static std::string readMetaFile(const std::string& archivedPath); + + // Create parent directories for a path if they don't exist + static bool ensureParentDirExists(const std::string& path); + + // Check if a path is a supported book format + static bool isSupportedBookFormat(const std::string& path); +}; diff --git a/src/RecentBooksStore.cpp b/src/RecentBooksStore.cpp index 03cfbbd..9a3bf9e 100644 --- a/src/RecentBooksStore.cpp +++ b/src/RecentBooksStore.cpp @@ -32,6 +32,18 @@ void RecentBooksStore::addBook(const std::string& path) { saveToFile(); } +bool RecentBooksStore::removeBook(const std::string& path) { + auto it = std::find(recentBooks.begin(), recentBooks.end(), path); + if (it == recentBooks.end()) { + return false; // Book not found in recent list + } + + recentBooks.erase(it); + saveToFile(); + Serial.printf("[%lu] [RBS] Removed book from recent list: %s\n", millis(), path.c_str()); + return true; +} + bool RecentBooksStore::saveToFile() const { // Make sure the directory exists SdMan.mkdir("/.crosspoint"); diff --git a/src/RecentBooksStore.h b/src/RecentBooksStore.h index b98bd40..1dc16c1 100644 --- a/src/RecentBooksStore.h +++ b/src/RecentBooksStore.h @@ -17,6 +17,10 @@ class RecentBooksStore { // Add a book path to the recent list (moves to front if already exists) void addBook(const std::string& path); + // Remove a book path from the recent list (e.g., when archived or deleted) + // Returns true if the book was found and removed + bool removeBook(const std::string& path); + // Get the list of recent book paths (most recent first) const std::vector& getBooks() const { return recentBooks; } diff --git a/src/activities/home/MyLibraryActivity.cpp b/src/activities/home/MyLibraryActivity.cpp index 9e6f373..3610814 100644 --- a/src/activities/home/MyLibraryActivity.cpp +++ b/src/activities/home/MyLibraryActivity.cpp @@ -5,6 +5,7 @@ #include +#include "BookManager.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" #include "ScreenComponents.h" @@ -22,6 +23,7 @@ constexpr int RIGHT_MARGIN = 40; // Extra space for scroll indicator // Timing thresholds constexpr int SKIP_PAGE_MS = 700; constexpr unsigned long GO_HOME_MS = 1000; +constexpr unsigned long ACTION_MENU_MS = 700; // Long press to open action menu void sortFileList(std::vector& strs) { std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) { @@ -180,7 +182,122 @@ void MyLibraryActivity::onExit() { files.clear(); } +bool MyLibraryActivity::isSelectedItemAFile() const { + if (currentTab == Tab::Recent) { + return !bookPaths.empty() && selectorIndex < static_cast(bookPaths.size()); + } else { + // Files tab - check if it's a file (not a directory) + if (files.empty() || selectorIndex >= static_cast(files.size())) { + return false; + } + return files[selectorIndex].back() != '/'; + } +} + +void MyLibraryActivity::openActionMenu() { + if (!isSelectedItemAFile()) { + return; + } + + if (currentTab == Tab::Recent) { + actionTargetPath = bookPaths[selectorIndex]; + actionTargetName = bookTitles[selectorIndex]; + } else { + if (basepath.back() != '/') { + actionTargetPath = basepath + "/" + files[selectorIndex]; + } else { + actionTargetPath = basepath + files[selectorIndex]; + } + actionTargetName = files[selectorIndex]; + } + + uiState = UIState::ActionMenu; + menuSelection = 0; // Default to Archive + ignoreNextConfirmRelease = true; // Ignore the release from the long-press that opened this menu + updateRequired = true; +} + +void MyLibraryActivity::executeAction() { + bool success = false; + + if (selectedAction == ActionType::Archive) { + success = BookManager::archiveBook(actionTargetPath); + } else { + success = BookManager::deleteBook(actionTargetPath); + } + + if (success) { + // Reload data + loadRecentBooks(); + loadFiles(); + + // Adjust selector if needed + const int itemCount = getCurrentItemCount(); + if (selectorIndex >= itemCount && itemCount > 0) { + selectorIndex = itemCount - 1; + } else if (itemCount == 0) { + selectorIndex = 0; + } + } + + uiState = UIState::Normal; + updateRequired = true; +} + void MyLibraryActivity::loop() { + // Handle action menu state + if (uiState == UIState::ActionMenu) { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + uiState = UIState::Normal; + ignoreNextConfirmRelease = false; + updateRequired = true; + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { + menuSelection = 0; // Archive + updateRequired = true; + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { + menuSelection = 1; // Delete + updateRequired = true; + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + // Ignore the release from the long-press that opened this menu + if (ignoreNextConfirmRelease) { + ignoreNextConfirmRelease = false; + return; + } + selectedAction = (menuSelection == 0) ? ActionType::Archive : ActionType::Delete; + uiState = UIState::Confirming; + updateRequired = true; + return; + } + + return; + } + + // Handle confirmation state + if (uiState == UIState::Confirming) { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + uiState = UIState::ActionMenu; + updateRequired = true; + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + executeAction(); + return; + } + + return; + } + + // Normal state handling const int itemCount = getCurrentItemCount(); const int pageItems = getPageItems(); @@ -196,6 +313,13 @@ void MyLibraryActivity::loop() { return; } + // Long press Confirm to open action menu (only for files, not directories) + if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && + mappedInput.getHeldTime() >= ACTION_MENU_MS && isSelectedItemAFile()) { + openActionMenu(); + return; + } + const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Up); const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Down); const bool leftReleased = mappedInput.wasReleased(MappedInputManager::Button::Left); @@ -203,8 +327,13 @@ void MyLibraryActivity::loop() { const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; - // Confirm button - open selected item + // Confirm button - open selected item (short press) if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + // Ignore if it was a long press that triggered the action menu + if (mappedInput.getHeldTime() >= ACTION_MENU_MS) { + return; + } + if (currentTab == Tab::Recent) { if (!bookPaths.empty() && selectorIndex < static_cast(bookPaths.size())) { onSelectBook(bookPaths[selectorIndex], currentTab); @@ -302,6 +431,20 @@ void MyLibraryActivity::displayTaskLoop() { void MyLibraryActivity::render() const { renderer.clearScreen(); + // Handle different UI states + if (uiState == UIState::ActionMenu) { + renderActionMenu(); + renderer.displayBuffer(); + return; + } + + if (uiState == UIState::Confirming) { + renderConfirmation(); + renderer.displayBuffer(); + return; + } + + // Normal state - draw library view // Draw tab bar std::vector tabs = {{"Recent", currentTab == Tab::Recent}, {"Files", currentTab == Tab::Files}}; ScreenComponents::drawTabBar(renderer, TAB_BAR_Y, tabs); @@ -376,3 +519,69 @@ void MyLibraryActivity::renderFilesTab() const { i != selectorIndex); } } + +void MyLibraryActivity::renderActionMenu() const { + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Title + renderer.drawCenteredText(UI_12_FONT_ID, 20, "Book Actions", true, EpdFontFamily::BOLD); + + // Show filename + const int filenameY = 70; + auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, actionTargetName.c_str(), pageWidth - 40); + renderer.drawCenteredText(UI_10_FONT_ID, filenameY, truncatedName.c_str()); + + // Menu options + const int menuStartY = pageHeight / 2 - 30; + constexpr int menuLineHeight = 40; + constexpr int menuItemWidth = 120; + const int menuX = (pageWidth - menuItemWidth) / 2; + + // Archive option + if (menuSelection == 0) { + renderer.fillRect(menuX - 10, menuStartY - 5, menuItemWidth + 20, menuLineHeight); + } + renderer.drawCenteredText(UI_10_FONT_ID, menuStartY, "Archive", menuSelection != 0); + + // Delete option + if (menuSelection == 1) { + renderer.fillRect(menuX - 10, menuStartY + menuLineHeight - 5, menuItemWidth + 20, menuLineHeight); + } + renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight, "Delete", menuSelection != 1); + + // Draw side button hints (up/down navigation) + renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<"); + + // Draw bottom button hints + const auto labels = mappedInput.mapLabels("« Cancel", "Select", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} + +void MyLibraryActivity::renderConfirmation() const { + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Title based on action + const char* actionTitle = (selectedAction == ActionType::Archive) ? "Archive Book?" : "Delete Book?"; + renderer.drawCenteredText(UI_12_FONT_ID, 20, actionTitle, true, EpdFontFamily::BOLD); + + // Show filename + const int filenameY = pageHeight / 2 - 40; + auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, actionTargetName.c_str(), pageWidth - 40); + renderer.drawCenteredText(UI_10_FONT_ID, filenameY, truncatedName.c_str()); + + // Warning text + const int warningY = pageHeight / 2; + if (selectedAction == ActionType::Archive) { + renderer.drawCenteredText(UI_10_FONT_ID, warningY, "Book will be moved to archive."); + renderer.drawCenteredText(UI_10_FONT_ID, warningY + 25, "Reading progress will be saved."); + } else { + renderer.drawCenteredText(UI_10_FONT_ID, warningY, "Book will be permanently deleted!", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, warningY + 25, "This cannot be undone."); + } + + // Draw bottom button hints + const auto labels = mappedInput.mapLabels("« Cancel", "Confirm", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} diff --git a/src/activities/home/MyLibraryActivity.h b/src/activities/home/MyLibraryActivity.h index c6c52b6..9e2fcf7 100644 --- a/src/activities/home/MyLibraryActivity.h +++ b/src/activities/home/MyLibraryActivity.h @@ -12,6 +12,8 @@ class MyLibraryActivity final : public Activity { public: enum class Tab { Recent, Files }; + enum class UIState { Normal, ActionMenu, Confirming }; + enum class ActionType { Archive, Delete }; private: TaskHandle_t displayTaskHandle = nullptr; @@ -21,6 +23,14 @@ class MyLibraryActivity final : public Activity { int selectorIndex = 0; bool updateRequired = false; + // Action menu state + UIState uiState = UIState::Normal; + ActionType selectedAction = ActionType::Archive; + std::string actionTargetPath; + std::string actionTargetName; + int menuSelection = 0; // 0 = Archive, 1 = Delete + bool ignoreNextConfirmRelease = false; // Prevents immediate selection after long-press opens menu + // Recent tab state std::vector bookTitles; // Display titles for each book std::vector bookPaths; // Paths for each visible book (excludes missing) @@ -50,6 +60,13 @@ class MyLibraryActivity final : public Activity { void render() const; void renderRecentTab() const; void renderFilesTab() const; + void renderActionMenu() const; + void renderConfirmation() const; + + // Action handling + void openActionMenu(); + void executeAction(); + bool isSelectedItemAFile() const; public: explicit MyLibraryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 2417517..1113cad 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -5,6 +5,7 @@ #include #include +#include "BookManager.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "EpubReaderChapterSelectionActivity.h" @@ -119,6 +120,33 @@ void EpubReaderActivity::loop() { return; } + // Handle end-of-book prompt + if (showingEndOfBookPrompt) { + if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { + endOfBookSelection = (endOfBookSelection + 2) % 3; + updateRequired = true; + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { + endOfBookSelection = (endOfBookSelection + 1) % 3; + updateRequired = true; + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + handleEndOfBookAction(); + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + // Go back to last page instead + currentSpineIndex = epub->getSpineItemsCount() - 1; + nextPageNumber = UINT16_MAX; + showingEndOfBookPrompt = false; + updateRequired = true; + return; + } + return; + } + // Enter chapter selection activity if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Don't start activity transition while rendering @@ -260,11 +288,9 @@ void EpubReaderActivity::loop() { return; } - // any botton press when at end of the book goes back to the last page - if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) { - currentSpineIndex = epub->getSpineItemsCount() - 1; - nextPageNumber = UINT16_MAX; - updateRequired = true; + // any button press when at end of the book - this is now handled by the prompt + // Just ensure we don't go past the end + if (currentSpineIndex >= epub->getSpineItemsCount()) { return; } @@ -341,13 +367,13 @@ void EpubReaderActivity::renderScreen() { currentSpineIndex = epub->getSpineItemsCount(); } - // Show end of book screen + // Show end of book prompt if (currentSpineIndex == epub->getSpineItemsCount()) { - renderer.clearScreen(); - renderer.drawCenteredText(UI_12_FONT_ID, 300, "End of book", true, EpdFontFamily::BOLD); - renderer.displayBuffer(); + showingEndOfBookPrompt = true; + renderEndOfBookPrompt(); return; } + showingEndOfBookPrompt = false; // Apply screen viewable areas and additional padding int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; @@ -586,3 +612,60 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in title.c_str()); } } + +void EpubReaderActivity::renderEndOfBookPrompt() { + const int pageWidth = renderer.getScreenWidth(); + const int pageHeight = renderer.getScreenHeight(); + + renderer.clearScreen(); + + // Title + renderer.drawCenteredText(UI_12_FONT_ID, 80, "Finished!", true, EpdFontFamily::BOLD); + + // Book title (truncated if needed) + std::string bookTitle = epub->getTitle(); + if (bookTitle.length() > 30) { + bookTitle = bookTitle.substr(0, 27) + "..."; + } + renderer.drawCenteredText(UI_10_FONT_ID, 120, bookTitle.c_str()); + + // Menu options + const int menuStartY = pageHeight / 2 - 30; + constexpr int menuLineHeight = 45; + constexpr int menuItemWidth = 140; + const int menuX = (pageWidth - menuItemWidth) / 2; + + const char* options[] = {"Archive", "Delete", "Keep"}; + for (int i = 0; i < 3; i++) { + const int optionY = menuStartY + i * menuLineHeight; + if (endOfBookSelection == i) { + renderer.fillRect(menuX - 10, optionY - 5, menuItemWidth + 20, menuLineHeight - 5); + } + renderer.drawCenteredText(UI_10_FONT_ID, optionY, options[i], endOfBookSelection != i); + } + + // Button hints + const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} + +void EpubReaderActivity::handleEndOfBookAction() { + const std::string bookPath = epub->getPath(); + + switch (endOfBookSelection) { + case 0: // Archive + BookManager::archiveBook(bookPath); + onGoHome(); + break; + case 1: // Delete + BookManager::deleteBook(bookPath); + onGoHome(); + break; + case 2: // Keep + default: + onGoHome(); + break; + } +} diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 63d4887..7018b9f 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -19,12 +19,18 @@ class EpubReaderActivity final : public ActivityWithSubactivity { const std::function onGoBack; const std::function onGoHome; + // End-of-book prompt state + bool showingEndOfBookPrompt = false; + int endOfBookSelection = 2; // 0=Archive, 1=Delete, 2=Keep (default to safe option) + static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void renderScreen(); void renderContents(std::unique_ptr page, int orientedMarginTop, int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft); void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const; + void renderEndOfBookPrompt(); + void handleEndOfBookAction(); public: explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr epub, diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index db72532..5195967 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -5,6 +5,7 @@ #include #include +#include "BookManager.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" @@ -95,6 +96,30 @@ void TxtReaderActivity::loop() { return; } + // Handle end-of-book prompt + if (showingEndOfBookPrompt) { + if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { + endOfBookSelection = (endOfBookSelection + 2) % 3; + updateRequired = true; + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { + endOfBookSelection = (endOfBookSelection + 1) % 3; + updateRequired = true; + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + handleEndOfBookAction(); + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + showingEndOfBookPrompt = false; + updateRequired = true; + return; + } + return; + } + // Long press BACK (1s+) goes directly to home if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) { onGoHome(); @@ -121,9 +146,15 @@ void TxtReaderActivity::loop() { if (prevReleased && currentPage > 0) { currentPage--; updateRequired = true; - } else if (nextReleased && currentPage < totalPages - 1) { - currentPage++; - updateRequired = true; + } else if (nextReleased) { + if (currentPage < totalPages - 1) { + currentPage++; + updateRequired = true; + } else { + // At last page, show end-of-book prompt + showingEndOfBookPrompt = true; + updateRequired = true; + } } } @@ -381,6 +412,12 @@ void TxtReaderActivity::renderScreen() { return; } + // Show end-of-book prompt if active + if (showingEndOfBookPrompt) { + renderEndOfBookPrompt(); + return; + } + // Initialize reader if not done if (!initialized) { renderer.clearScreen(); @@ -698,3 +735,64 @@ void TxtReaderActivity::savePageIndexCache() const { f.close(); Serial.printf("[%lu] [TRS] Saved page index cache: %d pages\n", millis(), totalPages); } + +void TxtReaderActivity::renderEndOfBookPrompt() { + const int pageWidth = renderer.getScreenWidth(); + const int pageHeight = renderer.getScreenHeight(); + + renderer.clearScreen(); + + // Title + renderer.drawCenteredText(UI_12_FONT_ID, 80, "Finished!", true, EpdFontFamily::BOLD); + + // Filename (truncated if needed) + std::string filename = txt->getPath(); + const size_t lastSlash = filename.find_last_of('/'); + if (lastSlash != std::string::npos) { + filename = filename.substr(lastSlash + 1); + } + if (filename.length() > 30) { + filename = filename.substr(0, 27) + "..."; + } + renderer.drawCenteredText(UI_10_FONT_ID, 120, filename.c_str()); + + // Menu options + const int menuStartY = pageHeight / 2 - 30; + constexpr int menuLineHeight = 45; + constexpr int menuItemWidth = 140; + const int menuX = (pageWidth - menuItemWidth) / 2; + + const char* options[] = {"Archive", "Delete", "Keep"}; + for (int i = 0; i < 3; i++) { + const int optionY = menuStartY + i * menuLineHeight; + if (endOfBookSelection == i) { + renderer.fillRect(menuX - 10, optionY - 5, menuItemWidth + 20, menuLineHeight - 5); + } + renderer.drawCenteredText(UI_10_FONT_ID, optionY, options[i], endOfBookSelection != i); + } + + // Button hints + const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} + +void TxtReaderActivity::handleEndOfBookAction() { + const std::string bookPath = txt->getPath(); + + switch (endOfBookSelection) { + case 0: // Archive + BookManager::archiveBook(bookPath); + onGoHome(); + break; + case 1: // Delete + BookManager::deleteBook(bookPath); + onGoHome(); + break; + case 2: // Keep + default: + onGoHome(); + break; + } +} diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index 41ccbfb..dd4e152 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -21,6 +21,10 @@ class TxtReaderActivity final : public ActivityWithSubactivity { const std::function onGoBack; const std::function onGoHome; + // End-of-book prompt state + bool showingEndOfBookPrompt = false; + int endOfBookSelection = 2; // 0=Archive, 1=Delete, 2=Keep + // Streaming text reader - stores file offsets for each page std::vector pageOffsets; // File offset for start of each page std::vector currentPageLines; @@ -38,6 +42,8 @@ class TxtReaderActivity final : public ActivityWithSubactivity { void renderScreen(); void renderPage(); void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const; + void renderEndOfBookPrompt(); + void handleEndOfBookAction(); void initializeReader(); bool loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset); diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 0a58d7b..d944ad6 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -11,6 +11,7 @@ #include #include +#include "BookManager.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" @@ -79,6 +80,32 @@ void XtcReaderActivity::loop() { return; } + // Handle end-of-book prompt + if (showingEndOfBookPrompt) { + if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { + endOfBookSelection = (endOfBookSelection + 2) % 3; + updateRequired = true; + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { + endOfBookSelection = (endOfBookSelection + 1) % 3; + updateRequired = true; + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + handleEndOfBookAction(); + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + // Go back to last page + currentPage = xtc->getPageCount() - 1; + showingEndOfBookPrompt = false; + updateRequired = true; + return; + } + return; + } + // Enter chapter selection activity if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) { @@ -122,10 +149,8 @@ void XtcReaderActivity::loop() { return; } - // Handle end of book + // If at end of book prompt position, handle differently if (currentPage >= xtc->getPageCount()) { - currentPage = xtc->getPageCount() - 1; - updateRequired = true; return; } @@ -142,7 +167,7 @@ void XtcReaderActivity::loop() { } else if (nextReleased) { currentPage += skipAmount; if (currentPage >= xtc->getPageCount()) { - currentPage = xtc->getPageCount(); // Allow showing "End of book" + currentPage = xtc->getPageCount(); // Will trigger end-of-book prompt } updateRequired = true; } @@ -165,14 +190,13 @@ void XtcReaderActivity::renderScreen() { return; } - // Bounds check + // Bounds check - show end-of-book prompt if (currentPage >= xtc->getPageCount()) { - // Show end of book screen - renderer.clearScreen(); - renderer.drawCenteredText(UI_12_FONT_ID, 300, "End of book", true, EpdFontFamily::BOLD); - renderer.displayBuffer(); + showingEndOfBookPrompt = true; + renderEndOfBookPrompt(); return; } + showingEndOfBookPrompt = false; renderPage(); saveProgress(); @@ -389,3 +413,64 @@ void XtcReaderActivity::loadProgress() { f.close(); } } + +void XtcReaderActivity::renderEndOfBookPrompt() { + const int pageWidth = renderer.getScreenWidth(); + const int pageHeight = renderer.getScreenHeight(); + + renderer.clearScreen(); + + // Title + renderer.drawCenteredText(UI_12_FONT_ID, 80, "Finished!", true, EpdFontFamily::BOLD); + + // Filename (truncated if needed) + std::string filename = xtc->getPath(); + const size_t lastSlash = filename.find_last_of('/'); + if (lastSlash != std::string::npos) { + filename = filename.substr(lastSlash + 1); + } + if (filename.length() > 30) { + filename = filename.substr(0, 27) + "..."; + } + renderer.drawCenteredText(UI_10_FONT_ID, 120, filename.c_str()); + + // Menu options + const int menuStartY = pageHeight / 2 - 30; + constexpr int menuLineHeight = 45; + constexpr int menuItemWidth = 140; + const int menuX = (pageWidth - menuItemWidth) / 2; + + const char* options[] = {"Archive", "Delete", "Keep"}; + for (int i = 0; i < 3; i++) { + const int optionY = menuStartY + i * menuLineHeight; + if (endOfBookSelection == i) { + renderer.fillRect(menuX - 10, optionY - 5, menuItemWidth + 20, menuLineHeight - 5); + } + renderer.drawCenteredText(UI_10_FONT_ID, optionY, options[i], endOfBookSelection != i); + } + + // Button hints + const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} + +void XtcReaderActivity::handleEndOfBookAction() { + const std::string bookPath = xtc->getPath(); + + switch (endOfBookSelection) { + case 0: // Archive + BookManager::archiveBook(bookPath); + onGoHome(); + break; + case 1: // Delete + BookManager::deleteBook(bookPath); + onGoHome(); + break; + case 2: // Keep + default: + onGoHome(); + break; + } +} diff --git a/src/activities/reader/XtcReaderActivity.h b/src/activities/reader/XtcReaderActivity.h index 579e177..44952ab 100644 --- a/src/activities/reader/XtcReaderActivity.h +++ b/src/activities/reader/XtcReaderActivity.h @@ -24,12 +24,18 @@ class XtcReaderActivity final : public ActivityWithSubactivity { const std::function onGoBack; const std::function onGoHome; + // End-of-book prompt state + bool showingEndOfBookPrompt = false; + int endOfBookSelection = 2; // 0=Archive, 1=Delete, 2=Keep + static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void renderScreen(); void renderPage(); void saveProgress() const; void loadProgress(); + void renderEndOfBookPrompt(); + void handleEndOfBookAction(); public: explicit XtcReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr xtc, diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 90dfed7..cbe63a5 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -9,6 +9,7 @@ #include +#include "BookManager.h" #include "html/FilesPageHtml.generated.h" #include "html/HomePageHtml.generated.h" #include "util/StringUtils.h" @@ -106,6 +107,11 @@ void CrossPointWebServer::begin() { // Delete file/folder endpoint server->on("/delete", HTTP_POST, [this] { handleDelete(); }); + // Archive/Unarchive endpoints + server->on("/archive", HTTP_POST, [this] { handleArchive(); }); + server->on("/unarchive", HTTP_POST, [this] { handleUnarchive(); }); + server->on("/api/archived", HTTP_GET, [this] { handleArchivedList(); }); + server->onNotFound([this] { handleNotFound(); }); Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap()); @@ -602,6 +608,7 @@ void CrossPointWebServer::handleDelete() const { String itemPath = server->arg("path"); const String itemType = server->hasArg("type") ? server->arg("type") : "file"; + const bool isArchived = server->hasArg("archived") && server->arg("archived") == "true"; // Validate path if (itemPath.isEmpty() || itemPath == "/") { @@ -617,8 +624,8 @@ void CrossPointWebServer::handleDelete() const { // Security check: prevent deletion of protected items const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1); - // Check if item starts with a dot (hidden/system file) - if (itemName.startsWith(".")) { + // Check if item starts with a dot (hidden/system file) - but allow archived items + if (itemName.startsWith(".") && !isArchived) { Serial.printf("[%lu] [WEB] Delete rejected - hidden/system item: %s\n", millis(), itemPath.c_str()); server->send(403, "text/plain", "Cannot delete system files"); return; @@ -633,18 +640,19 @@ void CrossPointWebServer::handleDelete() const { } } - // Check if item exists - if (!SdMan.exists(itemPath.c_str())) { - Serial.printf("[%lu] [WEB] Delete 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 delete %s: %s\n", millis(), itemType.c_str(), itemPath.c_str()); + Serial.printf("[%lu] [WEB] Attempting to delete %s (archived=%d): %s\n", millis(), itemType.c_str(), isArchived, + itemPath.c_str()); bool success = false; if (itemType == "folder") { + // Check if item exists + if (!SdMan.exists(itemPath.c_str())) { + Serial.printf("[%lu] [WEB] Delete failed - item not found: %s\n", millis(), itemPath.c_str()); + server->send(404, "text/plain", "Item not found"); + return; + } + // For folders, try to remove (will fail if not empty) FsFile dir = SdMan.open(itemPath.c_str()); if (dir && dir.isDirectory()) { @@ -662,8 +670,13 @@ void CrossPointWebServer::handleDelete() const { } success = SdMan.rmdir(itemPath.c_str()); } else { - // For files, use remove - success = SdMan.remove(itemPath.c_str()); + // For files, use BookManager to also clean up cache and recent books + if (isArchived) { + // For archived books, just pass the filename + success = BookManager::deleteBook(itemName.c_str(), true); + } else { + success = BookManager::deleteBook(itemPath.c_str(), false); + } } if (success) { @@ -675,6 +688,90 @@ void CrossPointWebServer::handleDelete() const { } } +void CrossPointWebServer::handleArchive() const { + if (!server->hasArg("path")) { + server->send(400, "text/plain", "Missing path"); + return; + } + + String bookPath = server->arg("path"); + + // Validate path + if (bookPath.isEmpty() || bookPath == "/") { + server->send(400, "text/plain", "Invalid path"); + return; + } + + // Ensure path starts with / + if (!bookPath.startsWith("/")) { + bookPath = "/" + bookPath; + } + + Serial.printf("[%lu] [WEB] Archiving book: %s\n", millis(), bookPath.c_str()); + + if (BookManager::archiveBook(bookPath.c_str())) { + server->send(200, "text/plain", "Book archived successfully"); + } else { + server->send(500, "text/plain", "Failed to archive book"); + } +} + +void CrossPointWebServer::handleUnarchive() const { + if (!server->hasArg("filename")) { + server->send(400, "text/plain", "Missing filename"); + return; + } + + const String filename = server->arg("filename"); + + if (filename.isEmpty()) { + server->send(400, "text/plain", "Invalid filename"); + return; + } + + Serial.printf("[%lu] [WEB] Unarchiving book: %s\n", millis(), filename.c_str()); + + // Get the original path before unarchiving (for response) + const std::string originalPath = BookManager::getArchivedBookOriginalPath(filename.c_str()); + + if (BookManager::unarchiveBook(filename.c_str())) { + // Return JSON with the original path + String response = "{\"success\":true,\"originalPath\":\""; + response += originalPath.c_str(); + response += "\"}"; + server->send(200, "application/json", response); + } else { + server->send(500, "text/plain", "Failed to unarchive book"); + } +} + +void CrossPointWebServer::handleArchivedList() const { + Serial.printf("[%lu] [WEB] Fetching archived books list\n", millis()); + + const auto archivedBooks = BookManager::listArchivedBooks(); + + // Build JSON response + String response = "["; + bool first = true; + for (const auto& filename : archivedBooks) { + if (!first) { + response += ","; + } + first = false; + + const std::string originalPath = BookManager::getArchivedBookOriginalPath(filename); + + response += "{\"filename\":\""; + response += filename.c_str(); + response += "\",\"originalPath\":\""; + response += originalPath.c_str(); + response += "\"}"; + } + response += "]"; + + server->send(200, "application/json", response); +} + // WebSocket callback trampoline void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { if (wsInstance) { diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index ecc2d3d..1d1c35e 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -60,4 +60,7 @@ class CrossPointWebServer { void handleUploadPost() const; void handleCreateFolder() const; void handleDelete() const; + void handleArchive() const; + void handleUnarchive() const; + void handleArchivedList() const; }; diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index bfdbe3c..bcde366 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -322,8 +322,8 @@ .folder-btn:hover { background-color: #d68910; } - /* Delete button styles */ - .delete-btn { + /* Action button styles */ + .delete-btn, .archive-btn { background: none; border: none; cursor: pointer; @@ -337,10 +337,75 @@ background-color: #fee; color: #e74c3c; } + .archive-btn:hover { + background-color: #e8f4fd; + color: #3498db; + } .actions-col { - width: 60px; + width: 90px; text-align: center; } + /* Archived files button */ + .archived-action-btn { + background-color: #9b59b6; + } + .archived-action-btn:hover { + background-color: #8e44ad; + } + /* Archive modal styles */ + .archive-warning { + color: #3498db; + font-weight: 600; + margin: 10px 0; + } + .archive-btn-confirm { + background-color: #3498db; + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1em; + width: 100%; + } + .archive-btn-confirm:hover { + background-color: #2980b9; + } + /* Archived files list */ + .archived-file-row { + background-color: #f3e5f5 !important; + } + .archived-file-row:hover { + background-color: #e1bee7 !important; + } + .original-path { + font-size: 0.8em; + color: #7f8c8d; + margin-top: 4px; + } + .restore-btn { + background: none; + border: none; + cursor: pointer; + font-size: 1.1em; + padding: 4px 8px; + border-radius: 4px; + color: #95a5a6; + transition: all 0.15s; + } + .restore-btn:hover { + background-color: #e8f6e9; + color: #27ae60; + } + .archive-badge { + display: inline-block; + padding: 3px 8px; + background-color: #9b59b6; + color: white; + border-radius: 4px; + font-size: 0.85em; + margin-left: 10px; + } /* Failed uploads banner */ .failed-uploads-banner { background-color: #fff3cd; @@ -586,6 +651,7 @@
+
@@ -659,12 +725,58 @@

+ + + + + + + + + +