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 <cursoragent@cursor.com>
This commit is contained in:
cottongin
2026-02-12 20:40:07 -05:00
parent 8d4bbf284d
commit 21a75c624d
6 changed files with 674 additions and 11 deletions

View File

@@ -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<int>(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<std::string> allWords;
for (const auto& element : p->elements) {
const auto* line = static_cast<const PageLine*>(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> 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);

View File

@@ -0,0 +1,262 @@
#include "EpubReaderBookmarkSelectionActivity.h"
#include <GfxRenderer.h>
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
int EpubReaderBookmarkSelectionActivity::getTotalItems() const { return static_cast<int>(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<EpubReaderBookmarkSelectionActivity*>(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<int>(bookmarks.size())) {
selectorIndex = std::max(0, static_cast<int>(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<int>(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();
}

View File

@@ -0,0 +1,60 @@
#pragma once
#include <Epub.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <memory>
#include <vector>
#include "../ActivityWithSubactivity.h"
#include "util/BookmarkStore.h"
#include "util/ButtonNavigator.h"
class EpubReaderBookmarkSelectionActivity final : public ActivityWithSubactivity {
std::shared_ptr<Epub> epub;
std::vector<Bookmark> 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<void()> onGoBack;
const std::function<void(int newSpineIndex, int newPage)> 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>& epub,
std::vector<Bookmark> bookmarks,
const std::string& cachePath,
const std::function<void()>& onGoBack,
const std::function<void(int newSpineIndex, int newPage)>& 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;
};

View File

@@ -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<void(uint8_t)>& onBack,
const std::function<void(MenuAction)>& 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<void(uint8_t)> onBack;
const std::function<void(MenuAction)> onAction;
static std::vector<MenuItem> buildMenuItems(bool hasDictionary) {
static std::vector<MenuItem> buildMenuItems(bool hasDictionary, bool isBookmarked) {
std::vector<MenuItem> 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"});

158
src/util/BookmarkStore.cpp Normal file
View File

@@ -0,0 +1,158 @@
#include "BookmarkStore.h"
#include <HalStorage.h>
#include <algorithm>
std::string BookmarkStore::filePath(const std::string& cachePath) { return cachePath + "/bookmarks.bin"; }
std::vector<Bookmark> BookmarkStore::load(const std::string& cachePath) {
std::vector<Bookmark> 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<uint16_t>(countBytes[0]) | (static_cast<uint16_t>(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<uint16_t>(firstByte) | (static_cast<uint16_t>(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<int16_t>(static_cast<uint16_t>(entry[0]) | (static_cast<uint16_t>(entry[1]) << 8));
b.pageNumber = static_cast<int16_t>(static_cast<uint16_t>(entry[2]) | (static_cast<uint16_t>(entry[3]) << 8));
if (hasSnippets) {
uint8_t snippetLen;
if (f.read(&snippetLen, 1) != 1) break;
if (snippetLen > 0) {
std::vector<uint8_t> 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<Bookmark>& 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<uint16_t>(bookmarks.size());
uint8_t header[2] = {static_cast<uint8_t>(count & 0xFF), static_cast<uint8_t>((count >> 8) & 0xFF)};
f.write(header, 2);
for (const auto& b : bookmarks) {
uint8_t entry[4];
entry[0] = static_cast<uint8_t>(b.spineIndex & 0xFF);
entry[1] = static_cast<uint8_t>((b.spineIndex >> 8) & 0xFF);
entry[2] = static_cast<uint8_t>(b.pageNumber & 0xFF);
entry[3] = static_cast<uint8_t>((b.pageNumber >> 8) & 0xFF);
f.write(entry, 4);
// Write snippet: length byte + string data
uint8_t snippetLen = static_cast<uint8_t>(std::min(static_cast<int>(b.snippet.size()), MAX_SNIPPET_LENGTH));
f.write(&snippetLen, 1);
if (snippetLen > 0) {
f.write(reinterpret_cast<const uint8_t*>(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<int>(bookmarks.size()) >= MAX_BOOKMARKS) {
return false;
}
Bookmark b;
b.spineIndex = static_cast<int16_t>(spineIndex);
b.pageNumber = static_cast<int16_t>(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;
});
}

24
src/util/BookmarkStore.h Normal file
View File

@@ -0,0 +1,24 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
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<Bookmark> load(const std::string& cachePath);
static bool save(const std::string& cachePath, const std::vector<Bookmark>& 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;
};