From 21a75c624dcd010e9edd229af092b6526146aab5 Mon Sep 17 00:00:00 2001 From: cottongin Date: Thu, 12 Feb 2026 20:40:07 -0500 Subject: [PATCH] feat: Implement bookmark functionality for epub reader Replace bookmark stubs with full add/remove/navigate implementation: - BookmarkStore: per-book binary persistence on SD card with v2 format supporting text snippets (backward-compatible with v1) - Visual bookmark ribbon indicator drawn on bookmarked pages via fillPolygon - Reader menu dynamically shows Add/Remove Bookmark based on current page state - Bookmark selection activity with chapter name, first sentence snippet, and page number display; long-press to delete with confirmation - Go to Bookmark falls back to Table of Contents when no bookmarks exist - Smart snippet extraction: skips partial sentences (lowercase first word) to capture the first full sentence on the page - Label truncation reserves space for page suffix so it's never cut off - Half refresh forced on menu exit to clear popup/menu artifacts Co-authored-by: Cursor --- src/activities/reader/EpubReaderActivity.cpp | 169 ++++++++++- .../EpubReaderBookmarkSelectionActivity.cpp | 262 ++++++++++++++++++ .../EpubReaderBookmarkSelectionActivity.h | 60 ++++ .../reader/EpubReaderMenuActivity.h | 12 +- src/util/BookmarkStore.cpp | 158 +++++++++++ src/util/BookmarkStore.h | 24 ++ 6 files changed, 674 insertions(+), 11 deletions(-) create mode 100644 src/activities/reader/EpubReaderBookmarkSelectionActivity.cpp create mode 100644 src/activities/reader/EpubReaderBookmarkSelectionActivity.h create mode 100644 src/util/BookmarkStore.cpp create mode 100644 src/util/BookmarkStore.h diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index f07173fa..398095e3 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -7,6 +7,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" +#include "EpubReaderBookmarkSelectionActivity.h" #include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderPercentSelectionActivity.h" #include "KOReaderCredentialStore.h" @@ -15,6 +16,7 @@ #include "RecentBooksStore.h" #include "components/UITheme.h" #include "fontIds.h" +#include "util/BookmarkStore.h" #include "util/Dictionary.h" #include "util/LookupHistory.h" @@ -235,10 +237,13 @@ void EpubReaderActivity::loop() { } const int bookProgressPercent = clampPercent(static_cast(bookProgress + 0.5f)); const bool hasDictionary = Dictionary::exists(); + const bool isBookmarked = BookmarkStore::hasBookmark( + epub->getCachePath(), currentSpineIndex, section ? section->currentPage : 0); exitActivity(); enterNewActivity(new EpubReaderMenuActivity( this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, - SETTINGS.orientation, hasDictionary, [this](const uint8_t orientation) { onReaderMenuBack(orientation); }, + SETTINGS.orientation, hasDictionary, isBookmarked, + [this](const uint8_t orientation) { onReaderMenuBack(orientation); }, [this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); })); xSemaphoreGive(renderingMutex); } @@ -332,6 +337,8 @@ void EpubReaderActivity::onReaderMenuBack(const uint8_t orientation) { // Apply the user-selected orientation when the menu is dismissed. // This ensures the menu can be navigated without immediately rotating the screen. applyOrientation(orientation); + // Force a half refresh on the next render to clear menu/popup artifacts + pagesUntilFullRefresh = 1; updateRequired = true; } @@ -400,21 +407,151 @@ void EpubReaderActivity::jumpToPercent(int percent) { void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) { switch (action) { case EpubReaderMenuActivity::MenuAction::ADD_BOOKMARK: { - // Stub — bookmark feature coming soon + const int page = section ? section->currentPage : 0; + + // Extract first full sentence from the current page for the bookmark snippet. + // If the first word is lowercase, the page starts mid-sentence — skip to the + // next sentence boundary and start collecting from there. + std::string snippet; + if (section) { + auto p = section->loadPageFromSectionFile(); + if (p) { + // Gather all words on the page into a flat list for easier traversal + std::vector allWords; + for (const auto& element : p->elements) { + const auto* line = static_cast(element.get()); + if (!line) continue; + const auto& block = line->getBlock(); + if (!block) continue; + for (const auto& word : block->getWords()) { + allWords.push_back(word); + } + } + + if (!allWords.empty()) { + size_t startIdx = 0; + + // Check if the first word starts with a lowercase letter (mid-sentence) + const char firstChar = allWords[0].empty() ? '\0' : allWords[0][0]; + if (firstChar >= 'a' && firstChar <= 'z') { + // Skip past the end of this partial sentence + for (size_t i = 0; i < allWords.size(); i++) { + if (!allWords[i].empty()) { + char last = allWords[i].back(); + if (last == '.' || last == '!' || last == '?' || last == ':') { + startIdx = i + 1; + break; + } + } + } + // If no sentence boundary found, fall back to using everything from the start + if (startIdx >= allWords.size()) { + startIdx = 0; + } + } + + // Collect words from startIdx until the next sentence boundary + for (size_t i = startIdx; i < allWords.size(); i++) { + if (!snippet.empty()) snippet += " "; + snippet += allWords[i]; + if (!allWords[i].empty()) { + char last = allWords[i].back(); + if (last == '.' || last == '!' || last == '?' || last == ':') { + break; + } + } + } + } + } + } + + BookmarkStore::addBookmark(epub->getCachePath(), currentSpineIndex, page, snippet); xSemaphoreTake(renderingMutex, portMAX_DELAY); - GUI.drawPopup(renderer, "Coming soon"); + GUI.drawPopup(renderer, "Bookmark added"); renderer.displayBuffer(HalDisplay::FAST_REFRESH); xSemaphoreGive(renderingMutex); - vTaskDelay(1500 / portTICK_PERIOD_MS); + vTaskDelay(750 / portTICK_PERIOD_MS); + // Exit the menu and return to reading — the bookmark indicator will show on re-render, + // and next menu open will reflect the updated state. + exitActivity(); + pagesUntilFullRefresh = 1; + updateRequired = true; + break; + } + case EpubReaderMenuActivity::MenuAction::REMOVE_BOOKMARK: { + const int page = section ? section->currentPage : 0; + BookmarkStore::removeBookmark(epub->getCachePath(), currentSpineIndex, page); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + GUI.drawPopup(renderer, "Bookmark removed"); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + xSemaphoreGive(renderingMutex); + vTaskDelay(750 / portTICK_PERIOD_MS); + exitActivity(); + pagesUntilFullRefresh = 1; + updateRequired = true; break; } case EpubReaderMenuActivity::MenuAction::GO_TO_BOOKMARK: { - // Stub — bookmark feature coming soon + auto bookmarks = BookmarkStore::load(epub->getCachePath()); + + if (bookmarks.empty()) { + // No bookmarks: fall back to Table of Contents if available, otherwise go back + if (epub->getTocItemsCount() > 0) { + const int currentP = section ? section->currentPage : 0; + const int totalP = section ? section->pageCount : 0; + const int spineIdx = currentSpineIndex; + const std::string path = epub->getPath(); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new EpubReaderChapterSelectionActivity( + this->renderer, this->mappedInput, epub, path, spineIdx, currentP, totalP, + [this] { + exitActivity(); + updateRequired = true; + }, + [this](const int newSpineIndex) { + if (currentSpineIndex != newSpineIndex) { + currentSpineIndex = newSpineIndex; + nextPageNumber = 0; + section.reset(); + } + exitActivity(); + updateRequired = true; + }, + [this](const int newSpineIndex, const int newPage) { + if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) { + currentSpineIndex = newSpineIndex; + nextPageNumber = newPage; + section.reset(); + } + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); + } + // If no TOC either, just return to reader (menu already closed by callback) + break; + } + xSemaphoreTake(renderingMutex, portMAX_DELAY); - GUI.drawPopup(renderer, "Coming soon"); - renderer.displayBuffer(HalDisplay::FAST_REFRESH); + exitActivity(); + enterNewActivity(new EpubReaderBookmarkSelectionActivity( + this->renderer, this->mappedInput, epub, std::move(bookmarks), epub->getCachePath(), + [this] { + exitActivity(); + updateRequired = true; + }, + [this](const int newSpineIndex, const int newPage) { + if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) { + currentSpineIndex = newSpineIndex; + nextPageNumber = newPage; + section.reset(); + } + exitActivity(); + updateRequired = true; + })); xSemaphoreGive(renderingMutex); - vTaskDelay(1500 / portTICK_PERIOD_MS); break; } case EpubReaderMenuActivity::MenuAction::DELETE_DICT_CACHE: { @@ -832,6 +969,22 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginLeft) { page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); + + // Draw bookmark ribbon indicator in top-right corner if current page is bookmarked + if (section && BookmarkStore::hasBookmark(epub->getCachePath(), currentSpineIndex, section->currentPage)) { + const int screenWidth = renderer.getScreenWidth(); + const int bkWidth = 12; + const int bkHeight = 22; + const int bkX = screenWidth - orientedMarginRight - bkWidth + 2; + const int bkY = 0; + const int notchDepth = bkHeight / 3; + const int centerX = bkX + bkWidth / 2; + + const int xPoints[5] = {bkX, bkX + bkWidth, bkX + bkWidth, centerX, bkX}; + const int yPoints[5] = {bkY, bkY, bkY + bkHeight, bkY + bkHeight - notchDepth, bkY + bkHeight}; + renderer.fillPolygon(xPoints, yPoints, 5, true); + } + renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); if (pagesUntilFullRefresh <= 1) { renderer.displayBuffer(HalDisplay::HALF_REFRESH); diff --git a/src/activities/reader/EpubReaderBookmarkSelectionActivity.cpp b/src/activities/reader/EpubReaderBookmarkSelectionActivity.cpp new file mode 100644 index 00000000..8ca8f6da --- /dev/null +++ b/src/activities/reader/EpubReaderBookmarkSelectionActivity.cpp @@ -0,0 +1,262 @@ +#include "EpubReaderBookmarkSelectionActivity.h" + +#include + +#include "MappedInputManager.h" +#include "components/UITheme.h" +#include "fontIds.h" + +int EpubReaderBookmarkSelectionActivity::getTotalItems() const { return static_cast(bookmarks.size()); } + +int EpubReaderBookmarkSelectionActivity::getPageItems() const { + constexpr int lineHeight = 30; + + const int screenHeight = renderer.getScreenHeight(); + const auto orientation = renderer.getOrientation(); + const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted; + const int hintGutterHeight = isPortraitInverted ? 50 : 0; + const int startY = 60 + hintGutterHeight; + const int availableHeight = screenHeight - startY - lineHeight; + return std::max(1, availableHeight / lineHeight); +} + +std::string EpubReaderBookmarkSelectionActivity::getBookmarkPrefix(const Bookmark& bookmark) const { + std::string label; + if (epub) { + const int tocIndex = epub->getTocIndexForSpineIndex(bookmark.spineIndex); + if (tocIndex >= 0 && tocIndex < epub->getTocItemsCount()) { + label = epub->getTocItem(tocIndex).title; + } else { + label = "Chapter " + std::to_string(bookmark.spineIndex + 1); + } + } else { + label = "Chapter " + std::to_string(bookmark.spineIndex + 1); + } + if (!bookmark.snippet.empty()) { + label += " - " + bookmark.snippet; + } + return label; +} + +std::string EpubReaderBookmarkSelectionActivity::getPageSuffix(const Bookmark& bookmark) { + return " - Page " + std::to_string(bookmark.pageNumber + 1); +} + +void EpubReaderBookmarkSelectionActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void EpubReaderBookmarkSelectionActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + + // Trigger first update + updateRequired = true; + xTaskCreate(&EpubReaderBookmarkSelectionActivity::taskTrampoline, "BookmarkSelTask", + 4096, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void EpubReaderBookmarkSelectionActivity::onExit() { + ActivityWithSubactivity::onExit(); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void EpubReaderBookmarkSelectionActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + const int totalItems = getTotalItems(); + + if (totalItems == 0) { + // All bookmarks deleted, go back + if (mappedInput.wasReleased(MappedInputManager::Button::Back) || + mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + onGoBack(); + } + return; + } + + // Delete confirmation mode: wait for confirm (delete) or back (cancel) + if (deleteConfirmMode) { + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (ignoreNextConfirmRelease) { + // Ignore the release from the initial long press + ignoreNextConfirmRelease = false; + } else { + // Confirm delete + BookmarkStore::removeBookmark(cachePath, bookmarks[pendingDeleteIndex].spineIndex, + bookmarks[pendingDeleteIndex].pageNumber); + bookmarks.erase(bookmarks.begin() + pendingDeleteIndex); + if (selectorIndex >= static_cast(bookmarks.size())) { + selectorIndex = std::max(0, static_cast(bookmarks.size()) - 1); + } + deleteConfirmMode = false; + updateRequired = true; + } + } + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + deleteConfirmMode = false; + ignoreNextConfirmRelease = false; + updateRequired = true; + } + return; + } + + // Detect long press on Confirm to trigger delete + constexpr unsigned long DELETE_HOLD_MS = 700; + if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= DELETE_HOLD_MS) { + if (totalItems > 0 && selectorIndex >= 0 && selectorIndex < totalItems) { + deleteConfirmMode = true; + ignoreNextConfirmRelease = true; + pendingDeleteIndex = selectorIndex; + updateRequired = true; + } + return; + } + + const int pageItems = getPageItems(); + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (selectorIndex >= 0 && selectorIndex < totalItems) { + const auto& b = bookmarks[selectorIndex]; + onSelectBookmark(b.spineIndex, b.pageNumber); + } else { + onGoBack(); + } + } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onGoBack(); + } + + buttonNavigator.onNextRelease([this, totalItems] { + selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems); + updateRequired = true; + }); + + buttonNavigator.onPreviousRelease([this, totalItems] { + selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems); + updateRequired = true; + }); + + buttonNavigator.onNextContinuous([this, totalItems, pageItems] { + selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems); + updateRequired = true; + }); + + buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] { + selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems); + updateRequired = true; + }); +} + +void EpubReaderBookmarkSelectionActivity::displayTaskLoop() { + while (true) { + if (updateRequired && !subActivity) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + renderScreen(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void EpubReaderBookmarkSelectionActivity::renderScreen() { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto orientation = renderer.getOrientation(); + const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise; + const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise; + const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted; + const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 30 : 0; + const int contentX = isLandscapeCw ? hintGutterWidth : 0; + const int contentWidth = pageWidth - hintGutterWidth; + const int hintGutterHeight = isPortraitInverted ? 50 : 0; + const int contentY = hintGutterHeight; + const int pageItems = getPageItems(); + const int totalItems = getTotalItems(); + + // Title + const int titleX = + contentX + (contentWidth - renderer.getTextWidth(UI_12_FONT_ID, "Go to Bookmark", EpdFontFamily::BOLD)) / 2; + renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, "Go to Bookmark", true, EpdFontFamily::BOLD); + + if (totalItems == 0) { + renderer.drawCenteredText(UI_10_FONT_ID, 100 + contentY, "No bookmarks", true); + } else { + const auto pageStartIndex = selectorIndex / pageItems * pageItems; + renderer.fillRect(contentX, 60 + contentY + (selectorIndex % pageItems) * 30 - 2, contentWidth - 1, 30); + + const int maxLabelWidth = contentWidth - 40 - contentX - 20; + + for (int i = 0; i < pageItems; i++) { + int itemIndex = pageStartIndex + i; + if (itemIndex >= totalItems) break; + const int displayY = 60 + contentY + i * 30; + const bool isSelected = (itemIndex == selectorIndex); + + const std::string suffix = getPageSuffix(bookmarks[itemIndex]); + const int suffixWidth = renderer.getTextWidth(UI_10_FONT_ID, suffix.c_str()); + + // Truncate the prefix (chapter + snippet) to leave room for the page suffix + const std::string prefix = getBookmarkPrefix(bookmarks[itemIndex]); + const std::string truncatedPrefix = + renderer.truncatedText(UI_10_FONT_ID, prefix.c_str(), maxLabelWidth - suffixWidth); + + const std::string label = truncatedPrefix + suffix; + + renderer.drawText(UI_10_FONT_ID, contentX + 20, displayY, label.c_str(), !isSelected); + } + } + + if (deleteConfirmMode && pendingDeleteIndex < static_cast(bookmarks.size())) { + // Draw delete confirmation overlay + const std::string suffix = getPageSuffix(bookmarks[pendingDeleteIndex]); + std::string msg = "Delete bookmark" + suffix + "?"; + + constexpr int margin = 15; + constexpr int popupY = 200; + const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, msg.c_str(), EpdFontFamily::BOLD); + const int textHeight = renderer.getLineHeight(UI_12_FONT_ID); + const int w = textWidth + margin * 2; + const int h = textHeight + margin * 2; + const int x = (renderer.getScreenWidth() - w) / 2; + + renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true); + renderer.fillRect(x, popupY, w, h, false); + + const int textX = x + (w - textWidth) / 2; + const int textY = popupY + margin - 2; + renderer.drawText(UI_12_FONT_ID, textX, textY, msg.c_str(), true, EpdFontFamily::BOLD); + + const auto labels = mappedInput.mapLabels("Cancel", "Delete", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + } else { + if (!bookmarks.empty()) { + const char* deleteHint = "Hold select to delete"; + const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint); + renderer.drawText(SMALL_FONT_ID, (renderer.getScreenWidth() - hintWidth) / 2, + renderer.getScreenHeight() - 70, deleteHint); + } + + const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down"); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + } + + renderer.displayBuffer(); +} diff --git a/src/activities/reader/EpubReaderBookmarkSelectionActivity.h b/src/activities/reader/EpubReaderBookmarkSelectionActivity.h new file mode 100644 index 00000000..4caf7c3f --- /dev/null +++ b/src/activities/reader/EpubReaderBookmarkSelectionActivity.h @@ -0,0 +1,60 @@ +#pragma once +#include +#include +#include +#include + +#include +#include + +#include "../ActivityWithSubactivity.h" +#include "util/BookmarkStore.h" +#include "util/ButtonNavigator.h" + +class EpubReaderBookmarkSelectionActivity final : public ActivityWithSubactivity { + std::shared_ptr epub; + std::vector bookmarks; + std::string cachePath; + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + ButtonNavigator buttonNavigator; + int selectorIndex = 0; + bool updateRequired = false; + bool deleteConfirmMode = false; + bool ignoreNextConfirmRelease = false; + int pendingDeleteIndex = 0; + const std::function onGoBack; + const std::function onSelectBookmark; + + // Number of items that fit on a page, derived from logical screen height. + int getPageItems() const; + + int getTotalItems() const; + + // Build the prefix portion of a bookmark label (chapter + snippet, without page suffix) + std::string getBookmarkPrefix(const Bookmark& bookmark) const; + + // Build the page suffix (e.g. " - Page 5") + static std::string getPageSuffix(const Bookmark& bookmark); + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void renderScreen(); + + public: + explicit EpubReaderBookmarkSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::shared_ptr& epub, + std::vector bookmarks, + const std::string& cachePath, + const std::function& onGoBack, + const std::function& onSelectBookmark) + : ActivityWithSubactivity("EpubReaderBookmarkSelection", renderer, mappedInput), + epub(epub), + bookmarks(std::move(bookmarks)), + cachePath(cachePath), + onGoBack(onGoBack), + onSelectBookmark(onSelectBookmark) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index 731149b4..9a002039 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -16,6 +16,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity { // Menu actions available from the reader menu. enum class MenuAction { ADD_BOOKMARK, + REMOVE_BOOKMARK, LOOKUP, LOOKED_UP_WORDS, ROTATE_SCREEN, @@ -31,10 +32,11 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity { explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title, const int currentPage, const int totalPages, const int bookProgressPercent, const uint8_t currentOrientation, const bool hasDictionary, + const bool isBookmarked, const std::function& onBack, const std::function& onAction) : ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput), - menuItems(buildMenuItems(hasDictionary)), + menuItems(buildMenuItems(hasDictionary, isBookmarked)), title(title), pendingOrientation(currentOrientation), currentPage(currentPage), @@ -70,9 +72,13 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity { const std::function onBack; const std::function onAction; - static std::vector buildMenuItems(bool hasDictionary) { + static std::vector buildMenuItems(bool hasDictionary, bool isBookmarked) { std::vector items; - items.push_back({MenuAction::ADD_BOOKMARK, "Add Bookmark"}); + if (isBookmarked) { + items.push_back({MenuAction::REMOVE_BOOKMARK, "Remove Bookmark"}); + } else { + items.push_back({MenuAction::ADD_BOOKMARK, "Add Bookmark"}); + } if (hasDictionary) { items.push_back({MenuAction::LOOKUP, "Lookup Word"}); items.push_back({MenuAction::LOOKED_UP_WORDS, "Lookup Word History"}); diff --git a/src/util/BookmarkStore.cpp b/src/util/BookmarkStore.cpp new file mode 100644 index 00000000..a370a481 --- /dev/null +++ b/src/util/BookmarkStore.cpp @@ -0,0 +1,158 @@ +#include "BookmarkStore.h" + +#include + +#include + +std::string BookmarkStore::filePath(const std::string& cachePath) { return cachePath + "/bookmarks.bin"; } + +std::vector BookmarkStore::load(const std::string& cachePath) { + std::vector bookmarks; + FsFile f; + if (!Storage.openFileForRead("BKM", filePath(cachePath), f)) { + return bookmarks; + } + + // File format v2: [version(1)] [count(2)] [entries...] + // Each entry: [spine(2)] [page(2)] [snippetLen(1)] [snippet(snippetLen)] + // v1 (no version byte): [count(2)] [entries of 4 bytes each] + // We detect v1 by checking if the first byte could be a version marker (0xFF). + + uint8_t firstByte; + if (f.read(&firstByte, 1) != 1) { + f.close(); + return bookmarks; + } + + uint16_t count; + bool hasSnippets; + + if (firstByte == 0xFF) { + // v2 format: version marker was 0xFF + hasSnippets = true; + uint8_t countBytes[2]; + if (f.read(countBytes, 2) != 2) { + f.close(); + return bookmarks; + } + count = static_cast(countBytes[0]) | (static_cast(countBytes[1]) << 8); + } else { + // v1 format: first byte was part of the count + hasSnippets = false; + uint8_t secondByte; + if (f.read(&secondByte, 1) != 1) { + f.close(); + return bookmarks; + } + count = static_cast(firstByte) | (static_cast(secondByte) << 8); + } + + if (count > MAX_BOOKMARKS) { + count = MAX_BOOKMARKS; + } + + for (uint16_t i = 0; i < count; i++) { + uint8_t entry[4]; + if (f.read(entry, 4) != 4) break; + Bookmark b; + b.spineIndex = static_cast(static_cast(entry[0]) | (static_cast(entry[1]) << 8)); + b.pageNumber = static_cast(static_cast(entry[2]) | (static_cast(entry[3]) << 8)); + + if (hasSnippets) { + uint8_t snippetLen; + if (f.read(&snippetLen, 1) != 1) break; + if (snippetLen > 0) { + std::vector buf(snippetLen); + if (f.read(buf.data(), snippetLen) != snippetLen) break; + b.snippet = std::string(buf.begin(), buf.end()); + } + } + + bookmarks.push_back(b); + } + + f.close(); + return bookmarks; +} + +bool BookmarkStore::save(const std::string& cachePath, const std::vector& bookmarks) { + FsFile f; + if (!Storage.openFileForWrite("BKM", filePath(cachePath), f)) { + Serial.printf("[%lu] [BKM] Could not save bookmarks!\n", millis()); + return false; + } + + // Write v2 format: version marker + count + entries with snippets + uint8_t version = 0xFF; + f.write(&version, 1); + + uint16_t count = static_cast(bookmarks.size()); + uint8_t header[2] = {static_cast(count & 0xFF), static_cast((count >> 8) & 0xFF)}; + f.write(header, 2); + + for (const auto& b : bookmarks) { + uint8_t entry[4]; + entry[0] = static_cast(b.spineIndex & 0xFF); + entry[1] = static_cast((b.spineIndex >> 8) & 0xFF); + entry[2] = static_cast(b.pageNumber & 0xFF); + entry[3] = static_cast((b.pageNumber >> 8) & 0xFF); + f.write(entry, 4); + + // Write snippet: length byte + string data + uint8_t snippetLen = static_cast(std::min(static_cast(b.snippet.size()), MAX_SNIPPET_LENGTH)); + f.write(&snippetLen, 1); + if (snippetLen > 0) { + f.write(reinterpret_cast(b.snippet.c_str()), snippetLen); + } + } + + f.close(); + Serial.printf("[%lu] [BKM] Saved %d bookmarks\n", millis(), count); + return true; +} + +bool BookmarkStore::addBookmark(const std::string& cachePath, int spineIndex, int page, const std::string& snippet) { + auto bookmarks = load(cachePath); + + // Check for duplicate + for (const auto& b : bookmarks) { + if (b.spineIndex == spineIndex && b.pageNumber == page) { + return true; // Already bookmarked + } + } + + if (static_cast(bookmarks.size()) >= MAX_BOOKMARKS) { + return false; + } + + Bookmark b; + b.spineIndex = static_cast(spineIndex); + b.pageNumber = static_cast(page); + b.snippet = snippet.substr(0, MAX_SNIPPET_LENGTH); + bookmarks.push_back(b); + + return save(cachePath, bookmarks); +} + +bool BookmarkStore::removeBookmark(const std::string& cachePath, int spineIndex, int page) { + auto bookmarks = load(cachePath); + + auto it = std::remove_if(bookmarks.begin(), bookmarks.end(), + [spineIndex, page](const Bookmark& b) { + return b.spineIndex == spineIndex && b.pageNumber == page; + }); + + if (it == bookmarks.end()) { + return false; // Not found + } + + bookmarks.erase(it, bookmarks.end()); + return save(cachePath, bookmarks); +} + +bool BookmarkStore::hasBookmark(const std::string& cachePath, int spineIndex, int page) { + auto bookmarks = load(cachePath); + return std::any_of(bookmarks.begin(), bookmarks.end(), [spineIndex, page](const Bookmark& b) { + return b.spineIndex == spineIndex && b.pageNumber == page; + }); +} diff --git a/src/util/BookmarkStore.h b/src/util/BookmarkStore.h new file mode 100644 index 00000000..ca97602d --- /dev/null +++ b/src/util/BookmarkStore.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include +#include + +struct Bookmark { + int16_t spineIndex; + int16_t pageNumber; + std::string snippet; // First sentence or text excerpt from the page +}; + +class BookmarkStore { + public: + static std::vector load(const std::string& cachePath); + static bool save(const std::string& cachePath, const std::vector& bookmarks); + static bool addBookmark(const std::string& cachePath, int spineIndex, int page, const std::string& snippet = ""); + static bool removeBookmark(const std::string& cachePath, int spineIndex, int page); + static bool hasBookmark(const std::string& cachePath, int spineIndex, int page); + + private: + static std::string filePath(const std::string& cachePath); + static constexpr int MAX_BOOKMARKS = 200; + static constexpr int MAX_SNIPPET_LENGTH = 120; +};