From 36a3a0cc3a7cef3ae7048e05968c50ab5283a59a Mon Sep 17 00:00:00 2001 From: Nathanael Maher Date: Wed, 27 May 2026 15:52:01 -0400 Subject: [PATCH] feat: epub bookmarks (#1337) Co-authored-by: vedi0boy Co-authored-by: Uri Tauber --- USER_GUIDE.md | 15 +- lib/Epub/Epub/Section.cpp | 37 +++ lib/Epub/Epub/Section.h | 4 + lib/I18n/translations/english.yaml | 5 + lib/KOReaderSync/ProgressMapper.cpp | 81 ++++++- lib/KOReaderSync/ProgressMapper.h | 25 +- src/BookmarkEntry.h | 14 ++ src/JsonSettingsIO.cpp | 42 ++++ src/JsonSettingsIO.h | 7 + src/activities/ActivityResult.h | 4 +- src/activities/reader/EpubReaderActivity.cpp | 190 +++++++++++---- src/activities/reader/EpubReaderActivity.h | 6 + .../reader/EpubReaderBookmarksActivity.cpp | 224 ++++++++++++++++++ .../reader/EpubReaderBookmarksActivity.h | 33 +++ .../reader/EpubReaderMenuActivity.cpp | 3 +- .../reader/EpubReaderMenuActivity.h | 1 + .../reader/KOReaderSyncActivity.cpp | 58 +---- src/activities/reader/KOReaderSyncActivity.h | 4 +- src/activities/reader/ReaderUtils.h | 2 + src/components/icons/bookmark.h | 12 + src/components/themes/BaseTheme.cpp | 5 + src/components/themes/BaseTheme.h | 3 +- src/components/themes/lyra/LyraTheme.cpp | 8 + src/components/themes/lyra/LyraTheme.h | 1 + src/util/BookmarkUtil.cpp | 35 +++ src/util/BookmarkUtil.h | 9 + 26 files changed, 700 insertions(+), 128 deletions(-) create mode 100644 src/BookmarkEntry.h create mode 100644 src/activities/reader/EpubReaderBookmarksActivity.cpp create mode 100644 src/activities/reader/EpubReaderBookmarksActivity.h create mode 100644 src/components/icons/bookmark.h create mode 100644 src/util/BookmarkUtil.cpp create mode 100644 src/util/BookmarkUtil.h diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 681e3088..ae021d81 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -35,6 +35,7 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control - [Supported Languages](#supported-languages) - [5. Reader Menu](#5-reader-menu) - [5.1 Chapter Selection](#51-chapter-selection) + - [5.2. Bookmarks](#52-bookmarks) - [6. Current Limitations \& Roadmap](#6-current-limitations--roadmap) - [7. Troubleshooting Issues \& Escaping Bootloop](#7-troubleshooting-issues--escaping-bootloop) @@ -502,7 +503,17 @@ Accessible by selecting **Chapters** from the Reader Menu. --- -## 6. Current Limitations & Roadmap +### 5.2 Bookmarks + +Bookmarks can be created to quickly save and restore your place in a book. + +To create a bookmark, hold **Confirm** for 1 second while inside a book. A popup will appear letting you know a bookmark was created. The popup message will automatically disappear in a couple of seconds. + +To open bookmarks, press **Confirm** while inside a book. Then navigate to the **Bookmarks** menu. Bookmarks can be opened by navigating to them and pressing **Confirm**, which will redirect you to that place in the book. You can delete bookmarks by holding **Confirm** for 1 second, and then pressing **Confirm** again to confirm deletion, or **Back** to cancel. + +Bookmarks are stored in the `.crosspoint/bookmarks` folder in the JSON format. + +## 7. Current Limitations & Roadmap Please note that this firmware is currently in active development. The following features are **not yet supported** but are planned for future updates: @@ -514,7 +525,7 @@ Please note that this firmware is currently in active development. The following --- -## 7. Troubleshooting Issues & Escaping Bootloop +## 8. Troubleshooting Issues & Escaping Bootloop If an issue or crash is encountered while using Crosspoint, feel free to raise an issue ticket and attach the logs. diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index e8666dad..6b5fa7f6 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -329,6 +329,43 @@ std::unique_ptr Section::loadPageFromSectionFile() { return page; } +std::string Section::getTextFromSectionFile() { + std::string fullText; + auto p = this->loadPageFromSectionFile(); + if (p) { + for (const auto& el : p->elements) { + if (el->getTag() == TAG_PageLine) { + const auto& line = static_cast(*el); + if (line.getBlock()) { + const auto& words = line.getBlock()->getWords(); + for (const auto& w : words) { + if (!fullText.empty()) fullText += " "; + fullText += w; + } + } + } + } + } + return fullText; +} + +std::optional Section::getCachedPageCount() const { + HalFile f; + if (!Storage.openFileForRead("SCT", filePath, f)) { + return std::nullopt; + } + + const uint32_t fileSize = f.size(); + if (fileSize < HEADER_SIZE) { + return std::nullopt; + } + + f.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t)); + uint16_t count; + serialization::readPod(f, count); + return count; +} + std::optional Section::getPageForAnchor(const std::string& anchor) const { HalFile f; if (!Storage.openFileForRead("SCT", filePath, f)) { diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index e2869d2a..ef216608 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -40,10 +40,14 @@ class Section { uint8_t imageRendering, bool focusReadingEnabled, const std::function& popupFn = nullptr); std::unique_ptr loadPageFromSectionFile(); + std::string getTextFromSectionFile(); // Look up the page number for an anchor id from the section cache file. std::optional getPageForAnchor(const std::string& anchor) const; + // Get the page count from the section cache file without fully loading it. + std::optional getCachedPageCount() const; + // Look up the page number for a synthetic paragraph index from XPath p[N]. std::optional getPageForParagraphIndex(uint16_t pIndex) const; diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 8852b95d..0e3105de 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -182,6 +182,8 @@ STR_DOWNLOADING: "Downloading..." STR_DOWNLOAD_FAILED: "Download failed" STR_ERROR_MSG: "Error:" STR_UNNAMED: "Unnamed" +STR_HOLD_CONFIRM_TO_DELETE: "Hold Confirm to Delete" +STR_BOOKMARK_INSTRUCTIONS: "Hold Confirm from the reader to create a bookmark." STR_NO_SERVER_URL: "No server URL configured" STR_FETCH_FEED_FAILED: "Failed to fetch feed" STR_PARSE_FEED_FAILED: "Failed to parse feed" @@ -259,6 +261,8 @@ STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix" STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons" +STR_BOOKMARKS: "Bookmarks" +STR_BOOKMARK_ADDED: "Bookmark added." STR_OPDS_BROWSER: "OPDS Browser" STR_SEARCH: "Search" STR_COVER_CUSTOM: "Cover + Custom" @@ -288,6 +292,7 @@ STR_GO_HOME_BUTTON: "Go Home" STR_SYNC_PROGRESS: "Sync Progress" STR_DELETE_CACHE: "Delete Book Cache" STR_DELETE: "Delete" +STR_CONFIRM_DELETE_BOOKMARK: "Delete this bookmark?" STR_DISPLAY_QR: "Show page as QR" STR_CHAPTER_PREFIX: "Chapter: " STR_PAGES_SEPARATOR: " pages | " diff --git a/lib/KOReaderSync/ProgressMapper.cpp b/lib/KOReaderSync/ProgressMapper.cpp index b89c90ef..da3f812e 100644 --- a/lib/KOReaderSync/ProgressMapper.cpp +++ b/lib/KOReaderSync/ProgressMapper.cpp @@ -1,5 +1,6 @@ #include "ProgressMapper.h" +#include #include #include @@ -7,6 +8,7 @@ #include #include "ChapterXPathResolver.h" +#include "Epub/Section.h" #include "Epub/htmlEntities.h" #include "Utf8.h" @@ -506,8 +508,9 @@ bool streamSpine(const std::shared_ptr& epub, int spineIndex, ParagraphStr } } // namespace -KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr& epub, const CrossPointPosition& pos) { - KOReaderPosition result; +SavedProgressPosition ProgressMapper::toSavedProgress(const std::shared_ptr& epub, + const CrossPointPosition& pos) { + SavedProgressPosition result; float intra = (pos.totalPages > 1) ? static_cast(pos.pageNumber) / static_cast(pos.totalPages - 1) : 0.0f; result.percentage = epub->calculateProgress(pos.spineIndex, intra); @@ -520,13 +523,14 @@ KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr& epub, c if (result.xpath.empty()) { result.xpath = generateXPath(epub, pos.spineIndex, intra); } - LOG_DBG("PM", "-> KO: spine=%d page=%d/%d %.2f%% %s", pos.spineIndex, pos.pageNumber, pos.totalPages, + LOG_DBG("PM", "-> Progress: spine=%d page=%d/%d %.2f%% %s", pos.spineIndex, pos.pageNumber, pos.totalPages, result.percentage * 100, result.xpath.c_str()); return result; } -CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr& epub, const KOReaderPosition& koPos, - int currentSpineIndex, int totalPagesInCurrentSpine) { +CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr& epub, const SavedProgressPosition& koPos, + GfxRenderer& renderer, int currentSpineIndex, + int totalPagesInCurrentSpine, int fallbackTotalPages) { CrossPointPosition result{}; const size_t bookSize = epub->getBookSize(); if (bookSize == 0) return result; @@ -556,7 +560,6 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr& epu } } } - if (result.spineIndex >= spineCount) return result; const size_t prevCum = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0; const size_t spineSize = epub->getCumulativeSpineItemSize(result.spineIndex) - prevCum; @@ -570,7 +573,17 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr& epu result.totalPages = std::max( 1, static_cast(totalPagesInCurrentSpine * static_cast(spineSize) / static_cast(cs))); } - if (spineSize == 0 || result.totalPages == 0) return result; + + if (result.totalPages <= 0) { + Section tempSection(epub, result.spineIndex, renderer); + if (auto cachedCount = tempSection.getCachedPageCount()) { + result.totalPages = *cachedCount; + } else if (fallbackTotalPages > 0) { + result.totalPages = fallbackTotalPages; + } else { + result.totalPages = 1; // Prevent division by zero and give a fallback + } + } float intra = 0.0f; if (useAncestry) { @@ -613,8 +626,60 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr& epu result.pageNumber = std::max( 0, std::min(static_cast(intra * static_cast(result.totalPages - 1) + 0.5f), result.totalPages - 1)); - LOG_DBG("PM", "<- KO: %.2f%% %s -> spine=%d page=%d/%d", koPos.percentage * 100, koPos.xpath.c_str(), + LOG_DBG("PM", "<- Progress: %.2f%% %s -> spine=%d page=%d/%d", koPos.percentage * 100, koPos.xpath.c_str(), result.spineIndex, result.pageNumber, result.totalPages); + + // Refine page using section cache LUTs: li index, anchor, or paragraph index. + if (result.hasLiIndex || result.xpathAnchorId[0] != '\0' || result.hasParagraphIndex) { + Section tempSection(epub, result.spineIndex, renderer); + bool refined = false; + if (result.hasLiIndex) { + const auto liPage = tempSection.getPageForListItemIndex(result.liIndex); + if (liPage.has_value()) { + LOG_DBG("PM", "Li index %u -> page %d (was %d)", result.liIndex, *liPage, result.pageNumber); + result.pageNumber = *liPage; + refined = true; + } else { + LOG_DBG("PM", "Li index %u not found in section LUT", result.liIndex); + } + } + if (!refined && result.xpathAnchorId[0] != '\0') { + const auto anchorPage = tempSection.getPageForAnchor(std::string(result.xpathAnchorId)); + if (anchorPage.has_value()) { + LOG_DBG("PM", "Anchor '%s' -> page %d (was %d)", result.xpathAnchorId, *anchorPage, result.pageNumber); + result.pageNumber = *anchorPage; + refined = true; + } else { + LOG_DBG("PM", "Anchor '%s' not found in section cache", result.xpathAnchorId); + } + } + if (!refined && result.hasParagraphIndex) { + const auto paragraphPage = tempSection.getPageForParagraphIndex(result.paragraphIndex); + const auto nextParagraphPage = tempSection.getPageForParagraphIndex(result.paragraphIndex + 1); + if (paragraphPage.has_value()) { + int refinedPage = std::max(result.pageNumber, static_cast(*paragraphPage)); + if (nextParagraphPage.has_value()) { + const int lutSpan = static_cast(*nextParagraphPage) - static_cast(*paragraphPage); + // Only cap when the LUT span is >1. A span of 1 means the LUT granularity is too + // coarse to trust over the intra-spine position (e.g. a stale cache where the paragraph + // occupies different pages than at build time). + if (lutSpan > 1 && refinedPage >= static_cast(*nextParagraphPage)) { + refinedPage = static_cast(*nextParagraphPage) - 1; + } + } + char nextParaBuf[8]; + if (nextParagraphPage.has_value()) + snprintf(nextParaBuf, sizeof(nextParaBuf), "%d", *nextParagraphPage); + else + snprintf(nextParaBuf, sizeof(nextParaBuf), "none"); + LOG_DBG("PM", "Paragraph %u -> LUT page %d, nextPara page %s, intra page %d, using %d", result.paragraphIndex, + *paragraphPage, nextParaBuf, result.pageNumber, refinedPage); + result.pageNumber = refinedPage; + } else { + LOG_DBG("PM", "Paragraph %u not found in section LUT", result.paragraphIndex); + } + } + } return result; } diff --git a/lib/KOReaderSync/ProgressMapper.h b/lib/KOReaderSync/ProgressMapper.h index bd6c73c1..d48da2e3 100644 --- a/lib/KOReaderSync/ProgressMapper.h +++ b/lib/KOReaderSync/ProgressMapper.h @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include @@ -19,18 +20,18 @@ struct CrossPointPosition { }; /** - * KOReader position representation. + * Progress position representation. */ -struct KOReaderPosition { +struct SavedProgressPosition { std::string xpath; // XPath-like progress string float percentage; // Progress percentage (0.0 to 1.0) }; /** - * Maps between CrossPoint and KOReader position formats. + * Maps between CrossPoint and SavedProgress position formats, such as those used by KOReader. * * CrossPoint tracks position as (spineIndex, pageNumber). - * KOReader uses XPath-like strings + percentage. + * SavedProgress uses XPath-like strings + percentage. * * Since CrossPoint discards HTML structure during parsing, we generate * synthetic XPath strings based on spine index, using percentage as the @@ -39,28 +40,30 @@ struct KOReaderPosition { class ProgressMapper { public: /** - * Convert CrossPoint position to KOReader format. + * Convert CrossPoint position to SavedProgress format. * * @param epub The EPUB book * @param pos CrossPoint position - * @return KOReader position + * @return SavedProgress position */ - static KOReaderPosition toKOReader(const std::shared_ptr& epub, const CrossPointPosition& pos); + static SavedProgressPosition toSavedProgress(const std::shared_ptr& epub, const CrossPointPosition& pos); /** - * Convert KOReader position to CrossPoint format. + * Convert SavedProgress position to CrossPoint format. * * Note: The returned pageNumber may be approximate since different * rendering settings produce different page counts. * * @param epub The EPUB book - * @param koPos KOReader position + * @param savedPos SavedProgress position + * @param renderer GfxRenderer for page count estimation * @param currentSpineIndex Index of the currently open spine item (for density estimation) * @param totalPagesInCurrentSpine Total pages in the current spine item (for density estimation) * @return CrossPoint position */ - static CrossPointPosition toCrossPoint(const std::shared_ptr& epub, const KOReaderPosition& koPos, - int currentSpineIndex = -1, int totalPagesInCurrentSpine = 0); + static CrossPointPosition toCrossPoint(const std::shared_ptr& epub, const SavedProgressPosition& savedPos, + GfxRenderer& renderer, int currentSpineIndex = -1, + int totalPagesInCurrentSpine = 0, int fallbackTotalPages = 0); private: /** diff --git a/src/BookmarkEntry.h b/src/BookmarkEntry.h new file mode 100644 index 00000000..954c24f3 --- /dev/null +++ b/src/BookmarkEntry.h @@ -0,0 +1,14 @@ +#pragma once +#include +#include + +// A single bookmark entry — a position in a book. +struct BookmarkEntry { + std::string xpath; // XPath-like progress string + std::string summary; // First few words of a page to help identify it + float percentage; // Progress percentage (0.0 to 1.0) + + uint16_t computedSpineIndex = 0; // Spine index at the time of bookmarking + uint16_t computedChapterPageCount = 0; // Total page count of the chapter at the time of bookmarking + uint16_t computedChapterProgress = 0; // Number of pages into the chapter at the time of bookmarking +}; \ No newline at end of file diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 065c6e51..ef79a30c 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -8,6 +8,7 @@ #include #include +#include "BookmarkEntry.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "OpdsServerStore.h" @@ -394,3 +395,44 @@ bool JsonSettingsIO::loadOpds(OpdsServerStore& store, const char* json, bool* ne LOG_DBG("OPS", "Loaded %zu OPDS servers from file", store.servers.size()); return true; } + +// ---- Bookmarks ---- + +bool JsonSettingsIO::saveBookmarks(const std::vector& bookmarks, const char* path) { + JsonDocument doc; + JsonArray arr = doc["bookmarks"].to(); + LOG_DBG("BKM", "Saving %zu bookmarks to file", bookmarks.size()); + for (const auto& bookmark : bookmarks) { + JsonObject obj = arr.add(); + obj["xpath"] = bookmark.xpath; + obj["percentage"] = bookmark.percentage; + obj["summary"] = bookmark.summary; + } + + String json; + serializeJson(doc, json); + return Storage.writeFile(path, json); +} + +bool JsonSettingsIO::loadBookmarks(std::vector& bookmarks, const char* json) { + JsonDocument doc; + auto error = deserializeJson(doc, json); + if (error) { + LOG_ERR("BKM", "JSON parse error: %s", error.c_str()); + return false; + } + + JsonArray arr = doc["bookmarks"].as(); + bookmarks.clear(); + bookmarks.reserve(arr.size()); + for (JsonObject obj : arr) { + bookmarks.emplace_back(); + auto& bookmark = bookmarks.back(); + bookmark.xpath = obj["xpath"] | std::string(""); + bookmark.percentage = obj["percentage"] | static_cast(0); + bookmark.summary = obj["summary"] | std::string(""); + } + + LOG_DBG("BKM", "Loaded %zu bookmarks from file", bookmarks.size()); + return true; +} diff --git a/src/JsonSettingsIO.h b/src/JsonSettingsIO.h index 95d96b35..45888b67 100644 --- a/src/JsonSettingsIO.h +++ b/src/JsonSettingsIO.h @@ -1,10 +1,13 @@ #pragma once +#include + class CrossPointSettings; class CrossPointState; class WifiCredentialStore; class RecentBooksStore; class OpdsServerStore; +struct BookmarkEntry; namespace JsonSettingsIO { @@ -28,4 +31,8 @@ bool loadRecentBooks(RecentBooksStore& store, const char* json); bool saveOpds(const OpdsServerStore& store, const char* path); bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave = nullptr); +// Bookmarks +bool saveBookmarks(const std::vector& bookmarks, const char* path); +bool loadBookmarks(std::vector& bookmarks, const char* json); + } // namespace JsonSettingsIO diff --git a/src/activities/ActivityResult.h b/src/activities/ActivityResult.h index 5d8b354f..84aa91c6 100644 --- a/src/activities/ActivityResult.h +++ b/src/activities/ActivityResult.h @@ -36,7 +36,7 @@ struct PageResult { uint32_t page = 0; }; -struct SyncResult { +struct ProgressChangeResult { int spineIndex = 0; int page = 0; }; @@ -56,7 +56,7 @@ struct FilePathResult { }; using ResultVariant = std::variant; + PageResult, ProgressChangeResult, NetworkModeResult, FootnoteResult, FilePathResult>; struct ActivityResult { bool isCancelled = false; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 2157c6fb..98174ee7 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -7,16 +7,20 @@ #include #include #include +#include #include #include #include +#include #include #include #include +#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(section->currentPage) / static_cast(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(section->currentPage) / static_cast(section->pageCount); + bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f; + } + const int bookProgressPercent = clampPercent(static_cast(bookProgress + 0.5f)); + startActivityForResult(std::make_unique( + 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(result.data); + applyOrientation(menu.orientation); + toggleAutoPageTurn(menu.pageTurnOption); + if (!result.isCancelled) { + onReaderMenuConfirm(static_cast(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(bookProgress + 0.5f)); - startActivityForResult(std::make_unique( - 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(result.data); - applyOrientation(menu.orientation); - toggleAutoPageTurn(menu.pageTurnOption); - if (!result.isCancelled) { - onReaderMenuConfirm(static_cast(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(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(*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(renderer, mappedInput, fullText), - [this](const ActivityResult& result) {}); - break; - } + std::string fullText = section->getTextFromSectionFile(); + if (!fullText.empty()) { + startActivityForResult(std::make_unique(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(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 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 paragraphIndex; + if (section && currentPage >= 0 && currentPage < section->pageCount) { + const uint16_t paragraphPage = + currentPage > 0 ? static_cast(currentPage - 1) : static_cast(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; +} diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 23849469..38aecd26 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -6,6 +6,7 @@ #include #include "EpubReaderMenuActivity.h" +#include "ProgressMapper.h" #include "activities/Activity.h" class EpubReaderActivity final : public Activity { @@ -31,9 +32,12 @@ class EpubReaderActivity final : public Activity { bool pendingSyncSaveError = false; bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit bool automaticPageTurnActive = false; + bool showBookmarkMessage = false; + bool ignoreNextConfirmRelease = false; // Tracks whether this book is currently removed from Recent Books by the // removeReadBooksFromRecents feature (set at End-of-Book, cleared if paged back in). bool recentsEntryRemoved = false; + unsigned long bookmarkMessageTime = 0UL; // Set when the reader is left at end-of-book and SETTINGS.moveFinishedToReadFolder is on. // Consumed in onExit() to relocate the finished book into /Read/. bool pendingReadFolderMove = false; @@ -59,6 +63,7 @@ class EpubReaderActivity final : public Activity { void applyOrientation(uint8_t orientation); void toggleAutoPageTurn(uint8_t selectedPageTurnOption); void pageTurn(bool isForwardTurn); + void addBookmark(); // Footnote navigation void navigateToHref(const std::string& href, bool savePosition = false); @@ -73,4 +78,5 @@ class EpubReaderActivity final : public Activity { void render(RenderLock&& lock) override; bool isReaderActivity() const override { return true; } ScreenshotInfo getScreenshotInfo() const override; + CrossPointPosition getCurrentPosition() const; }; diff --git a/src/activities/reader/EpubReaderBookmarksActivity.cpp b/src/activities/reader/EpubReaderBookmarksActivity.cpp new file mode 100644 index 00000000..617b3c17 --- /dev/null +++ b/src/activities/reader/EpubReaderBookmarksActivity.cpp @@ -0,0 +1,224 @@ +#include "EpubReaderBookmarksActivity.h" + +#include +#include +#include +#include +#include + +#include + +#include "MappedInputManager.h" +#include "ProgressMapper.h" +#include "components/UITheme.h" +#include "fontIds.h" + +namespace { +constexpr int ENTER_DELETE_MODE_MS = 700; +constexpr int DELETE_MODE_OFF = 0; +constexpr int DELETE_MODE_DISPLAY = 1; +constexpr int DELETE_MODE_CONFIRM = 2; + +// Layout constants used in renderScreen +constexpr int LINE_HEIGHT = 60; +} // namespace + +void EpubReaderBookmarksActivity::onEnter() { + Activity::onEnter(); + + if (!epub) { + return; + } + + const std::string path = BookmarkUtil::getBookmarkPath(epubPath); + if (Storage.exists(path.c_str())) { + String json = Storage.readFile(path.c_str()); + if (json.isEmpty()) { + LOG_ERR("EPB", "Failed to load bookmarks from %s. Empty bookmark file", path.c_str()); + bookmarks.clear(); + bookmarks.shrink_to_fit(); + } else { + JsonSettingsIO::loadBookmarks(bookmarks, json.c_str()); + + // pre-compute bookmark page values for quicker rendering + for (auto& bookmark : bookmarks) { + CrossPointPosition pos = ProgressMapper::toCrossPoint(epub, {bookmark.xpath, bookmark.percentage}, renderer); + bookmark.computedSpineIndex = pos.spineIndex; + bookmark.computedChapterPageCount = pos.totalPages; + bookmark.computedChapterProgress = pos.pageNumber; + } + } + } else { + LOG_DBG("EPB", "No bookmark file found at %s, starting with empty bookmarks", path.c_str()); + bookmarks.clear(); + bookmarks.shrink_to_fit(); + } + LOG_DBG("EPB", "Loaded %d bookmarks for book: %s", static_cast(bookmarks.size()), epubPath.c_str()); + + // Trigger first update + requestUpdate(); +} + +void EpubReaderBookmarksActivity::onExit() { Activity::onExit(); } + +int EpubReaderBookmarksActivity::getGutterBottom(const GfxRenderer& renderer) { + const auto orientation = renderer.getOrientation(); + const bool isPortrait = orientation == GfxRenderer::Orientation::Portrait; + return isPortrait ? 75 : 40; // Reserve vertical space for button hints at the bottom +} + +int EpubReaderBookmarksActivity::getListHeight(const GfxRenderer& renderer) { + const auto pageHeight = renderer.getScreenHeight(); + return pageHeight - getGutterBottom(renderer) - LINE_HEIGHT; // Reserve vertical space for title and button hints +} + +void EpubReaderBookmarksActivity::loop() { + // Delete confirmation mode + if (confirmingDelete >= DELETE_MODE_DISPLAY) { + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (confirmingDelete == DELETE_MODE_DISPLAY) { + confirmingDelete = DELETE_MODE_CONFIRM; // first confirmation, update text + requestUpdate(); + return; + } + bookmarks.erase(bookmarks.begin() + selectorIndex); + const std::string path = BookmarkUtil::getBookmarkPath(epubPath); + Storage.mkdir(BookmarkUtil::getBookmarksDir().c_str()); + if (!JsonSettingsIO::saveBookmarks(bookmarks, path.c_str())) { + LOG_ERR("EPB", "Failed to save bookmarks after delete"); + } + + // Move selector up if we deleted the last item + if (selectorIndex >= bookmarks.size() && selectorIndex > 0) { + selectorIndex--; + } + + requestUpdate(); + confirmingDelete = DELETE_MODE_OFF; + return; + } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + requestUpdate(); + confirmingDelete = DELETE_MODE_OFF; + return; + } + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Open + if (bookmarks.empty()) { + return; + } + auto bookmark = bookmarks.at(selectorIndex); + CrossPointPosition pos = ProgressMapper::toCrossPoint(epub, {bookmark.xpath, bookmark.percentage}, renderer); + setResult(ProgressChangeResult{pos.spineIndex, pos.pageNumber}); + finish(); + return; + } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + ActivityResult result; + result.isCancelled = true; + setResult(std::move(result)); + finish(); + return; + } + + if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() > ENTER_DELETE_MODE_MS) { + if (bookmarks.empty()) { + return; + } + confirmingDelete = DELETE_MODE_DISPLAY; + requestUpdate(); + } + + buttonNavigator.onNextRelease([this] { + selectorIndex = ButtonNavigator::nextIndex(selectorIndex, bookmarks.size()); + requestUpdate(); + }); + + buttonNavigator.onPreviousRelease([this] { + selectorIndex = ButtonNavigator::previousIndex(selectorIndex, bookmarks.size()); + requestUpdate(); + }); + + buttonNavigator.onNextContinuous([this] { + selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, bookmarks.size(), + GUI.getListPageItems(getListHeight(renderer), true)); + requestUpdate(); + }); + + buttonNavigator.onPreviousContinuous([this] { + selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, bookmarks.size(), + GUI.getListPageItems(getListHeight(renderer), true)); + requestUpdate(); + }); +} + +void EpubReaderBookmarksActivity::render(RenderLock&&) { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + const auto orientation = renderer.getOrientation(); + // Landscape orientation: reserve a horizontal gutter for button hints. + const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise; + const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise; + // Inverted portrait: reserve vertical space for hints at the top. + const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted; + const bool isPortrait = orientation == GfxRenderer::Orientation::Portrait; + const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 40 : 0; + // Landscape CW places hints on the left edge; CCW keeps them on the right. + const int contentX = isLandscapeCw ? hintGutterWidth : 0; + const int contentWidth = pageWidth - hintGutterWidth; + const int hintGutterHeight = isPortraitInverted ? 50 : 0; + const int hintGutterBottom = getGutterBottom(renderer); + const int contentY = hintGutterHeight; + const int listY = contentY + LINE_HEIGHT; // Reserve vertical space for title + const int listHeight = getListHeight(renderer); + const int numBookmarks = bookmarks.size(); + + // Manual centering to honor content gutters. + const int titleX = + contentX + (contentWidth - renderer.getTextWidth(UI_12_FONT_ID, tr(STR_BOOKMARKS), EpdFontFamily::BOLD)) / 2; + renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, tr(STR_BOOKMARKS), true, EpdFontFamily::BOLD); + + const auto getBookmarkTitle = [this](int index) { + return bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index).summary; + }; + const auto getBookmarkSubtitle = [this](int index) { + auto bookmark = bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index); + auto tocIndex = epub->getTocIndexForSpineIndex(bookmark.computedSpineIndex); + auto tocTitle = (tocIndex >= 0) ? (epub->getTocItem(tocIndex)).title : tr(STR_UNNAMED); + return std::to_string((int)bookmark.percentage) + "% - " + std::to_string(bookmark.computedChapterProgress) + "/" + + std::to_string(bookmark.computedChapterPageCount) + " - " + tocTitle; + }; + const auto getBookmarkIcon = [isPortrait](int index) { + // only enabled icon in portrait mode due to limitation with rotating icons for other orientations + return isPortrait ? UIIcon::Bookmark : UIIcon::None; + }; + + if (numBookmarks > 0) { + if (confirmingDelete >= DELETE_MODE_DISPLAY) { + GUI.drawHelpText(renderer, Rect{0, pageHeight / 2 - LINE_HEIGHT * 2, contentWidth, LINE_HEIGHT}, + tr(STR_CONFIRM_DELETE_BOOKMARK)); + + // render list with just the selected item for the user to confirm to delete + GUI.drawList(renderer, Rect{contentX, pageHeight / 2, contentWidth, LINE_HEIGHT}, 1, 0, getBookmarkTitle, + getBookmarkSubtitle, getBookmarkIcon); + } else { + GUI.drawList(renderer, Rect{contentX, listY, contentWidth, listHeight}, numBookmarks, selectorIndex, + getBookmarkTitle, getBookmarkSubtitle, getBookmarkIcon); + + GUI.drawHelpText(renderer, Rect{contentX, pageHeight - hintGutterBottom, contentWidth, LINE_HEIGHT}, + tr(STR_HOLD_CONFIRM_TO_DELETE)); + } + } else { + GUI.drawHelpText(renderer, Rect{contentX, LINE_HEIGHT * 2, contentWidth, LINE_HEIGHT}, + tr(STR_BOOKMARK_INSTRUCTIONS)); + } + + const auto backLabel = confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_CANCEL) : tr(STR_BACK); + const auto confirmLabel = + bookmarks.size() > 0 ? (confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_DELETE) : tr(STR_OPEN)) : ""; + const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/reader/EpubReaderBookmarksActivity.h b/src/activities/reader/EpubReaderBookmarksActivity.h new file mode 100644 index 00000000..56e57711 --- /dev/null +++ b/src/activities/reader/EpubReaderBookmarksActivity.h @@ -0,0 +1,33 @@ +#pragma once +#include + +#include + +#include "../../BookmarkEntry.h" +#include "../Activity.h" +#include "util/ButtonNavigator.h" + +class EpubReaderBookmarksActivity final : public Activity { + std::shared_ptr epub; + std::string epubPath; + ButtonNavigator buttonNavigator; + int selectorIndex = 0; + std::vector bookmarks; + int confirmingDelete = 0; // 0 = hide dialog, 1 = show dialog, 2 = allow confirmation to delete + + public: + explicit EpubReaderBookmarksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::shared_ptr& epub, const std::string& epubPath) + : Activity("EpubReaderBookmarks", renderer, mappedInput), epub(epub), epubPath(epubPath) {} + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + + private: + // Calculate the vertical space to reserve for button hints based on orientation + int getGutterBottom(const GfxRenderer& renderer); + + // Calculate the height available for the bookmark list based on orientation + int getListHeight(const GfxRenderer& renderer); +}; diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 23b3fafe..634db50c 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -21,11 +21,12 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInpu std::vector EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes) { std::vector items; - items.reserve(10); + items.reserve(11); items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER}); if (hasFootnotes) { items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES}); } + items.push_back({MenuAction::BOOKMARKS, StrId::STR_BOOKMARKS}); items.push_back({MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION}); items.push_back({MenuAction::AUTO_PAGE_TURN, StrId::STR_AUTO_TURN_PAGES_PER_MIN}); items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT}); diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index 2d967063..43272f48 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -17,6 +17,7 @@ class EpubReaderMenuActivity final : public Activity { GO_TO_PERCENT, AUTO_PAGE_TURN, ROTATE_SCREEN, + BOOKMARKS, SCREENSHOT, DISPLAY_QR, GO_HOME, diff --git a/src/activities/reader/KOReaderSyncActivity.cpp b/src/activities/reader/KOReaderSyncActivity.cpp index 834c2e6f..d4f7fef6 100644 --- a/src/activities/reader/KOReaderSyncActivity.cpp +++ b/src/activities/reader/KOReaderSyncActivity.cpp @@ -174,64 +174,10 @@ void KOReaderSyncActivity::performSync() { return; } - KOReaderPosition koPos = {remoteProgress.progress, remoteProgress.percentage}; - remotePosition = ProgressMapper::toCrossPoint(epub, koPos, currentSpineIndex, totalPagesInSpine); + SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage}; + remotePosition = ProgressMapper::toCrossPoint(epub, koPos, renderer, currentSpineIndex, totalPagesInSpine); - // Refine page using section cache LUTs: li index, anchor, or paragraph index. - if (remotePosition.hasLiIndex || remotePosition.xpathAnchorId[0] != '\0' || remotePosition.hasParagraphIndex) { - Section tempSection(epub, remotePosition.spineIndex, renderer); - bool refined = false; - if (remotePosition.hasLiIndex) { - const auto liPage = tempSection.getPageForListItemIndex(remotePosition.liIndex); - if (liPage.has_value()) { - LOG_DBG("KOSync", "Li index %u -> page %d (was %d)", remotePosition.liIndex, *liPage, - remotePosition.pageNumber); - remotePosition.pageNumber = *liPage; - refined = true; - } else { - LOG_DBG("KOSync", "Li index %u not found in section LUT", remotePosition.liIndex); - } - } - if (!refined && remotePosition.xpathAnchorId[0] != '\0') { - const auto anchorPage = tempSection.getPageForAnchor(std::string(remotePosition.xpathAnchorId)); - if (anchorPage.has_value()) { - LOG_DBG("KOSync", "Anchor '%s' -> page %d (was %d)", remotePosition.xpathAnchorId, *anchorPage, - remotePosition.pageNumber); - remotePosition.pageNumber = *anchorPage; - refined = true; - } else { - LOG_DBG("KOSync", "Anchor '%s' not found in section cache", remotePosition.xpathAnchorId); - } - } - if (!refined && remotePosition.hasParagraphIndex) { - const auto paragraphPage = tempSection.getPageForParagraphIndex(remotePosition.paragraphIndex); - const auto nextParagraphPage = tempSection.getPageForParagraphIndex(remotePosition.paragraphIndex + 1); - if (paragraphPage.has_value()) { - int refinedPage = std::max(remotePosition.pageNumber, static_cast(*paragraphPage)); - if (nextParagraphPage.has_value()) { - const int lutSpan = static_cast(*nextParagraphPage) - static_cast(*paragraphPage); - // Only cap when the LUT span is >1. A span of 1 means the LUT granularity is too - // coarse to trust over the intra-spine position (e.g. a stale cache where the paragraph - // occupies different pages than at build time). - if (lutSpan > 1 && refinedPage >= static_cast(*nextParagraphPage)) { - refinedPage = static_cast(*nextParagraphPage) - 1; - } - } - char nextParaBuf[8]; - if (nextParagraphPage.has_value()) - snprintf(nextParaBuf, sizeof(nextParaBuf), "%d", *nextParagraphPage); - else - snprintf(nextParaBuf, sizeof(nextParaBuf), "none"); - LOG_DBG("KOSync", "Paragraph %u -> LUT page %d, nextPara page %s, intra page %d, using %d", - remotePosition.paragraphIndex, *paragraphPage, nextParaBuf, remotePosition.pageNumber, refinedPage); - remotePosition.pageNumber = refinedPage; - } else { - LOG_DBG("KOSync", "Paragraph %u not found in section LUT", remotePosition.paragraphIndex); - } - } - } // localProgress was pre-computed in EpubReaderActivity before the Epub was released. - { RenderLock lock(*this); state = SHOWING_RESULT; diff --git a/src/activities/reader/KOReaderSyncActivity.h b/src/activities/reader/KOReaderSyncActivity.h index 7cc824ba..b3b82d01 100644 --- a/src/activities/reader/KOReaderSyncActivity.h +++ b/src/activities/reader/KOReaderSyncActivity.h @@ -23,7 +23,7 @@ class KOReaderSyncActivity final : public Activity { public: explicit KOReaderSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& epubPath, int currentSpineIndex, int currentPage, int totalPagesInSpine, - KOReaderPosition localKoPos, std::string localChapterName, + SavedProgressPosition localKoPos, std::string localChapterName, std::optional currentParagraphIndex = std::nullopt) : Activity("KOReaderSync", renderer, mappedInput), epubPath(epubPath), @@ -73,7 +73,7 @@ class KOReaderSyncActivity final : public Activity { CrossPointPosition remotePosition; // Local progress as KOReader format (pre-computed before Epub was released) - KOReaderPosition localProgress; + SavedProgressPosition localProgress; // Selection in result screen (0=Apply, 1=Upload) int selectedOption = 0; diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index f2845328..9d0a4aba 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -11,6 +11,8 @@ namespace ReaderUtils { constexpr unsigned long GO_HOME_MS = 1000; constexpr unsigned long SKIP_HOLD_MS = 700; +constexpr unsigned long BOOKMARK_HOLD_MS = 400; +constexpr unsigned long BOOKMARK_MESSAGE_DURATION_MS = 2500; inline void applyOrientation(GfxRenderer& renderer, const uint8_t orientation) { switch (orientation) { diff --git a/src/components/icons/bookmark.h b/src/components/icons/bookmark.h new file mode 100644 index 00000000..9f99e03c --- /dev/null +++ b/src/components/icons/bookmark.h @@ -0,0 +1,12 @@ +#pragma once +#include + +// size: 32x32 +static const uint8_t BookmarkIcon[] = { + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x00, + 0x03, 0x80, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x0F, 0x00, + 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00, 0x0F, + 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x01, 0xC0, 0x00, 0x00, 0x03, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 1c0c232e..44b8145e 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -228,6 +228,11 @@ void BaseTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* top } } +int BaseTheme::getListPageItems(int contentHeight, bool hasSubtitle) const { + int rowHeight = (hasSubtitle) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight; + return contentHeight / rowHeight; +} + void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, const std::function& rowTitle, const std::function& rowSubtitle, diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index 45c2b287..e140bc26 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -97,7 +97,7 @@ struct ThemeMetrics { int textFieldLineEndOffset; }; -enum UIIcon { Folder, Text, Image, Book, File, Recent, Settings, Transfer, Library, Wifi, Hotspot }; +enum UIIcon { None = 0, Folder, Text, Image, Book, File, Recent, Settings, Transfer, Library, Wifi, Hotspot, Bookmark }; enum class KeyboardKeyType { Normal, Shift, Mode, Space, Del, Ok, Disabled }; @@ -182,6 +182,7 @@ class BaseTheme { virtual void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const; virtual void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const; + virtual int getListPageItems(int contentHeight, bool hasSubtitle) const; virtual void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, const std::function& rowTitle, const std::function& rowSubtitle = nullptr, diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index 921e99c1..f04f7de0 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -14,6 +14,7 @@ #include "components/UITheme.h" #include "components/icons/book.h" #include "components/icons/book24.h" +#include "components/icons/bookmark.h" #include "components/icons/cover.h" #include "components/icons/file24.h" #include "components/icons/folder.h" @@ -73,6 +74,8 @@ const uint8_t* iconForName(UIIcon icon, int size) { return WifiIcon; case UIIcon::Hotspot: return HotspotIcon; + case UIIcon::Bookmark: + return BookmarkIcon; default: return nullptr; } @@ -202,6 +205,11 @@ void LyraTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::ve renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width - 1, rect.y + rect.height - 1, true); } +int LyraTheme::getListPageItems(int contentHeight, bool hasSubtitle) const { + int rowHeight = (hasSubtitle) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight; + return contentHeight / rowHeight; +} + void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, const std::function& rowTitle, const std::function& rowSubtitle, diff --git a/src/components/themes/lyra/LyraTheme.h b/src/components/themes/lyra/LyraTheme.h index 00a72afc..fbed8f42 100644 --- a/src/components/themes/lyra/LyraTheme.h +++ b/src/components/themes/lyra/LyraTheme.h @@ -78,6 +78,7 @@ class LyraTheme : public BaseTheme { const char* rightLabel = nullptr) const override; void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, bool selected) const override; + int getListPageItems(int contentHeight, bool hasSubtitle) const override; void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, const std::function& rowTitle, const std::function& rowSubtitle, diff --git a/src/util/BookmarkUtil.cpp b/src/util/BookmarkUtil.cpp new file mode 100644 index 00000000..f1a819ff --- /dev/null +++ b/src/util/BookmarkUtil.cpp @@ -0,0 +1,35 @@ +#include "BookmarkUtil.h" + +#include +#include + +std::string BookmarkUtil::getBookmarksDir() { return "/.crosspoint/bookmarks/"; } + +std::string BookmarkUtil::getBookmarkPath(const std::string& bookPath) { + // remove leading slash and replace internal slashes to create a flat filename + std::string bookName = std::string(bookPath).erase(0, 1); + std::replace(bookName.begin(), bookName.end(), '/', '_'); + std::replace(bookName.begin(), bookName.end(), '\\', '_'); + const size_t lastDot = bookName.find_last_of('.'); + if (lastDot != std::string::npos) { + bookName.erase(lastDot); + } + bookName += ".json"; + return getBookmarksDir() + bookName; +} + +std::string BookmarkUtil::sanitizeBookmarkSummary(std::string summary) { + summary.erase( + std::unique(summary.begin(), summary.end(), [](char a, char b) { return std::isspace(a) && std::isspace(b); }), + summary.end()); + summary.erase(std::remove(summary.begin(), summary.end(), '\n'), summary.end()); + summary.erase(summary.begin(), + std::find_if(summary.begin(), summary.end(), [](unsigned char ch) { return !std::isspace(ch); })); + summary.erase( + std::find_if(summary.rbegin(), summary.rend(), [](unsigned char ch) { return !std::isspace(ch); }).base(), + summary.end()); + if (summary.size() > 72) { + summary.resize(72); + } + return summary; +} diff --git a/src/util/BookmarkUtil.h b/src/util/BookmarkUtil.h new file mode 100644 index 00000000..bc7fc1e9 --- /dev/null +++ b/src/util/BookmarkUtil.h @@ -0,0 +1,9 @@ +#pragma once +#include + +class BookmarkUtil { + public: + static std::string getBookmarksDir(); + static std::string getBookmarkPath(const std::string& bookPath); + static std::string sanitizeBookmarkSummary(std::string summary); +};