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:
158
src/util/BookmarkStore.cpp
Normal file
158
src/util/BookmarkStore.cpp
Normal 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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user