feat: epub bookmarks (#1337)

Co-authored-by: vedi0boy <nate@origin8publishing.com>
Co-authored-by: Uri Tauber <uritaube@gmail.com>
This commit is contained in:
Nathanael Maher
2026-05-27 15:52:01 -04:00
committed by GitHub
co-authored by vedi0boy Uri Tauber
parent bbb3e06eb1
commit 36a3a0cc3a
26 changed files with 700 additions and 128 deletions
+145 -45
View File
@@ -7,16 +7,20 @@
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <JsonSettingsIO.h>
#include <Logging.h>
#include <Memory.h>
#include <esp_system.h>
#include <algorithm>
#include <functional>
#include <iterator>
#include <limits>
#include "BookmarkEntry.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "EpubReaderBookmarksActivity.h"
#include "EpubReaderChapterSelectionActivity.h"
#include "EpubReaderFootnotesActivity.h"
#include "EpubReaderPercentSelectionActivity.h"
@@ -30,6 +34,7 @@
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/BookmarkUtil.h"
#include "util/ScreenshotUtil.h"
namespace {
@@ -246,28 +251,48 @@ void EpubReaderActivity::loop() {
}
}
if (showBookmarkMessage && (millis() - bookmarkMessageTime) >= ReaderUtils::BOOKMARK_MESSAGE_DURATION_MS) {
showBookmarkMessage = false;
requestUpdate();
}
// Enter reader menu activity.
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const int currentPage = section ? section->currentPage + 1 : 0;
const int totalPages = section ? section->pageCount : 0;
float bookProgress = 0.0f;
if (epub->getBookSize() > 0 && section && section->pageCount > 0) {
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(section->pageCount);
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
if (ignoreNextConfirmRelease) {
ignoreNextConfirmRelease = false;
} else {
const int currentPage = section ? section->currentPage + 1 : 0;
const int totalPages = section ? section->pageCount : 0;
float bookProgress = 0.0f;
if (epub->getBookSize() > 0 && section && section->pageCount > 0) {
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(section->pageCount);
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
}
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
SETTINGS.orientation, !currentPageFootnotes.empty()),
[this](const ActivityResult& result) {
// Always apply orientation change even if the menu was cancelled
const auto& menu = std::get<MenuResult>(result.data);
applyOrientation(menu.orientation);
toggleAutoPageTurn(menu.pageTurnOption);
if (!result.isCancelled) {
onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(menu.action));
}
});
}
}
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
mappedInput.getHeldTime() >= ReaderUtils::BOOKMARK_HOLD_MS) {
if (!showBookmarkMessage) {
addBookmark();
showBookmarkMessage = true;
ignoreNextConfirmRelease = true; // Prevent accidental menu open after adding bookmark
bookmarkMessageTime = millis();
requestUpdate();
}
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
SETTINGS.orientation, !currentPageFootnotes.empty()),
[this](const ActivityResult& result) {
// Always apply orientation change even if the menu was cancelled
const auto& menu = std::get<MenuResult>(result.data);
applyOrientation(menu.orientation);
toggleAutoPageTurn(menu.pageTurnOption);
if (!result.isCancelled) {
onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(menu.action));
}
});
}
// Long press BACK (1s+) goes to file selection
@@ -410,6 +435,18 @@ void EpubReaderActivity::jumpToPercent(int percent) {
}
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
auto progressChangeResultHandler = [this](const ActivityResult& result) {
if (!result.isCancelled) {
const auto& sync = std::get<ProgressChangeResult>(result.data);
if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) {
RenderLock lock(*this);
currentSpineIndex = sync.spineIndex;
nextPageNumber = sync.page;
section.reset();
}
}
};
switch (action) {
case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: {
const int spineIdx = currentSpineIndex;
@@ -463,26 +500,11 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
}
case EpubReaderMenuActivity::MenuAction::DISPLAY_QR: {
if (section && section->currentPage >= 0 && section->currentPage < section->pageCount) {
auto p = section->loadPageFromSectionFile();
if (p) {
std::string fullText;
for (const auto& el : p->elements) {
if (el->getTag() == TAG_PageLine) {
const auto& line = static_cast<const PageLine&>(*el);
if (line.getBlock()) {
const auto& words = line.getBlock()->getWords();
for (const auto& w : words) {
if (!fullText.empty()) fullText += " ";
fullText += w;
}
}
}
}
if (!fullText.empty()) {
startActivityForResult(std::make_unique<QrDisplayActivity>(renderer, mappedInput, fullText),
[this](const ActivityResult& result) {});
break;
}
std::string fullText = section->getTextFromSectionFile();
if (!fullText.empty()) {
startActivityForResult(std::make_unique<QrDisplayActivity>(renderer, mappedInput, fullText),
[this](const ActivityResult& result) {});
break;
}
}
// If no text or page loading failed, just close menu
@@ -533,12 +555,8 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
}
// Pre-compute local KO position and chapter name while Epub is still in RAM.
CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPages};
if (paragraphIndex.has_value()) {
localPos.paragraphIndex = *paragraphIndex;
localPos.hasParagraphIndex = true;
}
KOReaderPosition localKoPos = ProgressMapper::toKOReader(epub, localPos);
CrossPointPosition localPos = getCurrentPosition();
SavedProgressPosition localKoPos = ProgressMapper::toSavedProgress(epub, localPos);
const int tocIdx = epub->getTocIndexForSpineIndex(currentSpineIndex);
std::string localChapterName = (tocIdx >= 0) ? epub->getTocItem(tocIdx).title : "";
const std::string savedEpubPath = epub->getPath();
@@ -570,6 +588,12 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
}
break;
}
case EpubReaderMenuActivity::MenuAction::BOOKMARKS: {
startActivityForResult(
std::make_unique<EpubReaderBookmarksActivity>(renderer, mappedInput, epub, epub->getPath()),
progressChangeResultHandler);
break;
}
}
}
@@ -838,6 +862,10 @@ void EpubReaderActivity::render(RenderLock&& lock) {
pendingScreenshot = false;
ScreenshotUtil::takeScreenshot(renderer);
}
if (showBookmarkMessage) {
GUI.drawPopup(renderer, tr(STR_BOOKMARK_ADDED));
}
}
void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportWidth, const uint16_t viewportHeight) {
@@ -1116,6 +1144,58 @@ void EpubReaderActivity::restoreSavedPosition() {
requestUpdate();
}
void EpubReaderActivity::addBookmark() {
if (!section || !epub) {
return;
}
LOG_DBG("ERS", "Adding bookmark at spine %d, page %d", currentSpineIndex, section ? section->currentPage : -1);
int currentPage;
int pageCount;
{
RenderLock lock(*this);
pageCount = section->pageCount;
currentPage = section->currentPage;
}
std::string pageText;
if (currentPage >= 0 && currentPage < pageCount) {
pageText = section->getTextFromSectionFile();
}
SavedProgressPosition progress = ProgressMapper::toSavedProgress(epub, getCurrentPosition());
BookmarkEntry entry;
entry.percentage = progress.percentage;
entry.xpath = progress.xpath;
entry.summary = BookmarkUtil::sanitizeBookmarkSummary(pageText);
// Add bookmark
const std::string path = BookmarkUtil::getBookmarkPath(epub->getPath());
LOG_DBG("ERS", "Bookmark path: %s", path.c_str());
const std::string bookmarksDir = BookmarkUtil::getBookmarksDir();
Storage.mkdir(bookmarksDir.c_str());
std::vector<BookmarkEntry> bookmarks;
if (Storage.exists(path.c_str())) {
LOG_DBG("ERS", "Existing bookmark file found, loading bookmarks");
String json = Storage.readFile(path.c_str());
if (!json.isEmpty()) {
JsonSettingsIO::loadBookmarks(bookmarks, json.c_str());
}
} else {
LOG_DBG("ERS", "No existing bookmark file, starting with empty bookmark list");
}
bookmarks.insert(bookmarks.begin(), entry);
LOG_DBG("ERS", "Saving bookmark to file: %s", path.c_str());
const bool ok = JsonSettingsIO::saveBookmarks(bookmarks, path.c_str());
if (ok) {
showBookmarkMessage = true;
} else {
LOG_ERR("ERS", "Failed to save bookmark to: %s", path.c_str());
}
requestUpdate();
}
ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
ScreenshotInfo info;
info.readerType = ScreenshotInfo::ReaderType::Epub;
@@ -1136,3 +1216,23 @@ ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
}
return info;
}
CrossPointPosition EpubReaderActivity::getCurrentPosition() const {
const int currentPage = section ? section->currentPage : nextPageNumber;
const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount;
std::optional<uint16_t> paragraphIndex;
if (section && currentPage >= 0 && currentPage < section->pageCount) {
const uint16_t paragraphPage =
currentPage > 0 ? static_cast<uint16_t>(currentPage - 1) : static_cast<uint16_t>(currentPage);
if (const auto pIdx = section->getParagraphIndexForPage(paragraphPage)) {
paragraphIndex = *pIdx;
}
}
CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPages};
if (paragraphIndex.has_value()) {
localPos.paragraphIndex = *paragraphIndex;
localPos.hasParagraphIndex = true;
}
return localPos;
}