From 2f4cea3706dfb1bdccdc2f59a45fc2ed7a7b2306 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 15 Apr 2026 09:32:52 +0200 Subject: [PATCH] Add Global Bookmark feature --- lib/I18n/translations/english.yaml | 2 + src/Bookmark.h | 10 + src/BookmarkStore.h | 8 +- src/CrossPointState.cpp | 1 + src/CrossPointState.h | 15 + src/GlobalBookmarkIndex.cpp | 193 ++++++++++++ src/GlobalBookmarkIndex.h | 66 ++++ src/JsonSettingsIO.cpp | 13 + src/activities/ActivityManager.cpp | 5 + src/activities/ActivityManager.h | 1 + .../home/GlobalBookmarksActivity.cpp | 290 ++++++++++++++++++ src/activities/home/GlobalBookmarksActivity.h | 57 ++++ src/activities/home/HomeActivity.cpp | 23 +- src/activities/home/HomeActivity.h | 1 + src/activities/reader/EpubReaderActivity.cpp | 26 ++ src/activities/reader/EpubReaderActivity.h | 4 + src/activities/reader/TxtReaderActivity.cpp | 25 ++ src/activities/reader/TxtReaderActivity.h | 3 + src/main.cpp | 2 + 19 files changed, 737 insertions(+), 8 deletions(-) create mode 100644 src/Bookmark.h create mode 100644 src/GlobalBookmarkIndex.cpp create mode 100644 src/GlobalBookmarkIndex.h create mode 100644 src/activities/home/GlobalBookmarksActivity.cpp create mode 100644 src/activities/home/GlobalBookmarksActivity.h diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 35809988..0c3c7005 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -299,6 +299,8 @@ STR_PAGE_OVERLAY: "Page overlay" STR_RECENTS: "Recents" STR_MENU_RECENT_BOOKS: "Recent Books" STR_NO_RECENT_BOOKS: "No recent books" +STR_GLOBAL_BOOKMARKS: "Bookmarks" +STR_NO_GLOBAL_BOOKMARKS: "No bookmarks yet" STR_CALIBRE_DESC: "Use Calibre wireless device transfers" STR_FORGET_AND_REMOVE: "Forget network and remove saved password?" STR_FORGET_BUTTON: "Forget" diff --git a/src/Bookmark.h b/src/Bookmark.h new file mode 100644 index 00000000..83b7e5f6 --- /dev/null +++ b/src/Bookmark.h @@ -0,0 +1,10 @@ +#pragma once + +#include +#include + +struct Bookmark { + uint16_t spineIndex; + uint16_t pageNumber; + std::string name; // optional user-provided label (empty = use default) +}; diff --git a/src/BookmarkStore.h b/src/BookmarkStore.h index e1966b23..f5c245e0 100644 --- a/src/BookmarkStore.h +++ b/src/BookmarkStore.h @@ -8,15 +8,13 @@ #include #include +#include "Bookmark.h" + // Stores starred/bookmarked pages for a single book. // Persisted as a binary file on SD card within the book's cache directory. class BookmarkStore { public: - struct Bookmark { - uint16_t spineIndex; - uint16_t pageNumber; - std::string name; // optional user-provided label (empty = use default) - }; + using Bookmark = ::Bookmark; // Load bookmarks from the cache directory (e.g. .crosspoint/epub_/). void load(const std::string& cachePath) { diff --git a/src/CrossPointState.cpp b/src/CrossPointState.cpp index 35bcd827..f93174ca 100644 --- a/src/CrossPointState.cpp +++ b/src/CrossPointState.cpp @@ -77,6 +77,7 @@ bool CrossPointState::loadFromBinaryFile() { } koReaderSyncSession.clear(); + pendingBookmarkJump.clear(); inputFile.close(); return true; diff --git a/src/CrossPointState.h b/src/CrossPointState.h index bcf74e8a..f1109cdb 100644 --- a/src/CrossPointState.h +++ b/src/CrossPointState.h @@ -18,6 +18,20 @@ enum class KOReaderSyncOutcomeState : uint8_t { APPLIED_REMOTE = 5, }; +struct PendingBookmarkJumpState { + bool active = false; + std::string bookPath; // source file path for disambiguation + uint16_t spineIndex = 0; // EPUB spine; ignored for TXT + uint16_t pageNumber = 0; // page within spine (EPUB) or global page (TXT) + + void clear() { + active = false; + bookPath.clear(); + spineIndex = 0; + pageNumber = 0; + } +}; + struct KOReaderSyncSessionState { bool active = false; std::string epubPath; @@ -60,6 +74,7 @@ class CrossPointState { uint8_t readerActivityLoadCount = 0; bool lastSleepFromReader = false; KOReaderSyncSessionState koReaderSyncSession; + PendingBookmarkJumpState pendingBookmarkJump; ~CrossPointState() = default; // Get singleton instance diff --git a/src/GlobalBookmarkIndex.cpp b/src/GlobalBookmarkIndex.cpp new file mode 100644 index 00000000..27483b2b --- /dev/null +++ b/src/GlobalBookmarkIndex.cpp @@ -0,0 +1,193 @@ +#include "GlobalBookmarkIndex.h" + +#include +#include + +#include + +GlobalBookmarkIndex GlobalBookmarkIndex::instance; + +namespace { +void writeString(FsFile& f, const std::string& s) { + const uint16_t len = static_cast(std::min(s.size(), UINT16_MAX)); + f.write(reinterpret_cast(&len), sizeof(len)); + if (len > 0) { + f.write(reinterpret_cast(s.data()), len); + } +} + +bool readString(FsFile& f, std::string& out) { + uint16_t len = 0; + if (f.read(reinterpret_cast(&len), sizeof(len)) != sizeof(len)) return false; + out.clear(); + if (len == 0) return true; + out.resize(len); + return f.read(reinterpret_cast(&out[0]), len) == len; +} +} // namespace + +std::vector::iterator GlobalBookmarkIndex::findBySourcePath(const std::string& sourcePath) { + return std::find_if(entries.begin(), entries.end(), + [&sourcePath](const Entry& e) { return e.sourcePath == sourcePath; }); +} + +void GlobalBookmarkIndex::load() { + entries.clear(); + loaded = true; + + FsFile f; + if (!Storage.openFileForRead("GBI", FILE_PATH, f)) { + LOG_DBG("GBI", "No existing global bookmarks file"); + return; + } + + uint8_t version = 0; + if (f.read(&version, 1) != 1 || version != FILE_VERSION) { + LOG_ERR("GBI", "Bad version: %u", version); + f.close(); + return; + } + + uint16_t entryCount = 0; + if (f.read(reinterpret_cast(&entryCount), sizeof(entryCount)) != sizeof(entryCount)) { + f.close(); + return; + } + + entries.reserve(entryCount); + for (uint16_t i = 0; i < entryCount; i++) { + Entry e; + uint8_t isTxtByte = 0; + if (!readString(f, e.sourcePath) || !readString(f, e.cacheDir) || !readString(f, e.title) || + f.read(&isTxtByte, 1) != 1) { + LOG_ERR("GBI", "Truncated entry %u", i); + entries.clear(); + f.close(); + return; + } + e.isTxt = (isTxtByte != 0); + + uint16_t bmCount = 0; + if (f.read(reinterpret_cast(&bmCount), sizeof(bmCount)) != sizeof(bmCount)) { + entries.clear(); + f.close(); + return; + } + e.bookmarks.reserve(bmCount); + for (uint16_t j = 0; j < bmCount; j++) { + BookmarkStore::Bookmark bm; + if (f.read(reinterpret_cast(&bm.spineIndex), sizeof(bm.spineIndex)) != sizeof(bm.spineIndex) || + f.read(reinterpret_cast(&bm.pageNumber), sizeof(bm.pageNumber)) != sizeof(bm.pageNumber) || + !readString(f, bm.name)) { + LOG_ERR("GBI", "Truncated bookmark"); + entries.clear(); + f.close(); + return; + } + e.bookmarks.push_back(std::move(bm)); + } + entries.push_back(std::move(e)); + } + f.close(); + LOG_DBG("GBI", "Loaded %u entries", static_cast(entries.size())); +} + +void GlobalBookmarkIndex::save() const { + Storage.mkdir("/.crosspoint"); + FsFile f; + if (!Storage.openFileForWrite("GBI", FILE_PATH, f)) { + LOG_ERR("GBI", "Failed to open for write"); + return; + } + + const uint8_t version = FILE_VERSION; + f.write(&version, 1); + const uint16_t entryCount = static_cast(std::min(entries.size(), UINT16_MAX)); + f.write(reinterpret_cast(&entryCount), sizeof(entryCount)); + + for (uint16_t i = 0; i < entryCount; i++) { + const Entry& e = entries[i]; + writeString(f, e.sourcePath); + writeString(f, e.cacheDir); + writeString(f, e.title); + const uint8_t isTxtByte = e.isTxt ? 1 : 0; + f.write(&isTxtByte, 1); + const uint16_t bmCount = static_cast(std::min(e.bookmarks.size(), UINT16_MAX)); + f.write(reinterpret_cast(&bmCount), sizeof(bmCount)); + for (const auto& bm : e.bookmarks) { + f.write(reinterpret_cast(&bm.spineIndex), sizeof(bm.spineIndex)); + f.write(reinterpret_cast(&bm.pageNumber), sizeof(bm.pageNumber)); + writeString(f, bm.name); + } + } + f.close(); + LOG_DBG("GBI", "Saved %u entries", static_cast(entryCount)); +} + +void GlobalBookmarkIndex::upsertFromStore(const std::string& sourcePath, const std::string& cacheDir, + const std::string& title, bool isTxt, + const std::vector& bookmarks) { + if (!loaded) load(); + + auto it = findBySourcePath(sourcePath); + if (bookmarks.empty()) { + if (it != entries.end()) { + entries.erase(it); + save(); + } + return; + } + + if (it == entries.end()) { + Entry e; + e.sourcePath = sourcePath; + e.cacheDir = cacheDir; + e.title = title; + e.isTxt = isTxt; + e.bookmarks = bookmarks; + entries.push_back(std::move(e)); + } else { + it->cacheDir = cacheDir; + it->title = title; + it->isTxt = isTxt; + it->bookmarks = bookmarks; + } + save(); +} + +void GlobalBookmarkIndex::syncFromStore(const BookmarkStore& store, const std::string& sourcePath, + const std::string& cacheDir, const std::string& title, bool isTxt) { + upsertFromStore(sourcePath, cacheDir, title, isTxt, store.getAll()); +} + +void GlobalBookmarkIndex::removeBySourcePath(const std::string& sourcePath) { + if (!loaded) load(); + auto it = findBySourcePath(sourcePath); + if (it != entries.end()) { + entries.erase(it); + save(); + } +} + +bool GlobalBookmarkIndex::reconcile() { + if (!loaded) load(); + const size_t before = entries.size(); + entries.erase(std::remove_if(entries.begin(), entries.end(), + [](const Entry& e) { + if (!Storage.exists(e.sourcePath.c_str())) { + LOG_DBG("GBI", "Dropping orphan entry: %s", e.sourcePath.c_str()); + return true; + } + return false; + }), + entries.end()); + const bool changed = entries.size() != before; + if (changed) save(); + return changed; +} + +size_t GlobalBookmarkIndex::totalBookmarkCount() const { + size_t total = 0; + for (const auto& e : entries) total += e.bookmarks.size(); + return total; +} diff --git a/src/GlobalBookmarkIndex.h b/src/GlobalBookmarkIndex.h new file mode 100644 index 00000000..5723c38b --- /dev/null +++ b/src/GlobalBookmarkIndex.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include + +#include "BookmarkStore.h" + +// Aggregates bookmarks from every indexed book into a single queryable catalog. +// Persisted as /.crosspoint/global_bookmarks.bin; updated incrementally when +// per-book BookmarkStore instances save, and reconciled against the filesystem +// when GlobalBookmarksActivity opens. +class GlobalBookmarkIndex { + public: + struct Entry { + std::string sourcePath; // e.g. /books/foo.epub + std::string cacheDir; // e.g. /.crosspoint/epub_12345 + std::string title; // display title + bool isTxt = false; // hint for jump dispatch + std::vector bookmarks; + }; + + // Singleton access. + static GlobalBookmarkIndex& getInstance() { return instance; } + + // Persist/load the index to/from /.crosspoint/global_bookmarks.bin. + void load(); + void save() const; + + // Per-book update. Replaces (or removes when empty) the entry for this source path. + // Safe to call during BookmarkStore::save() hook. + void upsertFromStore(const std::string& sourcePath, const std::string& cacheDir, const std::string& title, bool isTxt, + const std::vector& bookmarks); + + // Drop an entry (e.g. when a book is deleted). No-op if not indexed. + void removeBySourcePath(const std::string& sourcePath); + + // Convenience wrapper that pulls bookmarks out of a BookmarkStore and upserts. + // Also removes the entry if the store is empty. + void syncFromStore(const BookmarkStore& store, const std::string& sourcePath, const std::string& cacheDir, + const std::string& title, bool isTxt); + + // Walk every entry; stat sourcePath + cacheDir. Drop entries whose source file + // is missing. Called on GlobalBookmarksActivity entry. + // Returns true if anything changed (callers can decide whether to persist). + bool reconcile(); + + [[nodiscard]] const std::vector& getEntries() const { return entries; } + [[nodiscard]] bool isEmpty() const { return entries.empty(); } + + // Total bookmark count across all entries. + [[nodiscard]] size_t totalBookmarkCount() const; + + private: + static GlobalBookmarkIndex instance; + + std::vector entries; + bool loaded = false; + + static constexpr uint8_t FILE_VERSION = 1; + static constexpr const char* FILE_PATH = "/.crosspoint/global_bookmarks.bin"; + + std::vector::iterator findBySourcePath(const std::string& sourcePath); +}; + +#define GLOBAL_BOOKMARKS GlobalBookmarkIndex::getInstance() diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index ef1e8d49..3ed29dfd 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -73,6 +73,7 @@ bool JsonSettingsIO::saveState(const CrossPointState& s, const char* path) { doc["lastSleepImage"] = s.lastSleepImage; doc["readerActivityLoadCount"] = s.readerActivityLoadCount; doc["lastSleepFromReader"] = s.lastSleepFromReader; + // Information about a pending KOReader sync session JsonObject sync = doc["koReaderSyncSession"].to(); sync["active"] = s.koReaderSyncSession.active; sync["epubPath"] = s.koReaderSyncSession.epubPath; @@ -87,6 +88,12 @@ bool JsonSettingsIO::saveState(const CrossPointState& s, const char* path) { sync["resultPage"] = s.koReaderSyncSession.resultPage; sync["resultParagraphIndex"] = s.koReaderSyncSession.resultParagraphIndex; sync["resultHasParagraphIndex"] = s.koReaderSyncSession.resultHasParagraphIndex; + // Information about a pending bookmark jump + JsonObject jump = doc["pendingBookmarkJump"].to(); + jump["active"] = s.pendingBookmarkJump.active; + jump["bookPath"] = s.pendingBookmarkJump.bookPath; + jump["spineIndex"] = s.pendingBookmarkJump.spineIndex; + jump["pageNumber"] = s.pendingBookmarkJump.pageNumber; String json; serializeJson(doc, json); @@ -121,6 +128,12 @@ bool JsonSettingsIO::loadState(CrossPointState& s, const char* json) { s.koReaderSyncSession.resultPage = sync["resultPage"] | 0; s.koReaderSyncSession.resultParagraphIndex = sync["resultParagraphIndex"] | (uint16_t)0; s.koReaderSyncSession.resultHasParagraphIndex = sync["resultHasParagraphIndex"] | false; + + JsonObject jump = doc["pendingBookmarkJump"].as(); + s.pendingBookmarkJump.active = jump["active"] | false; + s.pendingBookmarkJump.bookPath = jump["bookPath"] | std::string(""); + s.pendingBookmarkJump.spineIndex = jump["spineIndex"] | (uint16_t)0; + s.pendingBookmarkJump.pageNumber = jump["pageNumber"] | (uint16_t)0; return true; } diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 47b2d76f..08dc0386 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -9,6 +9,7 @@ #include "boot_sleep/SleepActivity.h" #include "browser/OpdsBookBrowserActivity.h" #include "home/FileBrowserActivity.h" +#include "home/GlobalBookmarksActivity.h" #include "home/HomeActivity.h" #include "home/RecentBooksActivity.h" #include "network/CrossPointWebServerActivity.h" @@ -221,6 +222,10 @@ void ActivityManager::goToRecentBooks() { replaceActivity(std::make_unique(renderer, mappedInput)); } +void ActivityManager::goToGlobalBookmarks() { + replaceActivity(std::make_unique(renderer, mappedInput)); +} + void ActivityManager::goToBrowser() { replaceActivity(std::make_unique(renderer, mappedInput)); } diff --git a/src/activities/ActivityManager.h b/src/activities/ActivityManager.h index a907b8a9..b09f968c 100644 --- a/src/activities/ActivityManager.h +++ b/src/activities/ActivityManager.h @@ -87,6 +87,7 @@ class ActivityManager { void goToSettings(); void goToFileBrowser(std::string path = {}); void goToRecentBooks(); + void goToGlobalBookmarks(); void goToBrowser(); void goToReader(std::string path); void goToKOReaderSync(); diff --git a/src/activities/home/GlobalBookmarksActivity.cpp b/src/activities/home/GlobalBookmarksActivity.cpp new file mode 100644 index 00000000..bf394d29 --- /dev/null +++ b/src/activities/home/GlobalBookmarksActivity.cpp @@ -0,0 +1,290 @@ +#include "GlobalBookmarksActivity.h" + +#include +#include +#include +#include +#include + +#include +#include + +#include "BookmarkStore.h" +#include "CrossPointState.h" +#include "GlobalBookmarkIndex.h" +#include "MappedInputManager.h" +#include "activities/util/KeyboardEntryActivity.h" +#include "components/UITheme.h" +#include "fontIds.h" + +void GlobalBookmarksActivity::onEnter() { + Activity::onEnter(); + + if (GLOBAL_BOOKMARKS.reconcile()) { + GLOBAL_BOOKMARKS.save(); + } + rebuildRows(); + + const int first = firstSelectableIndex(); + selectorIndex = first >= 0 ? first : 0; + + const auto total = static_cast(rows.size()); + buttonNavigator.setSelectablePredicate([this](int index) { return !isSeparatorRow(index); }, total); + + requestUpdate(); +} + +void GlobalBookmarksActivity::onExit() { + Activity::onExit(); + rows.clear(); + buttonNavigator.clearSelectablePredicate(); +} + +void GlobalBookmarksActivity::rebuildRows() { + rows.clear(); + const auto& entries = GLOBAL_BOOKMARKS.getEntries(); + for (size_t bi = 0; bi < entries.size(); bi++) { + const auto& entry = entries[bi]; + if (entry.bookmarks.empty()) continue; + Row sep; + sep.isSeparator = true; + sep.bookIndex = bi; + rows.push_back(sep); + for (size_t mi = 0; mi < entry.bookmarks.size(); mi++) { + Row row; + row.isSeparator = false; + row.bookIndex = bi; + row.bookmarkIndex = mi; + rows.push_back(row); + } + } +} + +bool GlobalBookmarksActivity::isSeparatorRow(int index) const { + return index >= 0 && index < static_cast(rows.size()) && rows[index].isSeparator; +} + +int GlobalBookmarksActivity::firstSelectableIndex() const { + for (size_t i = 0; i < rows.size(); i++) { + if (!rows[i].isSeparator) return static_cast(i); + } + return -1; +} + +std::string GlobalBookmarksActivity::getRowTitle(int index) const { + if (index < 0 || index >= static_cast(rows.size())) return {}; + const auto& row = rows[index]; + const auto& entries = GLOBAL_BOOKMARKS.getEntries(); + if (row.bookIndex >= entries.size()) return {}; + const auto& entry = entries[row.bookIndex]; + + if (row.isSeparator) { + return UITheme::makeSeparatorTitle(entry.title.empty() ? entry.sourcePath : entry.title); + } + + if (row.bookmarkIndex >= entry.bookmarks.size()) return {}; + const auto& bm = entry.bookmarks[row.bookmarkIndex]; + if (!bm.name.empty()) return bm.name; + + char buf[64]; + if (entry.isTxt) { + snprintf(buf, sizeof(buf), "%s%d", tr(STR_PAGE_PREFIX), bm.pageNumber + 1); + } else { + snprintf(buf, sizeof(buf), "%s%d, %s%d", tr(STR_SECTION_PREFIX), bm.spineIndex + 1, tr(STR_PAGE_PREFIX), + bm.pageNumber + 1); + } + return std::string(buf); +} + +void GlobalBookmarksActivity::openSelected() { + if (isSeparatorRow(selectorIndex)) return; + const auto& row = rows[selectorIndex]; + const auto& entries = GLOBAL_BOOKMARKS.getEntries(); + if (row.bookIndex >= entries.size()) return; + const auto& entry = entries[row.bookIndex]; + if (row.bookmarkIndex >= entry.bookmarks.size()) return; + const auto& bm = entry.bookmarks[row.bookmarkIndex]; + + if (!Storage.exists(entry.sourcePath.c_str())) { + LOG_ERR("GBA", "Source file missing, reconciling: %s", entry.sourcePath.c_str()); + GLOBAL_BOOKMARKS.removeBySourcePath(entry.sourcePath); + GLOBAL_BOOKMARKS.save(); + rebuildRows(); + const int first = firstSelectableIndex(); + selectorIndex = first >= 0 ? first : 0; + buttonNavigator.setSelectablePredicate([this](int index) { return !isSeparatorRow(index); }, + static_cast(rows.size())); + requestUpdate(); + return; + } + + auto& jump = APP_STATE.pendingBookmarkJump; + jump.active = true; + jump.bookPath = entry.sourcePath; + jump.spineIndex = bm.spineIndex; + jump.pageNumber = bm.pageNumber; + APP_STATE.saveToFile(); + + LOG_DBG("GBA", "Jumping to bookmark in %s at %u/%u", entry.sourcePath.c_str(), bm.spineIndex, bm.pageNumber); + onSelectBook(entry.sourcePath); +} + +template +void GlobalBookmarksActivity::mutateBook(size_t bookIndex, Op&& op) { + const auto& entries = GLOBAL_BOOKMARKS.getEntries(); + if (bookIndex >= entries.size()) return; + const auto entry = entries[bookIndex]; // copy — index may invalidate after sync + + BookmarkStore store; + store.load(entry.cacheDir); + if (!op(store)) return; + store.save(); + + GLOBAL_BOOKMARKS.syncFromStore(store, entry.sourcePath, entry.cacheDir, entry.title, entry.isTxt); + GLOBAL_BOOKMARKS.save(); +} + +void GlobalBookmarksActivity::deleteSelected() { + if (isSeparatorRow(selectorIndex)) return; + const auto& row = rows[selectorIndex]; + const size_t bookmarkIndex = row.bookmarkIndex; + + mutateBook(row.bookIndex, [bookmarkIndex](BookmarkStore& store) { + if (bookmarkIndex >= store.getAll().size()) return false; + store.removeAt(bookmarkIndex); + return true; + }); + + rebuildRows(); + const int total = static_cast(rows.size()); + buttonNavigator.setSelectablePredicate([this](int index) { return !isSeparatorRow(index); }, total); + + if (rows.empty()) { + onGoHome(); + return; + } + if (selectorIndex >= total) selectorIndex = total - 1; + if (isSeparatorRow(selectorIndex)) { + const int next = ButtonNavigator::nextIndex(selectorIndex, total, [this](int i) { return !isSeparatorRow(i); }); + if (next >= 0) selectorIndex = next; + } + requestUpdate(); +} + +void GlobalBookmarksActivity::renameSelected() { + if (isSeparatorRow(selectorIndex)) return; + const auto& row = rows[selectorIndex]; + const auto& entries = GLOBAL_BOOKMARKS.getEntries(); + if (row.bookIndex >= entries.size()) return; + const auto& entry = entries[row.bookIndex]; + if (row.bookmarkIndex >= entry.bookmarks.size()) return; + + const size_t bookIndex = row.bookIndex; + const size_t bookmarkIndex = row.bookmarkIndex; + const std::string initial = + entry.bookmarks[bookmarkIndex].name.empty() ? getRowTitle(selectorIndex) : entry.bookmarks[bookmarkIndex].name; + + startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_RENAME), initial, + BookmarkStore::MAX_NAME_LENGTH, false), + [this, bookIndex, bookmarkIndex](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& kr = std::get(result.data); + mutateBook(bookIndex, [bookmarkIndex, &kr](BookmarkStore& store) { + if (bookmarkIndex >= store.getAll().size()) return false; + store.rename(bookmarkIndex, kr.text); + return true; + }); + rebuildRows(); + buttonNavigator.setSelectablePredicate([this](int i) { return !isSeparatorRow(i); }, + static_cast(rows.size())); + } + requestUpdate(); + }); +} + +void GlobalBookmarksActivity::loop() { + const int total = static_cast(rows.size()); + + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onGoHome(); + return; + } + + if (total == 0) return; + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + openSelected(); + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { + renameSelected(); + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Right)) { + deleteSelected(); + return; + } + + const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false); + + buttonNavigator.onNextRelease([this] { + selectorIndex = buttonNavigator.nextIndex(selectorIndex); + requestUpdate(); + }); + + buttonNavigator.onPreviousRelease([this] { + selectorIndex = buttonNavigator.previousIndex(selectorIndex); + requestUpdate(); + }); + + buttonNavigator.onNextContinuous([this, total, pageItems] { + int next = ButtonNavigator::nextPageIndex(selectorIndex, total, pageItems); + if (isSeparatorRow(next)) { + const int adj = ButtonNavigator::nextIndex(next, total, [this](int i) { return !isSeparatorRow(i); }); + if (adj >= 0) next = adj; + } + selectorIndex = next; + requestUpdate(); + }); + + buttonNavigator.onPreviousContinuous([this, total, pageItems] { + int prev = ButtonNavigator::previousPageIndex(selectorIndex, total, pageItems); + if (isSeparatorRow(prev)) { + const int adj = ButtonNavigator::previousIndex(prev, total, [this](int i) { return !isSeparatorRow(i); }); + if (adj >= 0) prev = adj; + } + selectorIndex = prev; + requestUpdate(); + }); +} + +void GlobalBookmarksActivity::render(RenderLock&&) { + renderer.clearScreen(); + + const auto& metrics = UITheme::getInstance().getMetrics(); + const Rect contentRect = UITheme::getContentRect(renderer, true, true); + + GUI.drawHeader(renderer, Rect{contentRect.x, metrics.topPadding, contentRect.width, metrics.headerHeight}, + tr(STR_GLOBAL_BOOKMARKS)); + + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = contentRect.height - contentTop - metrics.verticalSpacing; + + if (rows.empty()) { + renderer.drawText(UI_10_FONT_ID, contentRect.x + metrics.contentSidePadding, contentTop + 20, + tr(STR_NO_STARRED_PAGES)); + } else { + GUI.drawList(renderer, Rect{contentRect.x, contentTop, contentRect.width, contentHeight}, + static_cast(rows.size()), selectorIndex, [this](int index) { return getRowTitle(index); }); + } + + const bool hasBookmarks = !rows.empty() && !isSeparatorRow(selectorIndex); + const auto labels = mappedInput.mapLabels(tr(STR_HOME), hasBookmarks ? tr(STR_OPEN) : "", + hasBookmarks ? tr(STR_RENAME) : "", hasBookmarks ? tr(STR_DELETE) : ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + GUI.drawSideButtonHints(renderer, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + + renderer.displayBuffer(); +} diff --git a/src/activities/home/GlobalBookmarksActivity.h b/src/activities/home/GlobalBookmarksActivity.h new file mode 100644 index 00000000..3f4c9f75 --- /dev/null +++ b/src/activities/home/GlobalBookmarksActivity.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include + +#include "../Activity.h" +#include "util/ButtonNavigator.h" + +struct Rect; + +// Home-screen activity that aggregates bookmarks from every indexed book and +// jumps directly into the chosen book/position on Confirm. +// +// Data source: GlobalBookmarkIndex (persisted at /.crosspoint/global_bookmarks.bin). +// Reconciles against the filesystem on entry (drops entries whose source file +// has disappeared). +// +// The display list is a flat vector of rows, where each row is either a book +// header separator or a bookmark entry belonging to the preceding header. +class GlobalBookmarksActivity final : public Activity { + public: + explicit GlobalBookmarksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("GlobalBookmarks", renderer, mappedInput) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + + private: + struct Row { + bool isSeparator = false; + size_t bookIndex = 0; // index into GlobalBookmarkIndex entries + size_t bookmarkIndex = 0; // index within that entry's bookmarks (separator: ignored) + }; + + ButtonNavigator buttonNavigator; + std::vector rows; + int selectorIndex = 0; + + void rebuildRows(); + std::string getRowTitle(int index) const; + bool isSeparatorRow(int index) const; + int firstSelectableIndex() const; + + void openSelected(); + void deleteSelected(); + void renameSelected(); + + // Apply a mutation to the underlying per-book BookmarkStore + global index. + // `op` is invoked with the loaded store; it should mutate and return true + // when something changed worth persisting. Title/cacheDir/isTxt are taken + // from the current index entry. + template + void mutateBook(size_t bookIndex, Op&& op); +}; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 3d3ac5e1..53dd435a 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -14,6 +14,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" +#include "GlobalBookmarkIndex.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" #include "components/UITheme.h" @@ -106,6 +107,9 @@ int HomeActivity::getMenuItemCount() const { if (hasOpdsUrl) { count++; } + if (!GLOBAL_BOOKMARKS.isEmpty()) { + count++; + } return count; } @@ -278,8 +282,10 @@ void HomeActivity::loop() { // Calculate dynamic indices based on which options are available int idx = 0; int menuSelectedIndex = selectorIndex - static_cast(recentBooks.size()); + const bool hasGlobalBookmarks = !GLOBAL_BOOKMARKS.isEmpty(); const int fileBrowserIdx = idx++; const int recentsIdx = idx++; + const int globalBookmarksIdx = hasGlobalBookmarks ? idx++ : -1; const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; const int fileTransferIdx = idx++; const int weatherIdx = idx++; @@ -291,6 +297,8 @@ void HomeActivity::loop() { onFileBrowserOpen(); } else if (menuSelectedIndex == recentsIdx) { onRecentsOpen(); + } else if (menuSelectedIndex == globalBookmarksIdx) { + onGlobalBookmarksOpen(); } else if (menuSelectedIndex == opdsLibraryIdx) { onOpdsBrowserOpen(); } else if (menuSelectedIndex == weatherIdx) { @@ -317,10 +325,17 @@ void HomeActivity::render(RenderLock&&) { tr(STR_WEATHER), tr(STR_SETTINGS_TITLE)}; std::vector menuIcons = {Folder, Recent, Transfer, Weather, Settings}; + int insertAfterRecents = 2; + if (!GLOBAL_BOOKMARKS.isEmpty()) { + menuItems.insert(menuItems.begin() + insertAfterRecents, tr(STR_GLOBAL_BOOKMARKS)); + menuIcons.insert(menuIcons.begin() + insertAfterRecents, Book); + insertAfterRecents++; + } + if (hasOpdsUrl) { - // Insert OPDS Browser after Recents (before File Transfer) - menuItems.insert(menuItems.begin() + 2, tr(STR_OPDS_BROWSER)); - menuIcons.insert(menuIcons.begin() + 2, Library); + // Insert OPDS Browser after Recents (and Global Bookmarks if present) + menuItems.insert(menuItems.begin() + insertAfterRecents, tr(STR_OPDS_BROWSER)); + menuIcons.insert(menuIcons.begin() + insertAfterRecents, Library); } const HomeScreenLayout layout = @@ -356,6 +371,8 @@ void HomeActivity::onFileBrowserOpen() { activityManager.goToFileBrowser(); } void HomeActivity::onRecentsOpen() { activityManager.goToRecentBooks(); } +void HomeActivity::onGlobalBookmarksOpen() { activityManager.goToGlobalBookmarks(); } + void HomeActivity::onSettingsOpen() { activityManager.goToSettings(); } void HomeActivity::onFileTransferOpen() { activityManager.goToFileTransfer(); } diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 7d66caeb..8bf7ce7e 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -25,6 +25,7 @@ class HomeActivity final : public Activity { void onSelectBook(const std::string& path); void onFileBrowserOpen(); void onRecentsOpen(); + void onGlobalBookmarksOpen(); void onSettingsOpen(); void onFileTransferOpen(); void onOpdsBrowserOpen(); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 8068d8cb..2ceff469 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -18,6 +18,7 @@ #include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderFootnotesActivity.h" #include "EpubReaderPercentSelectionActivity.h" +#include "GlobalBookmarkIndex.h" #include "KOReaderCredentialStore.h" #include "MappedInputManager.h" #include "QrDisplayActivity.h" @@ -93,6 +94,7 @@ void EpubReaderActivity::onEnter() { epub->setupCacheDir(); applyPendingSyncSession(); + applyPendingBookmarkJump(); FsFile f; if (Storage.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) { @@ -145,6 +147,9 @@ void EpubReaderActivity::onExit() { // Save bookmarks before exit bookmarkStore.save(); + if (epub) { + GLOBAL_BOOKMARKS.syncFromStore(bookmarkStore, epub->getPath(), epub->getCachePath(), epub->getTitle(), false); + } // Reset orientation back to portrait for the rest of the UI renderer.setOrientation(GfxRenderer::Orientation::Portrait); @@ -560,6 +565,8 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction if (!bookmarkStore.isEmpty()) { bookmarkStore.markDirty(); bookmarkStore.save(); + GLOBAL_BOOKMARKS.syncFromStore(bookmarkStore, epub->getPath(), epub->getCachePath(), epub->getTitle(), + false); } } } @@ -689,6 +696,25 @@ void EpubReaderActivity::applyPendingSyncSession() { logReaderMemSnapshot("after_apply_pending_sync_session"); } +void EpubReaderActivity::applyPendingBookmarkJump() { + auto& jump = APP_STATE.pendingBookmarkJump; + if (!jump.active || !epub || jump.bookPath != epub->getPath()) { + return; + } + LOG_DBG("ERS", "Applying pending bookmark jump: spine=%u page=%u", jump.spineIndex, jump.pageNumber); + if (writeReaderProgressCache(epub->getCachePath(), jump.spineIndex, jump.pageNumber, 0)) { + cachedSpineIndex = jump.spineIndex; + cachedChapterTotalPageCount = 0; + } else { + currentSpineIndex = jump.spineIndex; + nextPageNumber = jump.pageNumber; + cachedSpineIndex = jump.spineIndex; + cachedChapterTotalPageCount = 0; + } + jump.clear(); + APP_STATE.saveToFile(); +} + void EpubReaderActivity::applyOrientation(const uint8_t orientation) { // No-op if the selected orientation matches current settings. if (SETTINGS.orientation == orientation) { diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index b07aa667..004e36fc 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -80,6 +80,10 @@ class EpubReaderActivity final : public Activity { // reader startup path reads it. Upload-complete leaves the existing local // progress.bin untouched and simply clears the pending session marker. void applyPendingSyncSession(); + // Consume a persisted bookmark-jump request (from GlobalBookmarksActivity) for + // this book. Rewrites progress.bin to the bookmarked position before the normal + // reader startup path reads it. + void applyPendingBookmarkJump(); void applyOrientation(uint8_t orientation); void applyTextDarkness(uint8_t textDarkness); void toggleAutoPageTurn(uint8_t selectedPageTurnOption); diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 35b95330..ecd8b074 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -9,6 +9,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" +#include "GlobalBookmarkIndex.h" #include "MappedInputManager.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" @@ -99,6 +100,7 @@ void TxtReaderActivity::onEnter() { } txt->setupCacheDir(); + applyPendingBookmarkJump(); // Load bookmarks for this file bookmarkStore.load(txt->getCachePath()); @@ -119,6 +121,9 @@ void TxtReaderActivity::onExit() { // Save bookmarks before exit bookmarkStore.save(); + if (txt) { + GLOBAL_BOOKMARKS.syncFromStore(bookmarkStore, txt->getPath(), txt->getCachePath(), txt->getTitle(), true); + } // Reset orientation back to portrait for the rest of the UI renderer.setOrientation(GfxRenderer::Orientation::Portrait); @@ -427,6 +432,26 @@ void TxtReaderActivity::saveProgress() const { } } +void TxtReaderActivity::applyPendingBookmarkJump() { + auto& jump = APP_STATE.pendingBookmarkJump; + if (!jump.active || !txt || jump.bookPath != txt->getPath()) { + return; + } + LOG_DBG("TRS", "Applying pending bookmark jump: page=%u", jump.pageNumber); + FsFile f; + if (Storage.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) { + uint8_t data[6] = {0}; + data[0] = jump.pageNumber & 0xFF; + data[1] = (jump.pageNumber >> 8) & 0xFF; + // Offset bytes stay 0: loadProgress reads only the page, and the lazy + // initializeReader() rebuilds the page index on first render anyway. + f.write(data, 6); + f.close(); + } + jump.clear(); + APP_STATE.saveToFile(); +} + void TxtReaderActivity::loadProgress() { FsFile f; if (Storage.openFileForRead("TRS", txt->getCachePath() + "/progress.bin", f)) { diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index f9cb16f8..03449fe7 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -46,6 +46,9 @@ class TxtReaderActivity final : public Activity { void savePageIndexCache() const; void saveProgress() const; void loadProgress(); + // Consume a persisted bookmark-jump request (from GlobalBookmarksActivity) for + // this TXT file. Rewrites progress.bin before initializeReader() reads it. + void applyPendingBookmarkJump(); public: explicit TxtReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr txt) diff --git a/src/main.cpp b/src/main.cpp index 4d875e94..71e9796a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -18,6 +18,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" +#include "GlobalBookmarkIndex.h" #include "KOReaderCredentialStore.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" @@ -247,6 +248,7 @@ void setup() { APP_STATE.loadFromFile(); HalClock::restore(); RECENT_BOOKS.loadFromFile(); + GLOBAL_BOOKMARKS.load(); // Boot to home screen if no book is open, last sleep was not from reader, back button is held, or reader activity // crashed (indicated by readerActivityLoadCount > 0)