diff --git a/src/BookmarkStore.cpp b/src/BookmarkStore.cpp new file mode 100644 index 0000000..693f176 --- /dev/null +++ b/src/BookmarkStore.cpp @@ -0,0 +1,301 @@ +#include "BookmarkStore.h" + +#include +#include +#include + +#include +#include + +#include "util/StringUtils.h" + +// Include the BookmarkedBook struct definition +#include "activities/home/MyLibraryActivity.h" + +namespace { +constexpr uint8_t BOOKMARKS_FILE_VERSION = 1; +constexpr char BOOKMARKS_FILENAME[] = "bookmarks.bin"; +constexpr int MAX_BOOKMARKS_PER_BOOK = 100; + +// Get cache directory path for a book (same logic as BookManager) +std::string getCacheDir(const std::string& bookPath) { + const size_t hash = std::hash{}(bookPath); + + if (StringUtils::checkFileExtension(bookPath, ".epub")) { + return "/.crosspoint/epub_" + std::to_string(hash); + } else if (StringUtils::checkFileExtension(bookPath, ".txt") || + StringUtils::checkFileExtension(bookPath, ".TXT") || + StringUtils::checkFileExtension(bookPath, ".md")) { + return "/.crosspoint/txt_" + std::to_string(hash); + } + return ""; +} +} // namespace + +std::string BookmarkStore::getBookmarksFilePath(const std::string& bookPath) { + const std::string cacheDir = getCacheDir(bookPath); + if (cacheDir.empty()) return ""; + return cacheDir + "/" + BOOKMARKS_FILENAME; +} + +std::vector BookmarkStore::getBookmarks(const std::string& bookPath) { + std::vector bookmarks; + loadBookmarks(bookPath, bookmarks); + return bookmarks; +} + +bool BookmarkStore::addBookmark(const std::string& bookPath, const Bookmark& bookmark) { + std::vector bookmarks; + loadBookmarks(bookPath, bookmarks); + + // Check if bookmark already exists at this location + auto it = std::find_if(bookmarks.begin(), bookmarks.end(), [&](const Bookmark& b) { + return b.spineIndex == bookmark.spineIndex && b.contentOffset == bookmark.contentOffset; + }); + + if (it != bookmarks.end()) { + Serial.printf("[%lu] [BMS] Bookmark already exists at spine %u, offset %u\n", + millis(), bookmark.spineIndex, bookmark.contentOffset); + return false; + } + + // Add new bookmark + bookmarks.push_back(bookmark); + + // Trim to max size (remove oldest) + if (bookmarks.size() > MAX_BOOKMARKS_PER_BOOK) { + // Sort by timestamp and remove oldest + std::sort(bookmarks.begin(), bookmarks.end(), [](const Bookmark& a, const Bookmark& b) { + return a.timestamp > b.timestamp; // Newest first + }); + bookmarks.resize(MAX_BOOKMARKS_PER_BOOK); + } + + return saveBookmarks(bookPath, bookmarks); +} + +bool BookmarkStore::removeBookmark(const std::string& bookPath, uint16_t spineIndex, uint32_t contentOffset) { + std::vector bookmarks; + loadBookmarks(bookPath, bookmarks); + + auto it = std::find_if(bookmarks.begin(), bookmarks.end(), [&](const Bookmark& b) { + return b.spineIndex == spineIndex && b.contentOffset == contentOffset; + }); + + if (it == bookmarks.end()) { + return false; + } + + bookmarks.erase(it); + Serial.printf("[%lu] [BMS] Removed bookmark at spine %u, offset %u\n", millis(), spineIndex, contentOffset); + + return saveBookmarks(bookPath, bookmarks); +} + +bool BookmarkStore::isPageBookmarked(const std::string& bookPath, uint16_t spineIndex, uint32_t contentOffset) { + std::vector bookmarks; + loadBookmarks(bookPath, bookmarks); + + return std::any_of(bookmarks.begin(), bookmarks.end(), [&](const Bookmark& b) { + return b.spineIndex == spineIndex && b.contentOffset == contentOffset; + }); +} + +int BookmarkStore::getBookmarkCount(const std::string& bookPath) { + const std::string filePath = getBookmarksFilePath(bookPath); + if (filePath.empty()) return 0; + + FsFile inputFile; + if (!SdMan.openFileForRead("BMS", filePath, inputFile)) { + return 0; + } + + uint8_t version; + serialization::readPod(inputFile, version); + if (version != BOOKMARKS_FILE_VERSION) { + inputFile.close(); + return 0; + } + + uint8_t count; + serialization::readPod(inputFile, count); + inputFile.close(); + + return count; +} + +std::vector BookmarkStore::getBooksWithBookmarks() { + std::vector result; + + // Scan /.crosspoint/ directory for cache folders with bookmarks + auto crosspoint = SdMan.open("/.crosspoint"); + if (!crosspoint || !crosspoint.isDirectory()) { + if (crosspoint) crosspoint.close(); + return result; + } + + crosspoint.rewindDirectory(); + char name[256]; + + for (auto entry = crosspoint.openNextFile(); entry; entry = crosspoint.openNextFile()) { + entry.getName(name, sizeof(name)); + + if (!entry.isDirectory()) { + entry.close(); + continue; + } + + // Check if this directory has a bookmarks file + std::string dirPath = "/.crosspoint/"; + dirPath += name; + std::string bookmarksPath = dirPath + "/" + BOOKMARKS_FILENAME; + + if (SdMan.exists(bookmarksPath.c_str())) { + // Read the bookmarks file to get count and book info + FsFile bookmarksFile; + if (SdMan.openFileForRead("BMS", bookmarksPath, bookmarksFile)) { + uint8_t version; + serialization::readPod(bookmarksFile, version); + + if (version == BOOKMARKS_FILE_VERSION) { + uint8_t count; + serialization::readPod(bookmarksFile, count); + + // Read book metadata (stored at end of file) + std::string bookPath, bookTitle, bookAuthor; + + // Skip bookmark entries to get to metadata + for (uint8_t i = 0; i < count; i++) { + std::string tempName; + uint16_t tempSpine; + uint32_t tempOffset, tempTimestamp; + uint16_t tempPage; + serialization::readString(bookmarksFile, tempName); + serialization::readPod(bookmarksFile, tempSpine); + serialization::readPod(bookmarksFile, tempOffset); + serialization::readPod(bookmarksFile, tempPage); + serialization::readPod(bookmarksFile, tempTimestamp); + } + + // Read book metadata + serialization::readString(bookmarksFile, bookPath); + serialization::readString(bookmarksFile, bookTitle); + serialization::readString(bookmarksFile, bookAuthor); + + if (!bookPath.empty() && count > 0) { + BookmarkedBook book; + book.path = bookPath; + book.title = bookTitle; + book.author = bookAuthor; + book.bookmarkCount = count; + result.push_back(book); + } + } + bookmarksFile.close(); + } + } + entry.close(); + } + crosspoint.close(); + + // Sort by title + std::sort(result.begin(), result.end(), [](const BookmarkedBook& a, const BookmarkedBook& b) { + return a.title < b.title; + }); + + return result; +} + +void BookmarkStore::clearBookmarks(const std::string& bookPath) { + const std::string filePath = getBookmarksFilePath(bookPath); + if (filePath.empty()) return; + + SdMan.remove(filePath.c_str()); + Serial.printf("[%lu] [BMS] Cleared all bookmarks for %s\n", millis(), bookPath.c_str()); +} + +bool BookmarkStore::saveBookmarks(const std::string& bookPath, const std::vector& bookmarks) { + const std::string cacheDir = getCacheDir(bookPath); + if (cacheDir.empty()) return false; + + // Make sure the directory exists + SdMan.mkdir(cacheDir.c_str()); + + const std::string filePath = cacheDir + "/" + BOOKMARKS_FILENAME; + + FsFile outputFile; + if (!SdMan.openFileForWrite("BMS", filePath, outputFile)) { + return false; + } + + serialization::writePod(outputFile, BOOKMARKS_FILE_VERSION); + const uint8_t count = static_cast(std::min(bookmarks.size(), static_cast(255))); + serialization::writePod(outputFile, count); + + for (size_t i = 0; i < count; i++) { + const auto& bookmark = bookmarks[i]; + serialization::writeString(outputFile, bookmark.name); + serialization::writePod(outputFile, bookmark.spineIndex); + serialization::writePod(outputFile, bookmark.contentOffset); + serialization::writePod(outputFile, bookmark.pageNumber); + serialization::writePod(outputFile, bookmark.timestamp); + } + + // Store book metadata at end (for getBooksWithBookmarks to read) + // Extract title from path if we don't have it + std::string title = bookPath; + const size_t lastSlash = title.find_last_of('/'); + if (lastSlash != std::string::npos) { + title = title.substr(lastSlash + 1); + } + const size_t dot = title.find_last_of('.'); + if (dot != std::string::npos) { + title.resize(dot); + } + + serialization::writeString(outputFile, bookPath); + serialization::writeString(outputFile, title); + serialization::writeString(outputFile, ""); // Author (not always available) + + outputFile.close(); + Serial.printf("[%lu] [BMS] Bookmarks saved for %s (%d entries)\n", millis(), bookPath.c_str(), count); + return true; +} + +bool BookmarkStore::loadBookmarks(const std::string& bookPath, std::vector& bookmarks) { + bookmarks.clear(); + + const std::string filePath = getBookmarksFilePath(bookPath); + if (filePath.empty()) return false; + + FsFile inputFile; + if (!SdMan.openFileForRead("BMS", filePath, inputFile)) { + return false; + } + + uint8_t version; + serialization::readPod(inputFile, version); + if (version != BOOKMARKS_FILE_VERSION) { + Serial.printf("[%lu] [BMS] Unknown bookmarks file version: %u\n", millis(), version); + inputFile.close(); + return false; + } + + uint8_t count; + serialization::readPod(inputFile, count); + bookmarks.reserve(count); + + for (uint8_t i = 0; i < count; i++) { + Bookmark bookmark; + serialization::readString(inputFile, bookmark.name); + serialization::readPod(inputFile, bookmark.spineIndex); + serialization::readPod(inputFile, bookmark.contentOffset); + serialization::readPod(inputFile, bookmark.pageNumber); + serialization::readPod(inputFile, bookmark.timestamp); + bookmarks.push_back(bookmark); + } + + inputFile.close(); + Serial.printf("[%lu] [BMS] Bookmarks loaded for %s (%d entries)\n", millis(), bookPath.c_str(), count); + return true; +} diff --git a/src/BookmarkStore.h b/src/BookmarkStore.h new file mode 100644 index 0000000..1ec1d66 --- /dev/null +++ b/src/BookmarkStore.h @@ -0,0 +1,63 @@ +#pragma once +#include +#include + +// Forward declaration for BookmarkedBook (used by MyLibraryActivity) +struct BookmarkedBook; + +// A single bookmark within a book +struct Bookmark { + std::string name; // Display name (e.g., "Chapter 1 - Page 42") + uint16_t spineIndex = 0; // For EPUB: which spine item + uint32_t contentOffset = 0; // Content offset for stable positioning + uint16_t pageNumber = 0; // Page number at time of bookmark (for display) + uint32_t timestamp = 0; // Unix timestamp when created + + bool operator==(const Bookmark& other) const { + return spineIndex == other.spineIndex && contentOffset == other.contentOffset; + } +}; + +/** + * BookmarkStore manages bookmarks for books. + * Bookmarks are stored per-book in the book's cache directory: + * /.crosspoint/{epub_|txt_}/bookmarks.bin + * + * This is a static utility class, not a singleton, since bookmarks + * are loaded/saved on demand for specific books. + */ +class BookmarkStore { + public: + // Get all bookmarks for a book + static std::vector getBookmarks(const std::string& bookPath); + + // Add a bookmark to a book + // Returns true if added, false if bookmark already exists at that location + static bool addBookmark(const std::string& bookPath, const Bookmark& bookmark); + + // Remove a bookmark from a book by content offset + // Returns true if removed, false if not found + static bool removeBookmark(const std::string& bookPath, uint16_t spineIndex, uint32_t contentOffset); + + // Check if a specific page is bookmarked + static bool isPageBookmarked(const std::string& bookPath, uint16_t spineIndex, uint32_t contentOffset); + + // Get count of bookmarks for a book (without loading all data) + static int getBookmarkCount(const std::string& bookPath); + + // Get all books that have bookmarks (for Bookmarks tab) + static std::vector getBooksWithBookmarks(); + + // Delete all bookmarks for a book + static void clearBookmarks(const std::string& bookPath); + + private: + // Get the bookmarks file path for a book + static std::string getBookmarksFilePath(const std::string& bookPath); + + // Save bookmarks to file + static bool saveBookmarks(const std::string& bookPath, const std::vector& bookmarks); + + // Load bookmarks from file + static bool loadBookmarks(const std::string& bookPath, std::vector& bookmarks); +}; diff --git a/src/activities/home/BookmarkListActivity.cpp b/src/activities/home/BookmarkListActivity.cpp new file mode 100644 index 0000000..93e45dc --- /dev/null +++ b/src/activities/home/BookmarkListActivity.cpp @@ -0,0 +1,262 @@ +#include "BookmarkListActivity.h" + +#include + +#include "BookmarkStore.h" +#include "MappedInputManager.h" +#include "ScreenComponents.h" +#include "fontIds.h" + +namespace { +constexpr int BASE_TAB_BAR_Y = 15; +constexpr int BASE_CONTENT_START_Y = 60; +constexpr int LINE_HEIGHT = 50; // Taller for bookmark name + location +constexpr int BASE_LEFT_MARGIN = 20; +constexpr int BASE_RIGHT_MARGIN = 40; +constexpr unsigned long ACTION_MENU_MS = 700; // Long press to delete +} // namespace + +int BookmarkListActivity::getPageItems() const { + const int screenHeight = renderer.getScreenHeight(); + const int bottomBarHeight = 60; + const int bezelTop = renderer.getBezelOffsetTop(); + const int bezelBottom = renderer.getBezelOffsetBottom(); + const int availableHeight = screenHeight - (BASE_CONTENT_START_Y + bezelTop) - bottomBarHeight - bezelBottom; + int items = availableHeight / LINE_HEIGHT; + if (items < 1) items = 1; + return items; +} + +int BookmarkListActivity::getTotalPages() const { + const int itemCount = static_cast(bookmarks.size()); + const int pageItems = getPageItems(); + if (itemCount == 0) return 1; + return (itemCount + pageItems - 1) / pageItems; +} + +int BookmarkListActivity::getCurrentPage() const { + const int pageItems = getPageItems(); + return selectorIndex / pageItems + 1; +} + +void BookmarkListActivity::loadBookmarks() { + bookmarks = BookmarkStore::getBookmarks(bookPath); +} + +void BookmarkListActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void BookmarkListActivity::onEnter() { + Activity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + + loadBookmarks(); + selectorIndex = 0; + updateRequired = true; + + xTaskCreate(&BookmarkListActivity::taskTrampoline, "BookmarkListTask", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void BookmarkListActivity::onExit() { + Activity::onExit(); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + vTaskDelay(10 / portTICK_PERIOD_MS); + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; + + bookmarks.clear(); +} + +void BookmarkListActivity::loop() { + // Handle confirmation state + if (uiState == UIState::Confirming) { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + uiState = UIState::Normal; + updateRequired = true; + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + // Delete the bookmark + if (!bookmarks.empty() && selectorIndex < static_cast(bookmarks.size())) { + const auto& bm = bookmarks[selectorIndex]; + BookmarkStore::removeBookmark(bookPath, bm.spineIndex, bm.contentOffset); + loadBookmarks(); + + // Adjust selector if needed + if (selectorIndex >= static_cast(bookmarks.size()) && !bookmarks.empty()) { + selectorIndex = static_cast(bookmarks.size()) - 1; + } else if (bookmarks.empty()) { + selectorIndex = 0; + } + } + uiState = UIState::Normal; + updateRequired = true; + return; + } + + return; + } + + // Normal state handling + const int itemCount = static_cast(bookmarks.size()); + const int pageItems = getPageItems(); + + // Long press Confirm to delete bookmark + if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && + mappedInput.getHeldTime() >= ACTION_MENU_MS && !bookmarks.empty() && + selectorIndex < itemCount) { + uiState = UIState::Confirming; + updateRequired = true; + return; + } + + // Short press Confirm - navigate to bookmark + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (mappedInput.getHeldTime() >= ACTION_MENU_MS) { + return; // Was a long press + } + + if (!bookmarks.empty() && selectorIndex < itemCount) { + const auto& bm = bookmarks[selectorIndex]; + onSelectBookmark(bm.spineIndex, bm.contentOffset); + } + return; + } + + // Back button + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onGoBack(); + return; + } + + // Navigation + const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Up); + const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Down); + + if (upReleased && itemCount > 0) { + selectorIndex = (selectorIndex + itemCount - 1) % itemCount; + updateRequired = true; + } else if (downReleased && itemCount > 0) { + selectorIndex = (selectorIndex + 1) % itemCount; + updateRequired = true; + } +} + +void BookmarkListActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void BookmarkListActivity::render() const { + renderer.clearScreen(); + + if (uiState == UIState::Confirming) { + renderConfirmation(); + renderer.displayBuffer(); + return; + } + + const auto pageWidth = renderer.getScreenWidth(); + const int pageItems = getPageItems(); + const int itemCount = static_cast(bookmarks.size()); + + // Calculate bezel-adjusted margins + const int bezelTop = renderer.getBezelOffsetTop(); + const int bezelLeft = renderer.getBezelOffsetLeft(); + const int bezelRight = renderer.getBezelOffsetRight(); + const int bezelBottom = renderer.getBezelOffsetBottom(); + const int CONTENT_START_Y = BASE_CONTENT_START_Y + bezelTop; + const int LEFT_MARGIN = BASE_LEFT_MARGIN + bezelLeft; + const int RIGHT_MARGIN = BASE_RIGHT_MARGIN + bezelRight; + + // Draw title + auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, bookTitle.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); + renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, BASE_TAB_BAR_Y + bezelTop, truncatedTitle.c_str(), true, EpdFontFamily::BOLD); + + if (itemCount == 0) { + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No bookmarks"); + + const auto labels = mappedInput.mapLabels("\xc2\xab Back", "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + const auto pageStartIndex = selectorIndex / pageItems * pageItems; + + // Draw selection highlight + renderer.fillRect(bezelLeft, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2, + pageWidth - RIGHT_MARGIN - bezelLeft, LINE_HEIGHT); + + // Draw items + for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) { + const auto& bm = bookmarks[i]; + const int y = CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT; + const bool isSelected = (i == selectorIndex); + + // Line 1: Bookmark name + auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, bm.name.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 2, truncatedName.c_str(), !isSelected); + + // Line 2: Location info + std::string locText = "Page " + std::to_string(bm.pageNumber + 1); + renderer.drawText(SMALL_FONT_ID, LEFT_MARGIN, y + 26, locText.c_str(), !isSelected); + } + + // Draw scroll indicator + const int screenHeight = renderer.getScreenHeight(); + const int contentHeight = screenHeight - CONTENT_START_Y - 60 - bezelBottom; + ScreenComponents::drawScrollIndicator(renderer, getCurrentPage(), getTotalPages(), CONTENT_START_Y, contentHeight); + + // Draw side button hints + renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<"); + + // Draw bottom button hints + const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Go to", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} + +void BookmarkListActivity::renderConfirmation() const { + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Title + renderer.drawCenteredText(UI_12_FONT_ID, 20, "Delete Bookmark?", true, EpdFontFamily::BOLD); + + // Show bookmark name + if (!bookmarks.empty() && selectorIndex < static_cast(bookmarks.size())) { + const auto& bm = bookmarks[selectorIndex]; + auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, bm.name.c_str(), pageWidth - 40); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, truncatedName.c_str()); + } + + // Warning text + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 20, "This cannot be undone."); + + // Draw bottom button hints + const auto labels = mappedInput.mapLabels("\xc2\xab Cancel", "Delete", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} diff --git a/src/activities/home/BookmarkListActivity.h b/src/activities/home/BookmarkListActivity.h new file mode 100644 index 0000000..75b62a2 --- /dev/null +++ b/src/activities/home/BookmarkListActivity.h @@ -0,0 +1,65 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +#include "../Activity.h" +#include "BookmarkStore.h" + +/** + * BookmarkListActivity displays all bookmarks for a specific book. + * - Short press: Navigate to bookmark location + * - Long press Confirm: Delete bookmark (with confirmation) + * - Back: Return to previous screen + */ +class BookmarkListActivity final : public Activity { + public: + enum class UIState { Normal, Confirming }; + + private: + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + + std::string bookPath; + std::string bookTitle; + std::vector bookmarks; + int selectorIndex = 0; + bool updateRequired = false; + UIState uiState = UIState::Normal; + + // Callbacks + const std::function onGoBack; + const std::function onSelectBookmark; + + // Number of items that fit on a page + int getPageItems() const; + int getTotalPages() const; + int getCurrentPage() const; + + // Data loading + void loadBookmarks(); + + // Rendering + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + void renderConfirmation() const; + + public: + explicit BookmarkListActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::string& bookPath, const std::string& bookTitle, + const std::function& onGoBack, + const std::function& onSelectBookmark) + : Activity("BookmarkList", renderer, mappedInput), + bookPath(bookPath), + bookTitle(bookTitle), + onGoBack(onGoBack), + onSelectBookmark(onSelectBookmark) {} + void onEnter() override; + void onExit() override; + void loop() override; +};