Add Global Bookmark feature

This commit is contained in:
jpirnay
2026-04-15 09:32:52 +02:00
parent f7bf43d39b
commit 2f4cea3706
19 changed files with 737 additions and 8 deletions
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <cstdint>
#include <string>
struct Bookmark {
uint16_t spineIndex;
uint16_t pageNumber;
std::string name; // optional user-provided label (empty = use default)
};
+3 -5
View File
@@ -8,15 +8,13 @@
#include <string>
#include <vector>
#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_<hash>/).
void load(const std::string& cachePath) {
+1
View File
@@ -77,6 +77,7 @@ bool CrossPointState::loadFromBinaryFile() {
}
koReaderSyncSession.clear();
pendingBookmarkJump.clear();
inputFile.close();
return true;
+15
View File
@@ -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
+193
View File
@@ -0,0 +1,193 @@
#include "GlobalBookmarkIndex.h"
#include <HalStorage.h>
#include <Logging.h>
#include <algorithm>
GlobalBookmarkIndex GlobalBookmarkIndex::instance;
namespace {
void writeString(FsFile& f, const std::string& s) {
const uint16_t len = static_cast<uint16_t>(std::min<size_t>(s.size(), UINT16_MAX));
f.write(reinterpret_cast<const uint8_t*>(&len), sizeof(len));
if (len > 0) {
f.write(reinterpret_cast<const uint8_t*>(s.data()), len);
}
}
bool readString(FsFile& f, std::string& out) {
uint16_t len = 0;
if (f.read(reinterpret_cast<uint8_t*>(&len), sizeof(len)) != sizeof(len)) return false;
out.clear();
if (len == 0) return true;
out.resize(len);
return f.read(reinterpret_cast<uint8_t*>(&out[0]), len) == len;
}
} // namespace
std::vector<GlobalBookmarkIndex::Entry>::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<uint8_t*>(&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<uint8_t*>(&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<uint8_t*>(&bm.spineIndex), sizeof(bm.spineIndex)) != sizeof(bm.spineIndex) ||
f.read(reinterpret_cast<uint8_t*>(&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<unsigned>(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<uint16_t>(std::min<size_t>(entries.size(), UINT16_MAX));
f.write(reinterpret_cast<const uint8_t*>(&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<uint16_t>(std::min<size_t>(e.bookmarks.size(), UINT16_MAX));
f.write(reinterpret_cast<const uint8_t*>(&bmCount), sizeof(bmCount));
for (const auto& bm : e.bookmarks) {
f.write(reinterpret_cast<const uint8_t*>(&bm.spineIndex), sizeof(bm.spineIndex));
f.write(reinterpret_cast<const uint8_t*>(&bm.pageNumber), sizeof(bm.pageNumber));
writeString(f, bm.name);
}
}
f.close();
LOG_DBG("GBI", "Saved %u entries", static_cast<unsigned>(entryCount));
}
void GlobalBookmarkIndex::upsertFromStore(const std::string& sourcePath, const std::string& cacheDir,
const std::string& title, bool isTxt,
const std::vector<BookmarkStore::Bookmark>& 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;
}
+66
View File
@@ -0,0 +1,66 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#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<BookmarkStore::Bookmark> 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<BookmarkStore::Bookmark>& 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<Entry>& 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<Entry> entries;
bool loaded = false;
static constexpr uint8_t FILE_VERSION = 1;
static constexpr const char* FILE_PATH = "/.crosspoint/global_bookmarks.bin";
std::vector<Entry>::iterator findBySourcePath(const std::string& sourcePath);
};
#define GLOBAL_BOOKMARKS GlobalBookmarkIndex::getInstance()
+13
View File
@@ -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<JsonObject>();
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<JsonObject>();
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<JsonObject>();
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;
}
+5
View File
@@ -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<RecentBooksActivity>(renderer, mappedInput));
}
void ActivityManager::goToGlobalBookmarks() {
replaceActivity(std::make_unique<GlobalBookmarksActivity>(renderer, mappedInput));
}
void ActivityManager::goToBrowser() {
replaceActivity(std::make_unique<OpdsBookBrowserActivity>(renderer, mappedInput));
}
+1
View File
@@ -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();
@@ -0,0 +1,290 @@
#include "GlobalBookmarksActivity.h"
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <Logging.h>
#include <algorithm>
#include <cstdio>
#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<int>(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<int>(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<int>(i);
}
return -1;
}
std::string GlobalBookmarksActivity::getRowTitle(int index) const {
if (index < 0 || index >= static_cast<int>(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<int>(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 <typename Op>
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<int>(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<KeyboardEntryActivity>(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<KeyboardResult>(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<int>(rows.size()));
}
requestUpdate();
});
}
void GlobalBookmarksActivity::loop() {
const int total = static_cast<int>(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<int>(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();
}
@@ -0,0 +1,57 @@
#pragma once
#include <cstddef>
#include <string>
#include <vector>
#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<Row> 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 <typename Op>
void mutateBook(size_t bookIndex, Op&& op);
};
+20 -3
View File
@@ -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<int>(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<UIIcon> 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(); }
+1
View File
@@ -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();
@@ -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) {
@@ -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);
@@ -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)) {
@@ -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> txt)
+2
View File
@@ -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)