feat: epub bookmarks (#1337)
Co-authored-by: vedi0boy <nate@origin8publishing.com> Co-authored-by: Uri Tauber <uritaube@gmail.com>
This commit is contained in:
co-authored by
vedi0boy
Uri Tauber
parent
bbb3e06eb1
commit
36a3a0cc3a
+13
-2
@@ -35,6 +35,7 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
|||||||
- [Supported Languages](#supported-languages)
|
- [Supported Languages](#supported-languages)
|
||||||
- [5. Reader Menu](#5-reader-menu)
|
- [5. Reader Menu](#5-reader-menu)
|
||||||
- [5.1 Chapter Selection](#51-chapter-selection)
|
- [5.1 Chapter Selection](#51-chapter-selection)
|
||||||
|
- [5.2. Bookmarks](#52-bookmarks)
|
||||||
- [6. Current Limitations \& Roadmap](#6-current-limitations--roadmap)
|
- [6. Current Limitations \& Roadmap](#6-current-limitations--roadmap)
|
||||||
- [7. Troubleshooting Issues \& Escaping Bootloop](#7-troubleshooting-issues--escaping-bootloop)
|
- [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:
|
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.
|
If an issue or crash is encountered while using Crosspoint, feel free to raise an issue ticket and attach the logs.
|
||||||
|
|
||||||
|
|||||||
@@ -329,6 +329,43 @@ std::unique_ptr<Page> Section::loadPageFromSectionFile() {
|
|||||||
return page;
|
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<const PageLine&>(*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<uint16_t> 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<uint16_t> Section::getPageForAnchor(const std::string& anchor) const {
|
std::optional<uint16_t> Section::getPageForAnchor(const std::string& anchor) const {
|
||||||
HalFile f;
|
HalFile f;
|
||||||
if (!Storage.openFileForRead("SCT", filePath, f)) {
|
if (!Storage.openFileForRead("SCT", filePath, f)) {
|
||||||
|
|||||||
@@ -40,10 +40,14 @@ class Section {
|
|||||||
uint8_t imageRendering, bool focusReadingEnabled,
|
uint8_t imageRendering, bool focusReadingEnabled,
|
||||||
const std::function<void()>& popupFn = nullptr);
|
const std::function<void()>& popupFn = nullptr);
|
||||||
std::unique_ptr<Page> loadPageFromSectionFile();
|
std::unique_ptr<Page> loadPageFromSectionFile();
|
||||||
|
std::string getTextFromSectionFile();
|
||||||
|
|
||||||
// Look up the page number for an anchor id from the section cache file.
|
// Look up the page number for an anchor id from the section cache file.
|
||||||
std::optional<uint16_t> getPageForAnchor(const std::string& anchor) const;
|
std::optional<uint16_t> getPageForAnchor(const std::string& anchor) const;
|
||||||
|
|
||||||
|
// Get the page count from the section cache file without fully loading it.
|
||||||
|
std::optional<uint16_t> getCachedPageCount() const;
|
||||||
|
|
||||||
// Look up the page number for a synthetic paragraph index from XPath p[N].
|
// Look up the page number for a synthetic paragraph index from XPath p[N].
|
||||||
std::optional<uint16_t> getPageForParagraphIndex(uint16_t pIndex) const;
|
std::optional<uint16_t> getPageForParagraphIndex(uint16_t pIndex) const;
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,8 @@ STR_DOWNLOADING: "Downloading..."
|
|||||||
STR_DOWNLOAD_FAILED: "Download failed"
|
STR_DOWNLOAD_FAILED: "Download failed"
|
||||||
STR_ERROR_MSG: "Error:"
|
STR_ERROR_MSG: "Error:"
|
||||||
STR_UNNAMED: "Unnamed"
|
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_NO_SERVER_URL: "No server URL configured"
|
||||||
STR_FETCH_FEED_FAILED: "Failed to fetch feed"
|
STR_FETCH_FEED_FAILED: "Failed to fetch feed"
|
||||||
STR_PARSE_FEED_FAILED: "Failed to parse feed"
|
STR_PARSE_FEED_FAILED: "Failed to parse feed"
|
||||||
@@ -259,6 +261,8 @@ STR_THEME_ROUNDEDRAFF: "RoundedRaff"
|
|||||||
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
|
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
|
||||||
STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix"
|
STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix"
|
||||||
STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons"
|
STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons"
|
||||||
|
STR_BOOKMARKS: "Bookmarks"
|
||||||
|
STR_BOOKMARK_ADDED: "Bookmark added."
|
||||||
STR_OPDS_BROWSER: "OPDS Browser"
|
STR_OPDS_BROWSER: "OPDS Browser"
|
||||||
STR_SEARCH: "Search"
|
STR_SEARCH: "Search"
|
||||||
STR_COVER_CUSTOM: "Cover + Custom"
|
STR_COVER_CUSTOM: "Cover + Custom"
|
||||||
@@ -288,6 +292,7 @@ STR_GO_HOME_BUTTON: "Go Home"
|
|||||||
STR_SYNC_PROGRESS: "Sync Progress"
|
STR_SYNC_PROGRESS: "Sync Progress"
|
||||||
STR_DELETE_CACHE: "Delete Book Cache"
|
STR_DELETE_CACHE: "Delete Book Cache"
|
||||||
STR_DELETE: "Delete"
|
STR_DELETE: "Delete"
|
||||||
|
STR_CONFIRM_DELETE_BOOKMARK: "Delete this bookmark?"
|
||||||
STR_DISPLAY_QR: "Show page as QR"
|
STR_DISPLAY_QR: "Show page as QR"
|
||||||
STR_CHAPTER_PREFIX: "Chapter: "
|
STR_CHAPTER_PREFIX: "Chapter: "
|
||||||
STR_PAGES_SEPARATOR: " pages | "
|
STR_PAGES_SEPARATOR: " pages | "
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "ProgressMapper.h"
|
#include "ProgressMapper.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -7,6 +8,7 @@
|
|||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
#include "ChapterXPathResolver.h"
|
#include "ChapterXPathResolver.h"
|
||||||
|
#include "Epub/Section.h"
|
||||||
#include "Epub/htmlEntities.h"
|
#include "Epub/htmlEntities.h"
|
||||||
#include "Utf8.h"
|
#include "Utf8.h"
|
||||||
|
|
||||||
@@ -506,8 +508,9 @@ bool streamSpine(const std::shared_ptr<Epub>& epub, int spineIndex, ParagraphStr
|
|||||||
}
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos) {
|
SavedProgressPosition ProgressMapper::toSavedProgress(const std::shared_ptr<Epub>& epub,
|
||||||
KOReaderPosition result;
|
const CrossPointPosition& pos) {
|
||||||
|
SavedProgressPosition result;
|
||||||
float intra =
|
float intra =
|
||||||
(pos.totalPages > 1) ? static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages - 1) : 0.0f;
|
(pos.totalPages > 1) ? static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages - 1) : 0.0f;
|
||||||
result.percentage = epub->calculateProgress(pos.spineIndex, intra);
|
result.percentage = epub->calculateProgress(pos.spineIndex, intra);
|
||||||
@@ -520,13 +523,14 @@ KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, c
|
|||||||
if (result.xpath.empty()) {
|
if (result.xpath.empty()) {
|
||||||
result.xpath = generateXPath(epub, pos.spineIndex, intra);
|
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());
|
result.percentage * 100, result.xpath.c_str());
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epub, const KOReaderPosition& koPos,
|
CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epub, const SavedProgressPosition& koPos,
|
||||||
int currentSpineIndex, int totalPagesInCurrentSpine) {
|
GfxRenderer& renderer, int currentSpineIndex,
|
||||||
|
int totalPagesInCurrentSpine, int fallbackTotalPages) {
|
||||||
CrossPointPosition result{};
|
CrossPointPosition result{};
|
||||||
const size_t bookSize = epub->getBookSize();
|
const size_t bookSize = epub->getBookSize();
|
||||||
if (bookSize == 0) return result;
|
if (bookSize == 0) return result;
|
||||||
@@ -556,7 +560,6 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (result.spineIndex >= spineCount) return result;
|
|
||||||
|
|
||||||
const size_t prevCum = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0;
|
const size_t prevCum = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0;
|
||||||
const size_t spineSize = epub->getCumulativeSpineItemSize(result.spineIndex) - prevCum;
|
const size_t spineSize = epub->getCumulativeSpineItemSize(result.spineIndex) - prevCum;
|
||||||
@@ -570,7 +573,17 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
|
|||||||
result.totalPages = std::max(
|
result.totalPages = std::max(
|
||||||
1, static_cast<int>(totalPagesInCurrentSpine * static_cast<float>(spineSize) / static_cast<float>(cs)));
|
1, static_cast<int>(totalPagesInCurrentSpine * static_cast<float>(spineSize) / static_cast<float>(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;
|
float intra = 0.0f;
|
||||||
if (useAncestry) {
|
if (useAncestry) {
|
||||||
@@ -613,8 +626,60 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
|
|||||||
|
|
||||||
result.pageNumber = std::max(
|
result.pageNumber = std::max(
|
||||||
0, std::min(static_cast<int>(intra * static_cast<float>(result.totalPages - 1) + 0.5f), result.totalPages - 1));
|
0, std::min(static_cast<int>(intra * static_cast<float>(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);
|
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<int>(*paragraphPage));
|
||||||
|
if (nextParagraphPage.has_value()) {
|
||||||
|
const int lutSpan = static_cast<int>(*nextParagraphPage) - static_cast<int>(*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<int>(*nextParagraphPage)) {
|
||||||
|
refinedPage = static_cast<int>(*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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <Epub.h>
|
#include <Epub.h>
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -19,18 +20,18 @@ struct CrossPointPosition {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KOReader position representation.
|
* Progress position representation.
|
||||||
*/
|
*/
|
||||||
struct KOReaderPosition {
|
struct SavedProgressPosition {
|
||||||
std::string xpath; // XPath-like progress string
|
std::string xpath; // XPath-like progress string
|
||||||
float percentage; // Progress percentage (0.0 to 1.0)
|
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).
|
* 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
|
* Since CrossPoint discards HTML structure during parsing, we generate
|
||||||
* synthetic XPath strings based on spine index, using percentage as the
|
* synthetic XPath strings based on spine index, using percentage as the
|
||||||
@@ -39,28 +40,30 @@ struct KOReaderPosition {
|
|||||||
class ProgressMapper {
|
class ProgressMapper {
|
||||||
public:
|
public:
|
||||||
/**
|
/**
|
||||||
* Convert CrossPoint position to KOReader format.
|
* Convert CrossPoint position to SavedProgress format.
|
||||||
*
|
*
|
||||||
* @param epub The EPUB book
|
* @param epub The EPUB book
|
||||||
* @param pos CrossPoint position
|
* @param pos CrossPoint position
|
||||||
* @return KOReader position
|
* @return SavedProgress position
|
||||||
*/
|
*/
|
||||||
static KOReaderPosition toKOReader(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos);
|
static SavedProgressPosition toSavedProgress(const std::shared_ptr<Epub>& 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
|
* Note: The returned pageNumber may be approximate since different
|
||||||
* rendering settings produce different page counts.
|
* rendering settings produce different page counts.
|
||||||
*
|
*
|
||||||
* @param epub The EPUB book
|
* @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 currentSpineIndex Index of the currently open spine item (for density estimation)
|
||||||
* @param totalPagesInCurrentSpine Total pages in the current spine item (for density estimation)
|
* @param totalPagesInCurrentSpine Total pages in the current spine item (for density estimation)
|
||||||
* @return CrossPoint position
|
* @return CrossPoint position
|
||||||
*/
|
*/
|
||||||
static CrossPointPosition toCrossPoint(const std::shared_ptr<Epub>& epub, const KOReaderPosition& koPos,
|
static CrossPointPosition toCrossPoint(const std::shared_ptr<Epub>& epub, const SavedProgressPosition& savedPos,
|
||||||
int currentSpineIndex = -1, int totalPagesInCurrentSpine = 0);
|
GfxRenderer& renderer, int currentSpineIndex = -1,
|
||||||
|
int totalPagesInCurrentSpine = 0, int fallbackTotalPages = 0);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// 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
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
#include "BookmarkEntry.h"
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
#include "OpdsServerStore.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());
|
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", store.servers.size());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Bookmarks ----
|
||||||
|
|
||||||
|
bool JsonSettingsIO::saveBookmarks(const std::vector<BookmarkEntry>& bookmarks, const char* path) {
|
||||||
|
JsonDocument doc;
|
||||||
|
JsonArray arr = doc["bookmarks"].to<JsonArray>();
|
||||||
|
LOG_DBG("BKM", "Saving %zu bookmarks to file", bookmarks.size());
|
||||||
|
for (const auto& bookmark : bookmarks) {
|
||||||
|
JsonObject obj = arr.add<JsonObject>();
|
||||||
|
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<BookmarkEntry>& 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<JsonArray>();
|
||||||
|
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<float>(0);
|
||||||
|
bookmark.summary = obj["summary"] | std::string("");
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DBG("BKM", "Loaded %zu bookmarks from file", bookmarks.size());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
class CrossPointSettings;
|
class CrossPointSettings;
|
||||||
class CrossPointState;
|
class CrossPointState;
|
||||||
class WifiCredentialStore;
|
class WifiCredentialStore;
|
||||||
class RecentBooksStore;
|
class RecentBooksStore;
|
||||||
class OpdsServerStore;
|
class OpdsServerStore;
|
||||||
|
struct BookmarkEntry;
|
||||||
|
|
||||||
namespace JsonSettingsIO {
|
namespace JsonSettingsIO {
|
||||||
|
|
||||||
@@ -28,4 +31,8 @@ bool loadRecentBooks(RecentBooksStore& store, const char* json);
|
|||||||
bool saveOpds(const OpdsServerStore& store, const char* path);
|
bool saveOpds(const OpdsServerStore& store, const char* path);
|
||||||
bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave = nullptr);
|
bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave = nullptr);
|
||||||
|
|
||||||
|
// Bookmarks
|
||||||
|
bool saveBookmarks(const std::vector<BookmarkEntry>& bookmarks, const char* path);
|
||||||
|
bool loadBookmarks(std::vector<BookmarkEntry>& bookmarks, const char* json);
|
||||||
|
|
||||||
} // namespace JsonSettingsIO
|
} // namespace JsonSettingsIO
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ struct PageResult {
|
|||||||
uint32_t page = 0;
|
uint32_t page = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct SyncResult {
|
struct ProgressChangeResult {
|
||||||
int spineIndex = 0;
|
int spineIndex = 0;
|
||||||
int page = 0;
|
int page = 0;
|
||||||
};
|
};
|
||||||
@@ -56,7 +56,7 @@ struct FilePathResult {
|
|||||||
};
|
};
|
||||||
|
|
||||||
using ResultVariant = std::variant<std::monostate, WifiResult, KeyboardResult, MenuResult, ChapterResult, PercentResult,
|
using ResultVariant = std::variant<std::monostate, WifiResult, KeyboardResult, MenuResult, ChapterResult, PercentResult,
|
||||||
PageResult, SyncResult, NetworkModeResult, FootnoteResult, FilePathResult>;
|
PageResult, ProgressChangeResult, NetworkModeResult, FootnoteResult, FilePathResult>;
|
||||||
|
|
||||||
struct ActivityResult {
|
struct ActivityResult {
|
||||||
bool isCancelled = false;
|
bool isCancelled = false;
|
||||||
|
|||||||
@@ -7,16 +7,20 @@
|
|||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
#include <I18n.h>
|
#include <I18n.h>
|
||||||
|
#include <JsonSettingsIO.h>
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <Memory.h>
|
#include <Memory.h>
|
||||||
#include <esp_system.h>
|
#include <esp_system.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <iterator>
|
#include <iterator>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
|
||||||
|
#include "BookmarkEntry.h"
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
|
#include "EpubReaderBookmarksActivity.h"
|
||||||
#include "EpubReaderChapterSelectionActivity.h"
|
#include "EpubReaderChapterSelectionActivity.h"
|
||||||
#include "EpubReaderFootnotesActivity.h"
|
#include "EpubReaderFootnotesActivity.h"
|
||||||
#include "EpubReaderPercentSelectionActivity.h"
|
#include "EpubReaderPercentSelectionActivity.h"
|
||||||
@@ -30,6 +34,7 @@
|
|||||||
#include "RecentBooksStore.h"
|
#include "RecentBooksStore.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
#include "util/BookmarkUtil.h"
|
||||||
#include "util/ScreenshotUtil.h"
|
#include "util/ScreenshotUtil.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -246,28 +251,48 @@ void EpubReaderActivity::loop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showBookmarkMessage && (millis() - bookmarkMessageTime) >= ReaderUtils::BOOKMARK_MESSAGE_DURATION_MS) {
|
||||||
|
showBookmarkMessage = false;
|
||||||
|
requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
// Enter reader menu activity.
|
// Enter reader menu activity.
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
const int currentPage = section ? section->currentPage + 1 : 0;
|
if (ignoreNextConfirmRelease) {
|
||||||
const int totalPages = section ? section->pageCount : 0;
|
ignoreNextConfirmRelease = false;
|
||||||
float bookProgress = 0.0f;
|
} else {
|
||||||
if (epub->getBookSize() > 0 && section && section->pageCount > 0) {
|
const int currentPage = section ? section->currentPage + 1 : 0;
|
||||||
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(section->pageCount);
|
const int totalPages = section ? section->pageCount : 0;
|
||||||
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
|
float bookProgress = 0.0f;
|
||||||
|
if (epub->getBookSize() > 0 && section && section->pageCount > 0) {
|
||||||
|
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(section->pageCount);
|
||||||
|
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
|
||||||
|
}
|
||||||
|
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
|
||||||
|
startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
|
||||||
|
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
||||||
|
SETTINGS.orientation, !currentPageFootnotes.empty()),
|
||||||
|
[this](const ActivityResult& result) {
|
||||||
|
// Always apply orientation change even if the menu was cancelled
|
||||||
|
const auto& menu = std::get<MenuResult>(result.data);
|
||||||
|
applyOrientation(menu.orientation);
|
||||||
|
toggleAutoPageTurn(menu.pageTurnOption);
|
||||||
|
if (!result.isCancelled) {
|
||||||
|
onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(menu.action));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
|
||||||
|
mappedInput.getHeldTime() >= ReaderUtils::BOOKMARK_HOLD_MS) {
|
||||||
|
if (!showBookmarkMessage) {
|
||||||
|
addBookmark();
|
||||||
|
showBookmarkMessage = true;
|
||||||
|
ignoreNextConfirmRelease = true; // Prevent accidental menu open after adding bookmark
|
||||||
|
bookmarkMessageTime = millis();
|
||||||
|
requestUpdate();
|
||||||
}
|
}
|
||||||
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
|
|
||||||
startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
|
|
||||||
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
|
||||||
SETTINGS.orientation, !currentPageFootnotes.empty()),
|
|
||||||
[this](const ActivityResult& result) {
|
|
||||||
// Always apply orientation change even if the menu was cancelled
|
|
||||||
const auto& menu = std::get<MenuResult>(result.data);
|
|
||||||
applyOrientation(menu.orientation);
|
|
||||||
toggleAutoPageTurn(menu.pageTurnOption);
|
|
||||||
if (!result.isCancelled) {
|
|
||||||
onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(menu.action));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Long press BACK (1s+) goes to file selection
|
// Long press BACK (1s+) goes to file selection
|
||||||
@@ -410,6 +435,18 @@ void EpubReaderActivity::jumpToPercent(int percent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
|
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
|
||||||
|
auto progressChangeResultHandler = [this](const ActivityResult& result) {
|
||||||
|
if (!result.isCancelled) {
|
||||||
|
const auto& sync = std::get<ProgressChangeResult>(result.data);
|
||||||
|
if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) {
|
||||||
|
RenderLock lock(*this);
|
||||||
|
currentSpineIndex = sync.spineIndex;
|
||||||
|
nextPageNumber = sync.page;
|
||||||
|
section.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: {
|
case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: {
|
||||||
const int spineIdx = currentSpineIndex;
|
const int spineIdx = currentSpineIndex;
|
||||||
@@ -463,26 +500,11 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
|||||||
}
|
}
|
||||||
case EpubReaderMenuActivity::MenuAction::DISPLAY_QR: {
|
case EpubReaderMenuActivity::MenuAction::DISPLAY_QR: {
|
||||||
if (section && section->currentPage >= 0 && section->currentPage < section->pageCount) {
|
if (section && section->currentPage >= 0 && section->currentPage < section->pageCount) {
|
||||||
auto p = section->loadPageFromSectionFile();
|
std::string fullText = section->getTextFromSectionFile();
|
||||||
if (p) {
|
if (!fullText.empty()) {
|
||||||
std::string fullText;
|
startActivityForResult(std::make_unique<QrDisplayActivity>(renderer, mappedInput, fullText),
|
||||||
for (const auto& el : p->elements) {
|
[this](const ActivityResult& result) {});
|
||||||
if (el->getTag() == TAG_PageLine) {
|
break;
|
||||||
const auto& line = static_cast<const PageLine&>(*el);
|
|
||||||
if (line.getBlock()) {
|
|
||||||
const auto& words = line.getBlock()->getWords();
|
|
||||||
for (const auto& w : words) {
|
|
||||||
if (!fullText.empty()) fullText += " ";
|
|
||||||
fullText += w;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!fullText.empty()) {
|
|
||||||
startActivityForResult(std::make_unique<QrDisplayActivity>(renderer, mappedInput, fullText),
|
|
||||||
[this](const ActivityResult& result) {});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If no text or page loading failed, just close menu
|
// 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.
|
// Pre-compute local KO position and chapter name while Epub is still in RAM.
|
||||||
CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPages};
|
CrossPointPosition localPos = getCurrentPosition();
|
||||||
if (paragraphIndex.has_value()) {
|
SavedProgressPosition localKoPos = ProgressMapper::toSavedProgress(epub, localPos);
|
||||||
localPos.paragraphIndex = *paragraphIndex;
|
|
||||||
localPos.hasParagraphIndex = true;
|
|
||||||
}
|
|
||||||
KOReaderPosition localKoPos = ProgressMapper::toKOReader(epub, localPos);
|
|
||||||
const int tocIdx = epub->getTocIndexForSpineIndex(currentSpineIndex);
|
const int tocIdx = epub->getTocIndexForSpineIndex(currentSpineIndex);
|
||||||
std::string localChapterName = (tocIdx >= 0) ? epub->getTocItem(tocIdx).title : "";
|
std::string localChapterName = (tocIdx >= 0) ? epub->getTocItem(tocIdx).title : "";
|
||||||
const std::string savedEpubPath = epub->getPath();
|
const std::string savedEpubPath = epub->getPath();
|
||||||
@@ -570,6 +588,12 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case EpubReaderMenuActivity::MenuAction::BOOKMARKS: {
|
||||||
|
startActivityForResult(
|
||||||
|
std::make_unique<EpubReaderBookmarksActivity>(renderer, mappedInput, epub, epub->getPath()),
|
||||||
|
progressChangeResultHandler);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,6 +862,10 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
pendingScreenshot = false;
|
pendingScreenshot = false;
|
||||||
ScreenshotUtil::takeScreenshot(renderer);
|
ScreenshotUtil::takeScreenshot(renderer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showBookmarkMessage) {
|
||||||
|
GUI.drawPopup(renderer, tr(STR_BOOKMARK_ADDED));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportWidth, const uint16_t viewportHeight) {
|
void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportWidth, const uint16_t viewportHeight) {
|
||||||
@@ -1116,6 +1144,58 @@ void EpubReaderActivity::restoreSavedPosition() {
|
|||||||
requestUpdate();
|
requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void EpubReaderActivity::addBookmark() {
|
||||||
|
if (!section || !epub) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LOG_DBG("ERS", "Adding bookmark at spine %d, page %d", currentSpineIndex, section ? section->currentPage : -1);
|
||||||
|
int currentPage;
|
||||||
|
int pageCount;
|
||||||
|
{
|
||||||
|
RenderLock lock(*this);
|
||||||
|
pageCount = section->pageCount;
|
||||||
|
currentPage = section->currentPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string pageText;
|
||||||
|
if (currentPage >= 0 && currentPage < pageCount) {
|
||||||
|
pageText = section->getTextFromSectionFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
SavedProgressPosition progress = ProgressMapper::toSavedProgress(epub, getCurrentPosition());
|
||||||
|
|
||||||
|
BookmarkEntry entry;
|
||||||
|
entry.percentage = progress.percentage;
|
||||||
|
entry.xpath = progress.xpath;
|
||||||
|
entry.summary = BookmarkUtil::sanitizeBookmarkSummary(pageText);
|
||||||
|
|
||||||
|
// Add bookmark
|
||||||
|
const std::string path = BookmarkUtil::getBookmarkPath(epub->getPath());
|
||||||
|
LOG_DBG("ERS", "Bookmark path: %s", path.c_str());
|
||||||
|
const std::string bookmarksDir = BookmarkUtil::getBookmarksDir();
|
||||||
|
Storage.mkdir(bookmarksDir.c_str());
|
||||||
|
std::vector<BookmarkEntry> bookmarks;
|
||||||
|
if (Storage.exists(path.c_str())) {
|
||||||
|
LOG_DBG("ERS", "Existing bookmark file found, loading bookmarks");
|
||||||
|
String json = Storage.readFile(path.c_str());
|
||||||
|
if (!json.isEmpty()) {
|
||||||
|
JsonSettingsIO::loadBookmarks(bookmarks, json.c_str());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LOG_DBG("ERS", "No existing bookmark file, starting with empty bookmark list");
|
||||||
|
}
|
||||||
|
bookmarks.insert(bookmarks.begin(), entry);
|
||||||
|
LOG_DBG("ERS", "Saving bookmark to file: %s", path.c_str());
|
||||||
|
const bool ok = JsonSettingsIO::saveBookmarks(bookmarks, path.c_str());
|
||||||
|
if (ok) {
|
||||||
|
showBookmarkMessage = true;
|
||||||
|
} else {
|
||||||
|
LOG_ERR("ERS", "Failed to save bookmark to: %s", path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
|
ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
|
||||||
ScreenshotInfo info;
|
ScreenshotInfo info;
|
||||||
info.readerType = ScreenshotInfo::ReaderType::Epub;
|
info.readerType = ScreenshotInfo::ReaderType::Epub;
|
||||||
@@ -1136,3 +1216,23 @@ ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
|
|||||||
}
|
}
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CrossPointPosition EpubReaderActivity::getCurrentPosition() const {
|
||||||
|
const int currentPage = section ? section->currentPage : nextPageNumber;
|
||||||
|
const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount;
|
||||||
|
std::optional<uint16_t> paragraphIndex;
|
||||||
|
if (section && currentPage >= 0 && currentPage < section->pageCount) {
|
||||||
|
const uint16_t paragraphPage =
|
||||||
|
currentPage > 0 ? static_cast<uint16_t>(currentPage - 1) : static_cast<uint16_t>(currentPage);
|
||||||
|
if (const auto pIdx = section->getParagraphIndexForPage(paragraphPage)) {
|
||||||
|
paragraphIndex = *pIdx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPages};
|
||||||
|
if (paragraphIndex.has_value()) {
|
||||||
|
localPos.paragraphIndex = *paragraphIndex;
|
||||||
|
localPos.hasParagraphIndex = true;
|
||||||
|
}
|
||||||
|
return localPos;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <optional>
|
#include <optional>
|
||||||
|
|
||||||
#include "EpubReaderMenuActivity.h"
|
#include "EpubReaderMenuActivity.h"
|
||||||
|
#include "ProgressMapper.h"
|
||||||
#include "activities/Activity.h"
|
#include "activities/Activity.h"
|
||||||
|
|
||||||
class EpubReaderActivity final : public Activity {
|
class EpubReaderActivity final : public Activity {
|
||||||
@@ -31,9 +32,12 @@ class EpubReaderActivity final : public Activity {
|
|||||||
bool pendingSyncSaveError = false;
|
bool pendingSyncSaveError = false;
|
||||||
bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit
|
bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit
|
||||||
bool automaticPageTurnActive = false;
|
bool automaticPageTurnActive = false;
|
||||||
|
bool showBookmarkMessage = false;
|
||||||
|
bool ignoreNextConfirmRelease = false;
|
||||||
// Tracks whether this book is currently removed from Recent Books by the
|
// Tracks whether this book is currently removed from Recent Books by the
|
||||||
// removeReadBooksFromRecents feature (set at End-of-Book, cleared if paged back in).
|
// removeReadBooksFromRecents feature (set at End-of-Book, cleared if paged back in).
|
||||||
bool recentsEntryRemoved = false;
|
bool recentsEntryRemoved = false;
|
||||||
|
unsigned long bookmarkMessageTime = 0UL;
|
||||||
// Set when the reader is left at end-of-book and SETTINGS.moveFinishedToReadFolder is on.
|
// 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/.
|
// Consumed in onExit() to relocate the finished book into /Read/.
|
||||||
bool pendingReadFolderMove = false;
|
bool pendingReadFolderMove = false;
|
||||||
@@ -59,6 +63,7 @@ class EpubReaderActivity final : public Activity {
|
|||||||
void applyOrientation(uint8_t orientation);
|
void applyOrientation(uint8_t orientation);
|
||||||
void toggleAutoPageTurn(uint8_t selectedPageTurnOption);
|
void toggleAutoPageTurn(uint8_t selectedPageTurnOption);
|
||||||
void pageTurn(bool isForwardTurn);
|
void pageTurn(bool isForwardTurn);
|
||||||
|
void addBookmark();
|
||||||
|
|
||||||
// Footnote navigation
|
// Footnote navigation
|
||||||
void navigateToHref(const std::string& href, bool savePosition = false);
|
void navigateToHref(const std::string& href, bool savePosition = false);
|
||||||
@@ -73,4 +78,5 @@ class EpubReaderActivity final : public Activity {
|
|||||||
void render(RenderLock&& lock) override;
|
void render(RenderLock&& lock) override;
|
||||||
bool isReaderActivity() const override { return true; }
|
bool isReaderActivity() const override { return true; }
|
||||||
ScreenshotInfo getScreenshotInfo() const override;
|
ScreenshotInfo getScreenshotInfo() const override;
|
||||||
|
CrossPointPosition getCurrentPosition() const;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
#include "EpubReaderBookmarksActivity.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <HalStorage.h>
|
||||||
|
#include <I18n.h>
|
||||||
|
#include <JsonSettingsIO.h>
|
||||||
|
#include <util/BookmarkUtil.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#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<int>(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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <Epub.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include "../../BookmarkEntry.h"
|
||||||
|
#include "../Activity.h"
|
||||||
|
#include "util/ButtonNavigator.h"
|
||||||
|
|
||||||
|
class EpubReaderBookmarksActivity final : public Activity {
|
||||||
|
std::shared_ptr<Epub> epub;
|
||||||
|
std::string epubPath;
|
||||||
|
ButtonNavigator buttonNavigator;
|
||||||
|
int selectorIndex = 0;
|
||||||
|
std::vector<BookmarkEntry> 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>& 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);
|
||||||
|
};
|
||||||
@@ -21,11 +21,12 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInpu
|
|||||||
|
|
||||||
std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes) {
|
std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes) {
|
||||||
std::vector<MenuItem> items;
|
std::vector<MenuItem> items;
|
||||||
items.reserve(10);
|
items.reserve(11);
|
||||||
items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER});
|
items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER});
|
||||||
if (hasFootnotes) {
|
if (hasFootnotes) {
|
||||||
items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES});
|
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::ROTATE_SCREEN, StrId::STR_ORIENTATION});
|
||||||
items.push_back({MenuAction::AUTO_PAGE_TURN, StrId::STR_AUTO_TURN_PAGES_PER_MIN});
|
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});
|
items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class EpubReaderMenuActivity final : public Activity {
|
|||||||
GO_TO_PERCENT,
|
GO_TO_PERCENT,
|
||||||
AUTO_PAGE_TURN,
|
AUTO_PAGE_TURN,
|
||||||
ROTATE_SCREEN,
|
ROTATE_SCREEN,
|
||||||
|
BOOKMARKS,
|
||||||
SCREENSHOT,
|
SCREENSHOT,
|
||||||
DISPLAY_QR,
|
DISPLAY_QR,
|
||||||
GO_HOME,
|
GO_HOME,
|
||||||
|
|||||||
@@ -174,64 +174,10 @@ void KOReaderSyncActivity::performSync() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
KOReaderPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
|
SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
|
||||||
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, currentSpineIndex, totalPagesInSpine);
|
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<int>(*paragraphPage));
|
|
||||||
if (nextParagraphPage.has_value()) {
|
|
||||||
const int lutSpan = static_cast<int>(*nextParagraphPage) - static_cast<int>(*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<int>(*nextParagraphPage)) {
|
|
||||||
refinedPage = static_cast<int>(*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.
|
// localProgress was pre-computed in EpubReaderActivity before the Epub was released.
|
||||||
|
|
||||||
{
|
{
|
||||||
RenderLock lock(*this);
|
RenderLock lock(*this);
|
||||||
state = SHOWING_RESULT;
|
state = SHOWING_RESULT;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class KOReaderSyncActivity final : public Activity {
|
|||||||
public:
|
public:
|
||||||
explicit KOReaderSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& epubPath,
|
explicit KOReaderSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& epubPath,
|
||||||
int currentSpineIndex, int currentPage, int totalPagesInSpine,
|
int currentSpineIndex, int currentPage, int totalPagesInSpine,
|
||||||
KOReaderPosition localKoPos, std::string localChapterName,
|
SavedProgressPosition localKoPos, std::string localChapterName,
|
||||||
std::optional<uint16_t> currentParagraphIndex = std::nullopt)
|
std::optional<uint16_t> currentParagraphIndex = std::nullopt)
|
||||||
: Activity("KOReaderSync", renderer, mappedInput),
|
: Activity("KOReaderSync", renderer, mappedInput),
|
||||||
epubPath(epubPath),
|
epubPath(epubPath),
|
||||||
@@ -73,7 +73,7 @@ class KOReaderSyncActivity final : public Activity {
|
|||||||
CrossPointPosition remotePosition;
|
CrossPointPosition remotePosition;
|
||||||
|
|
||||||
// Local progress as KOReader format (pre-computed before Epub was released)
|
// Local progress as KOReader format (pre-computed before Epub was released)
|
||||||
KOReaderPosition localProgress;
|
SavedProgressPosition localProgress;
|
||||||
|
|
||||||
// Selection in result screen (0=Apply, 1=Upload)
|
// Selection in result screen (0=Apply, 1=Upload)
|
||||||
int selectedOption = 0;
|
int selectedOption = 0;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ namespace ReaderUtils {
|
|||||||
|
|
||||||
constexpr unsigned long GO_HOME_MS = 1000;
|
constexpr unsigned long GO_HOME_MS = 1000;
|
||||||
constexpr unsigned long SKIP_HOLD_MS = 700;
|
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) {
|
inline void applyOrientation(GfxRenderer& renderer, const uint8_t orientation) {
|
||||||
switch (orientation) {
|
switch (orientation) {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
// 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};
|
||||||
@@ -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,
|
void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
|
||||||
const std::function<std::string(int index)>& rowTitle,
|
const std::function<std::string(int index)>& rowTitle,
|
||||||
const std::function<std::string(int index)>& rowSubtitle,
|
const std::function<std::string(int index)>& rowSubtitle,
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ struct ThemeMetrics {
|
|||||||
int textFieldLineEndOffset;
|
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 };
|
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,
|
virtual void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
|
||||||
const char* btn4) const;
|
const char* btn4) const;
|
||||||
virtual void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) 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,
|
virtual void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
|
||||||
const std::function<std::string(int index)>& rowTitle,
|
const std::function<std::string(int index)>& rowTitle,
|
||||||
const std::function<std::string(int index)>& rowSubtitle = nullptr,
|
const std::function<std::string(int index)>& rowSubtitle = nullptr,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "components/icons/book.h"
|
#include "components/icons/book.h"
|
||||||
#include "components/icons/book24.h"
|
#include "components/icons/book24.h"
|
||||||
|
#include "components/icons/bookmark.h"
|
||||||
#include "components/icons/cover.h"
|
#include "components/icons/cover.h"
|
||||||
#include "components/icons/file24.h"
|
#include "components/icons/file24.h"
|
||||||
#include "components/icons/folder.h"
|
#include "components/icons/folder.h"
|
||||||
@@ -73,6 +74,8 @@ const uint8_t* iconForName(UIIcon icon, int size) {
|
|||||||
return WifiIcon;
|
return WifiIcon;
|
||||||
case UIIcon::Hotspot:
|
case UIIcon::Hotspot:
|
||||||
return HotspotIcon;
|
return HotspotIcon;
|
||||||
|
case UIIcon::Bookmark:
|
||||||
|
return BookmarkIcon;
|
||||||
default:
|
default:
|
||||||
return nullptr;
|
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);
|
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,
|
void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
|
||||||
const std::function<std::string(int index)>& rowTitle,
|
const std::function<std::string(int index)>& rowTitle,
|
||||||
const std::function<std::string(int index)>& rowSubtitle,
|
const std::function<std::string(int index)>& rowSubtitle,
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ class LyraTheme : public BaseTheme {
|
|||||||
const char* rightLabel = nullptr) const override;
|
const char* rightLabel = nullptr) const override;
|
||||||
void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs,
|
void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs,
|
||||||
bool selected) const override;
|
bool selected) const override;
|
||||||
|
int getListPageItems(int contentHeight, bool hasSubtitle) const override;
|
||||||
void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
|
void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
|
||||||
const std::function<std::string(int index)>& rowTitle,
|
const std::function<std::string(int index)>& rowTitle,
|
||||||
const std::function<std::string(int index)>& rowSubtitle,
|
const std::function<std::string(int index)>& rowSubtitle,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#include "BookmarkUtil.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
class BookmarkUtil {
|
||||||
|
public:
|
||||||
|
static std::string getBookmarksDir();
|
||||||
|
static std::string getBookmarkPath(const std::string& bookPath);
|
||||||
|
static std::string sanitizeBookmarkSummary(std::string summary);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user