Merge branch 'master' of https://github.com/jpirnay/crosspoint-reader into feat-rendering-perf
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Run formatter from repository root regardless of current directory.
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
# Capture the files already staged for commit so we only re-stage those
|
||||
# paths after formatting.
|
||||
staged_files=()
|
||||
while IFS= read -r -d '' file; do
|
||||
staged_files+=("${file}")
|
||||
done < <(git diff --cached --name-only -z --diff-filter=ACMR)
|
||||
|
||||
# Intentionally format all currently modified tracked C/C++ files.
|
||||
# The helper handles no-op cases and exits 0 when nothing matches.
|
||||
echo "Running clang-format fix before commit..."
|
||||
./bin/clang-format-fix
|
||||
|
||||
# Ensure formatting changes are included in the pending commit without
|
||||
# staging unrelated tracked modifications from other files in the
|
||||
# working tree.
|
||||
if ((${#staged_files[@]})); then
|
||||
git add -- "${staged_files[@]}"
|
||||
fi
|
||||
@@ -1,8 +1,7 @@
|
||||
name: Compile Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build-release:
|
||||
@@ -11,6 +10,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
@@ -31,13 +31,42 @@ jobs:
|
||||
- name: Patch min_chip_rev_full to 0
|
||||
run: python3 scripts/patch_min_chip_rev.py .pio/build/gh_release/firmware.bin
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Upload bootloader.bin to Release
|
||||
uses: actions/upload-release-asset@v1
|
||||
with:
|
||||
name: CrossPoint-${{ github.ref_name }}
|
||||
path: |
|
||||
.pio/build/gh_release/bootloader.bin
|
||||
.pio/build/gh_release/firmware.bin
|
||||
.pio/build/gh_release/firmware.elf
|
||||
.pio/build/gh_release/firmware.map
|
||||
.pio/build/gh_release/partitions.bin
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: .pio/build/gh_release/bootloader.bin
|
||||
asset_name: bootloader.bin
|
||||
asset_content_type: application/octet-stream
|
||||
|
||||
- name: Upload firmware.bin to Release
|
||||
uses: actions/upload-release-asset@v1
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: .pio/build/gh_release/firmware.bin
|
||||
asset_name: firmware.bin
|
||||
asset_content_type: application/octet-stream
|
||||
|
||||
- name: Upload firmware.elf to Release
|
||||
uses: actions/upload-release-asset@v1
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: .pio/build/gh_release/firmware.elf
|
||||
asset_name: firmware.elf
|
||||
asset_content_type: application/octet-stream
|
||||
|
||||
- name: Upload firmware.map to Release
|
||||
uses: actions/upload-release-asset@v1
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: .pio/build/gh_release/firmware.map
|
||||
asset_name: firmware.map
|
||||
asset_content_type: application/octet-stream
|
||||
|
||||
- name: Upload partitions.bin to Release
|
||||
uses: actions/upload-release-asset@v1
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: .pio/build/gh_release/partitions.bin
|
||||
asset_name: partitions.bin
|
||||
asset_content_type: application/octet-stream
|
||||
|
||||
@@ -21,6 +21,8 @@ Therefore this branch focuses on real fixes and real improvements while trying t
|
||||
- Weather information panel
|
||||
- Multiple under-the-hood performance improvements
|
||||
- Book information screen
|
||||
- Markdown-support
|
||||
- WiFi captive portal support
|
||||
- ...
|
||||
|
||||
# Choosing the _right_ reader...
|
||||
@@ -32,11 +34,11 @@ Last update: April, 12th, 2026
|
||||
| Reader | Visual appeal | Functionality | Formats | Pros | Cons | Custom fonts | CJK | Bluetooth |
|
||||
| ------------------------------------ | ------------- | ------------- | ------- | ---- | ---- | ------------ | --- | --- |
|
||||
| [Stock](https://www.xteink.com) | Okay | Reader | XTC, EPUB, TXT | Frequent official updates | Lot of rendering issues | Yes | Yes | Yes |
|
||||
| [THIS FORK: CrossPoint ++](https://github.com/jpirnay/crosspoint-reader) | Okay | Calibre Wireless support; Proper KOReader progress sync; Wi-Fi transfer; Book Info; Clock, Weather Info | EPUB, XTC, TXT | Faster integration of functionality | Small team | No | No | No |
|
||||
| [CrossPoint](https://github.com/crosspoint-reader/crosspoint-reader) | Okay | Calibre Wireless support; Limited KOReader progress sync; Wi-Fi transfer | EPUB, XTC, TXT | Biggest community | Least common denominator approach | No | No | No |
|
||||
| [THIS FORK: CrossPoint ++](https://github.com/jpirnay/crosspoint-reader) | Okay | Calibre Wireless support; Proper KOReader progress sync; Wi-Fi transfer; Book Info; Clock, Weather Info | EPUB, XTC, TXT, MD | Faster integration of functionality | Small team | No | No | No |
|
||||
| [CrossPoint](https://github.com/crosspoint-reader/crosspoint-reader) | Okay | Calibre Wireless support; KOReader progress sync; Wi-Fi transfer | EPUB, XTC, TXT | Biggest community | Least common denominator approach | No | No | No |
|
||||
| [CrossPet](https://github.com/trilwu/crosspet) | Excellent, Playful | Virtual pet motivator; mini-games | EPUB, TXT | A lot of additional apps | Higher battery drain, Small team, bloaty | Yes | Yes | Yes |
|
||||
| [Papyrix](https://github.com/bigbag/papyrix-reader) | Minimalist | Calibre Wireless support; exFAT support | EPUB, FB2, MD, TXT | A lot of good ideas | Small team | Yes | Yes | No |
|
||||
| [Inx](https://github.com/obijuankenobiii/inx) | Nice | mainly crosspoint functionality plus reading statistics | EPUB, XTC, TXT | Good reading stats | Small team | No | No | No |
|
||||
| [vCodex](https://github.com/franssjz/cpr-vcodex) | Nice | mainly crosspoint functionality plus reading statistics | EPUB, XTC, TXT | Good reading stats | Small team | No | No | No |
|
||||
| [PlusPoint](https://github.com/ngxson/pluspoint-reader) | Okay | Experimental | EPUB, TXT, JS Apps | Support for custom JS apps; better RTC | Based on older code, small team | Yes | Yes | No |
|
||||
| [SUMI](https://github.com/psychoplath9450/SUMI) | Nice | Lua custom apps; Bluetooth keyboard support for notes; Game Boy emulator; Bluetooth Transfer | EPUB, TXT, Markdown, XTC | Many apps, customizable, Papyrix fork | Single Contributor, **No Wi-Fi** | Yes | Yes | Yes |
|
||||
| [SUMI](https://github.com/psychoplath9450/SUMI) | Nice | Lua custom apps; Bluetooth keyboard support for notes; Game Boy emulator; Bluetooth Transfer | EPUB, TXT, XTC, MD | Many apps, customizable, Papyrix fork | Single Contributor, **No Wi-Fi** | Yes | Yes | Yes |
|
||||
|
||||
+27
-3
@@ -20,7 +20,8 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
||||
- [3.6.2 Reader](#362-reader)
|
||||
- [3.6.3 Controls](#363-controls)
|
||||
- [3.6.4 System](#364-system)
|
||||
- [3.6.5 KOReader Sync Quick Setup](#365-koreader-sync-quick-setup)
|
||||
- [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries)
|
||||
- [3.6.6 KOReader Sync Quick Setup](#366-koreader-sync-quick-setup)
|
||||
- [3.7 Sleep Screen](#37-sleep-screen)
|
||||
- [4. Reading Mode](#4-reading-mode)
|
||||
- [Page Turning](#page-turning)
|
||||
@@ -194,12 +195,35 @@ The Settings screen allows you to configure the device's behavior. There are a f
|
||||
|
||||
- **WiFi Networks**: Connect to WiFi networks for file transfers and firmware updates.
|
||||
- **KOReader Sync**: Options for setting up KOReader for syncing book progress.
|
||||
- **OPDS Browser**: Configure OPDS server settings for browsing and downloading books. Set the server URL (for Calibre Content Server, add `/opds` to the end), and optionally configure username and password for servers requiring authentication. Note: Only HTTP Basic authentication is supported. If using Calibre Content Server with authentication enabled, you must set it to use Basic authentication instead of the default Digest authentication.
|
||||
- **OPDS Servers**: Manage one or more OPDS libraries for browsing and downloading books. See [OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries) below.
|
||||
- **Clear Reading Cache**: Clear the internal SD card cache.
|
||||
- **Check for updates**: Check for Crosspoint firmware updates over WiFi.
|
||||
- **Language**: Set the system language (see **[Supported Languages](#supported-languages)** for more information).
|
||||
|
||||
#### 3.6.5 KOReader Sync Quick Setup
|
||||
#### 3.6.5 OPDS Servers (Multiple Libraries)
|
||||
|
||||
CrossPoint supports saving multiple OPDS servers and switching between them when browsing catalogs.
|
||||
|
||||
1. Open **Settings -> System -> OPDS Servers**.
|
||||
2. Select **Add Server** to create a new entry, or select an existing server to edit it.
|
||||
3. Configure these fields:
|
||||
- **Server Name**: Optional display name (for example, "Home Calibre" or "Public Catalog").
|
||||
- **OPDS Server URL**: Full catalog root URL (for Calibre Content Server, usually ends with `/opds`).
|
||||
- **Username / Password**: Optional credentials for authenticated servers.
|
||||
4. Use **Delete Server** inside a server entry to remove it.
|
||||
|
||||
Behavior notes:
|
||||
|
||||
- You can store up to 8 OPDS servers.
|
||||
- OPDS authentication supports HTTP Basic auth. If you use Calibre Content Server with authentication enabled, set it to Basic (not Digest).
|
||||
|
||||
You can also manage OPDS servers from the web interface while in File Transfer mode:
|
||||
|
||||
1. Connect to the device web UI.
|
||||
2. Open `http://<device-ip>/settings`.
|
||||
3. Use the **OPDS Servers** card to add, edit, or delete entries.
|
||||
|
||||
#### 3.6.6 KOReader Sync Quick Setup
|
||||
|
||||
CrossPoint can sync reading progress with KOReader-compatible sync servers.
|
||||
It also interoperates with KOReader apps/devices when they use the same server and credentials.
|
||||
|
||||
@@ -14,6 +14,7 @@ fi
|
||||
set -euo pipefail
|
||||
|
||||
GIT_LS_FILES_FLAGS=""
|
||||
# -g scopes formatting to tracked files currently modified in git status.
|
||||
if [[ "${1:-}" == "-g" ]]; then
|
||||
GIT_LS_FILES_FLAGS="--modified"
|
||||
fi
|
||||
@@ -21,6 +22,7 @@ fi
|
||||
CLANG_FORMAT_VERSION_RAW="$(${CLANG_FORMAT_BIN} --version)"
|
||||
CLANG_FORMAT_MAJOR="$(printf '%s\n' "${CLANG_FORMAT_VERSION_RAW}" | grep -oE '[0-9]+' | head -n1)"
|
||||
|
||||
# Guard against local binaries older than the repo formatting config.
|
||||
if [[ -z "${CLANG_FORMAT_MAJOR}" || "${CLANG_FORMAT_MAJOR}" -lt 21 ]]; then
|
||||
echo "Error: ${CLANG_FORMAT_BIN} is too old: ${CLANG_FORMAT_VERSION_RAW}"
|
||||
echo "This repository's .clang-format requires clang-format 21 or newer."
|
||||
@@ -37,9 +39,14 @@ fi
|
||||
# --exclude-standard: ignores files in .gitignore
|
||||
# Additionally exclude files in 'lib/EpdFont/builtinFonts/' as they are script-generated.
|
||||
# Also exclude files in 'lib/Epub/Epub/hyphenation/generated/' as they are script-generated.
|
||||
# Keep the no-match case non-fatal: grep returns 1 when no files match,
|
||||
# which is expected when there are no modified C/C++ files.
|
||||
set +o pipefail
|
||||
git ls-files --exclude-standard ${GIT_LS_FILES_FLAGS} \
|
||||
| grep -E '\.(c|cpp|h|hpp)$' \
|
||||
| grep -v -E '^lib/EpdFont/builtinFonts/' \
|
||||
| grep -v -E '^lib/Epub/Epub/hyphenation/generated/' \
|
||||
| grep -v -E '^lib/uzlib/' \
|
||||
| xargs -r "${CLANG_FORMAT_BIN}" -style=file -i
|
||||
# Restore strict pipeline failure handling for the rest of the script.
|
||||
set -o pipefail
|
||||
@@ -6,6 +6,7 @@ This page defines the expected local workflow before opening a pull request.
|
||||
|
||||
- Fork the repository to your own GitHub account
|
||||
- Clone your fork locally and add the upstream repository if needed
|
||||
- Enable repo hooks once per clone: `git config core.hooksPath .githooks && chmod +x .githooks/pre-commit`
|
||||
|
||||
- Branch from `master`
|
||||
- Keep each PR focused on one fix or feature area
|
||||
|
||||
@@ -53,6 +53,13 @@ If you already cloned without submodules:
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
Enable the repository-managed Git hooks (required once per clone):
|
||||
|
||||
```sh
|
||||
git config core.hooksPath .githooks
|
||||
chmod +x .githooks/pre-commit
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
|
||||
@@ -38,12 +38,16 @@ via a KOReader contributor mapping spine items to DocFragment numbers.
|
||||
Implemented in `ProgressMapper::toKOReader`.
|
||||
|
||||
1. Compute overall `percentage` from chapter/page.
|
||||
2. If a paragraph index is available from the section cache LUT (`CrossPointPosition::hasParagraphIndex`),
|
||||
generate an XPath directly: `/body/DocFragment[spineIndex + 1]/body/p[paragraphIndex]`.
|
||||
3. Otherwise, attempt byte-offset estimation via `ChapterXPathIndexer::findXPathForProgress`.
|
||||
4. If XPath extraction fails, fallback to synthetic chapter path:
|
||||
2. Generate XPath via byte-offset estimation (`ChapterXPathIndexer::findXPathForProgress`),
|
||||
producing a `…/text()[K].M` anchor proportional to intra-spine progress.
|
||||
3. If XPath extraction fails, fallback to synthetic chapter path:
|
||||
- `/body/DocFragment[spineIndex + 1]/body`
|
||||
|
||||
The paragraph LUT (see below) is intentionally **not** used for upload: snapping to the
|
||||
start of `p[N]` when the user is mid-paragraph causes pulled positions to land at the
|
||||
start of the paragraph (and at the start of the chapter when an opening paragraph spans
|
||||
many pages). The LUT remains in use for the reverse direction.
|
||||
|
||||
### KOReader -> CrossPoint
|
||||
|
||||
Implemented in `ProgressMapper::toCrossPoint`.
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#define FOOTNOTE_NUMBER_LEN 32
|
||||
#define FOOTNOTE_HREF_LEN 96
|
||||
|
||||
struct FootnoteEntry {
|
||||
char number[24];
|
||||
char href[64];
|
||||
char number[FOOTNOTE_NUMBER_LEN];
|
||||
char href[FOOTNOTE_HREF_LEN];
|
||||
|
||||
FootnoteEntry() {
|
||||
number[0] = '\0';
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#include "parsers/ChapterHtmlSlimParser.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 21;
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 22;
|
||||
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + // SECTION_FILE_VERSION
|
||||
sizeof(int) + // fontId
|
||||
sizeof(float) + // lineCompression
|
||||
|
||||
@@ -170,6 +170,17 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
|
||||
nextWordContinues = false;
|
||||
}
|
||||
|
||||
// Emit the current page, keeping paragraphLutPerPage and completedPageCount in lockstep.
|
||||
// Callers must ensure currentPage is non-null and carries content; the helper resets
|
||||
// currentPage to a fresh Page and zeroes currentPageNextY so the caller can keep building.
|
||||
void ChapterHtmlSlimParser::emitPage(uint32_t xhtmlByteOffset) {
|
||||
paragraphLutPerPage.push_back({xhtmlByteOffset, xpathParagraphIndex});
|
||||
completePageFn(std::move(currentPage));
|
||||
completedPageCount++;
|
||||
currentPage.reset(new Page());
|
||||
currentPageNextY = 0;
|
||||
}
|
||||
|
||||
// start a new text block if needed
|
||||
void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
|
||||
nextWordContinues = false; // New block = new paragraph, no continuation
|
||||
@@ -198,10 +209,7 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
|
||||
if (!pendingAnchorId.empty()) {
|
||||
if (std::find(tocAnchors.begin(), tocAnchors.end(), pendingAnchorId) != tocAnchors.end()) {
|
||||
if (currentPage && !currentPage->elements.empty()) {
|
||||
completePageFn(std::move(currentPage));
|
||||
completedPageCount++;
|
||||
currentPage.reset(new Page());
|
||||
currentPageNextY = 0;
|
||||
emitPage(lastBodyChildByteOffset);
|
||||
}
|
||||
}
|
||||
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
|
||||
@@ -218,10 +226,7 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
|
||||
if (!pendingAnchorId.empty() &&
|
||||
std::find(tocAnchors.begin(), tocAnchors.end(), pendingAnchorId) != tocAnchors.end()) {
|
||||
if (currentPage && !currentPage->elements.empty()) {
|
||||
completePageFn(std::move(currentPage));
|
||||
completedPageCount++;
|
||||
currentPage.reset(new Page());
|
||||
currentPageNextY = 0;
|
||||
emitPage(lastBodyChildByteOffset);
|
||||
}
|
||||
}
|
||||
// Record deferred anchor after previous block is flushed (and any TOC page break)
|
||||
@@ -593,15 +598,11 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
(self->currentPageNextY + totalImageHeightWithSpacing > self->viewportHeight)) {
|
||||
LOG_DBG("EHP", "Image page break: currentY=%d needed=%d viewportH=%d", self->currentPageNextY,
|
||||
totalImageHeightWithSpacing, self->viewportHeight);
|
||||
self->paragraphLutPerPage.push_back({self->lastBodyChildByteOffset, self->xpathParagraphIndex});
|
||||
self->completePageFn(std::move(self->currentPage));
|
||||
self->completedPageCount++;
|
||||
self->currentPage.reset(new Page());
|
||||
self->emitPage(self->lastBodyChildByteOffset);
|
||||
if (!self->currentPage) {
|
||||
LOG_ERR("EHP", "Failed to create new page");
|
||||
return;
|
||||
}
|
||||
self->currentPageNextY = 0;
|
||||
} else if (!self->currentPage) {
|
||||
self->currentPage.reset(new Page());
|
||||
if (!self->currentPage) {
|
||||
@@ -742,9 +743,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
}
|
||||
self->insideFootnoteLink = true;
|
||||
self->footnoteLinkDepth = self->depth;
|
||||
strncpy(self->currentFootnoteLinkHref, href, sizeof(self->currentFootnoteLinkHref) - 1);
|
||||
self->currentFootnoteLinkHref[sizeof(self->currentFootnoteLinkHref) - 1] = '\0';
|
||||
self->currentFootnoteLinkText[0] = '\0';
|
||||
strncpy(self->currentFootnote.href, href, sizeof(self->currentFootnote.href) - 1);
|
||||
self->currentFootnote.href[sizeof(self->currentFootnote.href) - 1] = '\0';
|
||||
self->currentFootnote.number[0] = '\0';
|
||||
self->currentFootnoteLinkTextLen = 0;
|
||||
|
||||
// Apply underline style to visually indicate the link
|
||||
@@ -985,11 +986,11 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
|
||||
}
|
||||
|
||||
// Extract footnote link text
|
||||
for (int i = start; (self->currentFootnoteLinkTextLen < sizeof(self->currentFootnoteLinkText) - 1) && (i <= end);
|
||||
for (int i = start; (self->currentFootnoteLinkTextLen < sizeof(self->currentFootnote.number) - 1) && (i <= end);
|
||||
++i) {
|
||||
self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen++] = s[i];
|
||||
self->currentFootnote.number[self->currentFootnoteLinkTextLen++] = s[i];
|
||||
}
|
||||
self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen] = '\0';
|
||||
self->currentFootnote.number[self->currentFootnoteLinkTextLen] = '\0';
|
||||
}
|
||||
|
||||
for (int i = 0; i < len; i++) {
|
||||
@@ -1220,11 +1221,11 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
||||
|
||||
// Closing a footnote link — create entry from collected text and href
|
||||
if (self->insideFootnoteLink && self->depth == self->footnoteLinkDepth) {
|
||||
if (self->currentFootnoteLinkText[0] != '\0' && self->currentFootnoteLinkHref[0] != '\0') {
|
||||
if (self->currentFootnote.number[0] != '\0' && self->currentFootnote.href[0] != '\0') {
|
||||
FootnoteEntry entry;
|
||||
strncpy(entry.number, self->currentFootnoteLinkText, sizeof(entry.number) - 1);
|
||||
strncpy(entry.number, self->currentFootnote.number, sizeof(entry.number) - 1);
|
||||
entry.number[sizeof(entry.number) - 1] = '\0';
|
||||
strncpy(entry.href, self->currentFootnoteLinkHref, sizeof(entry.href) - 1);
|
||||
strncpy(entry.href, self->currentFootnote.href, sizeof(entry.href) - 1);
|
||||
entry.href[sizeof(entry.href) - 1] = '\0';
|
||||
int wordIndex =
|
||||
self->wordsExtractedInBlock + (self->currentTextBlock ? static_cast<int>(self->currentTextBlock->size()) : 0);
|
||||
@@ -1423,9 +1424,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
|
||||
pendingAnchorId.clear();
|
||||
}
|
||||
paragraphLutPerPage.push_back({0u, xpathParagraphIndex}); // post-parse: no byte offset available
|
||||
completePageFn(std::move(currentPage));
|
||||
completedPageCount++;
|
||||
emitPage(0u); // post-parse: no byte offset available
|
||||
currentPage.reset();
|
||||
currentTextBlock.reset();
|
||||
}
|
||||
@@ -1444,11 +1443,7 @@ ParsedText::LineProcessResult ChapterHtmlSlimParser::addLineToPage(std::shared_p
|
||||
}
|
||||
|
||||
if (currentPageNextY + lineHeight > viewportHeight) {
|
||||
paragraphLutPerPage.push_back({lastBodyChildByteOffset, xpathParagraphIndex});
|
||||
completePageFn(std::move(currentPage));
|
||||
completedPageCount++;
|
||||
currentPage.reset(new Page());
|
||||
currentPageNextY = 0;
|
||||
emitPage(lastBodyChildByteOffset);
|
||||
}
|
||||
|
||||
const bool noRoomForAnotherLine =
|
||||
|
||||
@@ -113,9 +113,8 @@ class ChapterHtmlSlimParser {
|
||||
// Footnote link tracking
|
||||
bool insideFootnoteLink = false;
|
||||
int footnoteLinkDepth = -1;
|
||||
char currentFootnoteLinkText[24] = {};
|
||||
FootnoteEntry currentFootnote = {};
|
||||
int currentFootnoteLinkTextLen = 0;
|
||||
char currentFootnoteLinkHref[64] = {};
|
||||
std::vector<std::pair<int, FootnoteEntry>> pendingFootnotes; // <wordIndex, entry>
|
||||
int wordsExtractedInBlock = 0;
|
||||
|
||||
@@ -128,6 +127,10 @@ class ChapterHtmlSlimParser {
|
||||
void startNewTextBlock(const BlockStyle& blockStyle);
|
||||
void flushPartWordBuffer();
|
||||
void makePages();
|
||||
// Emit currentPage to the consumer while keeping paragraphLutPerPage and completedPageCount
|
||||
// in lockstep. Every page break MUST go through this helper; open-coded completePageFn
|
||||
// calls risk desynchronising paragraphLutPerPage and failing the size check in Section.cpp.
|
||||
void emitPage(uint32_t xhtmlByteOffset);
|
||||
// XML callbacks
|
||||
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
|
||||
static void XMLCALL characterData(void* userData, const XML_Char* s, int len);
|
||||
|
||||
@@ -152,6 +152,7 @@ STR_DST_ACTIVE: "DST: active"
|
||||
STR_DST_INACTIVE: "DST: inactive"
|
||||
STR_DST_UNKNOWN: "DST: unknown"
|
||||
STR_REFRESH_FREQ: "Refresh Frequency"
|
||||
STR_REFRESH_AFTER_IMAGE_PAGES: "Refresh after image pages"
|
||||
STR_KOREADER_SYNC: "KOReader Sync"
|
||||
STR_CHECK_UPDATES: "Check for updates"
|
||||
STR_LANGUAGE: "Language"
|
||||
@@ -380,6 +381,12 @@ STR_FOOTNOTES: "Footnotes"
|
||||
STR_NO_FOOTNOTES: "No footnotes on this page"
|
||||
STR_LINK: "[link]"
|
||||
STR_SCREENSHOT_BUTTON: "Take screenshot"
|
||||
STR_ADD_SERVER: "Add Server"
|
||||
STR_SERVER_NAME: "Server Name"
|
||||
STR_NO_SERVERS: "No OPDS servers configured"
|
||||
STR_DELETE_SERVER: "Delete Server"
|
||||
STR_DELETE_CONFIRM: "Delete this server?"
|
||||
STR_OPDS_SERVERS: "OPDS Servers"
|
||||
STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)"
|
||||
STR_WEATHER: "Weather"
|
||||
@@ -511,6 +518,7 @@ STR_IMAGE_DISPLAY_GRAYSCALE: ">> Gray"
|
||||
STR_WEATHER_MOON_INFO: "Moon"
|
||||
STR_WEATHER_SUN_INFO: "Sun"
|
||||
STR_READER_BOOKMARKS: "Bookmarks & Footnotes"
|
||||
STR_READER_OVERRIDES: "Book-specific overrides"
|
||||
STR_READER_UTILS: "Helper"
|
||||
STR_READER_TOOLS: "Tools"
|
||||
STR_READER_NAVIGATION: "Navigation"
|
||||
|
||||
@@ -318,7 +318,7 @@ STR_UPLOAD: "Carica"
|
||||
STR_BOOK_S_STYLE: "Stile libro"
|
||||
STR_EMBEDDED_STYLE: "Stile integrato dell'epub"
|
||||
STR_OPDS_SERVER_URL: "Server OPDS"
|
||||
STR_FOOTNOTES: "Note piè pagina"
|
||||
STR_FOOTNOTES: "Note a piè pagina"
|
||||
STR_NO_FOOTNOTES: "Nessuna nota in questa pagina"
|
||||
STR_LINK: "[link]"
|
||||
STR_SCREENSHOT_BUTTON: "Screenshot"
|
||||
|
||||
@@ -316,8 +316,8 @@ STR_FOOTNOTES: "Pie de página"
|
||||
STR_NO_FOOTNOTES: "No hay notas al pie de esta página"
|
||||
STR_LINK: "[enlace]"
|
||||
STR_SCREENSHOT_BUTTON: "Tomar captura de pantalla"
|
||||
STR_AUTO_TURN_ENABLED: "Paso automático de páginas: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Paso automático de páginas (pág./min.)"
|
||||
STR_AUTO_TURN_ENABLED: "Avance activado: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Avance auto. (pág./min)"
|
||||
STR_REGISTER: "Registrarse"
|
||||
STR_REGISTERING: "Registrando..."
|
||||
STR_REGISTER_SUCCESS: "¡Cuenta creada correctamente!"
|
||||
|
||||
@@ -2,7 +2,7 @@ _language_name: "Svenska"
|
||||
_language_code: "SV"
|
||||
_order: "7"
|
||||
|
||||
STR_CROSSPOINT: "Crosspoint"
|
||||
STR_CROSSPOINT: "CrossPoint"
|
||||
STR_BOOTING: "STARTAR"
|
||||
STR_SLEEPING: "VILA"
|
||||
STR_ENTERING_SLEEP: "Går i vila"
|
||||
@@ -135,7 +135,7 @@ STR_PREV_NEXT: "Förra/Nästa"
|
||||
STR_NEXT_PREV: "Nästa/Förra"
|
||||
STR_BOOKERLY: "Bookerly"
|
||||
STR_NOTO_SANS: "Noto Sans"
|
||||
STR_OPEN_DYSLEXIC: "Öppen dyslektisk"
|
||||
STR_OPEN_DYSLEXIC: "Öppen Dyslexic"
|
||||
STR_SMALL: "Liten"
|
||||
STR_MEDIUM: "Medium"
|
||||
STR_LARGE: "Stor"
|
||||
@@ -290,3 +290,18 @@ STR_LINK: "[länk]"
|
||||
STR_SCREENSHOT_BUTTON: "Ta en skärmdump"
|
||||
STR_AUTO_TURN_ENABLED: "Automatisk vändning aktiverad: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vändning (sidor per minut)"
|
||||
STR_KB_HINT_MOVE_CURSOR: "Tryck VÄNSTER eller HÖGER för att flytta markören"
|
||||
STR_KB_HINT_RETURN_CURSOR: "Tryck VÄNSTER för att återgå till markörpositionen"
|
||||
STR_KB_HINT_HIDE_PASSWORD: "Håll HÖGER och tryck sedan på [***] för att dölja lösenordet"
|
||||
STR_KB_HINT_SHOW_PASSWORD: "Håll HÖGER och tryck sedan på [abc] för att visa lösenordet"
|
||||
STR_KB_HINT_TOGGLE_HIDE_PASSWORD: "Tryck på [***] för att dölja lösenordet"
|
||||
STR_KB_HINT_TOGGLE_SHOW_PASSWORD: "Tryck på [abc] för att visa lösenordet"
|
||||
STR_KB_HINT_EDIT_ENTRY: "Håll UPP för att redigera fältet"
|
||||
STR_KB_TIPS: "Tips:"
|
||||
STR_KB_HINT_RETURN_KEYBOARD: "Tryck NER för att återgå till tangentbordet"
|
||||
STR_KB_HINT_EXIT_URL_MODE: "Tryck på ABC för att avsluta URL-läget"
|
||||
STR_KB_HINT_CLEAR_TEXT: "Håll DEL för att rensa all text"
|
||||
STR_KB_HINT_SECONDARY_CHAR: "Håll VÄLJ för sekundärt tecken"
|
||||
STR_KB_HINT_UPPER_SECONDARY: "Håll VÄLJ för VERSALER eller sekundärt tecken"
|
||||
STR_KB_HINT_LOWER_SECONDARY: "Håll VÄLJ för gemener eller sekundärt tecken"
|
||||
STR_KB_HINT_URL_SNIPPETS: "Tryck på URL för URL-fragment"
|
||||
@@ -247,6 +247,15 @@ void resetSessionClientForRetry() {
|
||||
}
|
||||
}
|
||||
|
||||
void applyAuthHeaders(esp_http_client_handle_t client) {
|
||||
esp_http_client_set_header(client, "Accept", "application/vnd.koreader.v1+json");
|
||||
esp_http_client_set_header(client, "x-auth-user", KOREADER_STORE.getUsername().c_str());
|
||||
esp_http_client_set_header(client, "x-auth-key", KOREADER_STORE.getMd5Password().c_str());
|
||||
|
||||
std::string credentials = KOREADER_STORE.getUsername() + ":" + KOREADER_STORE.getPassword();
|
||||
esp_http_client_set_header(client, "Authorization", ("Basic " + base64Encode(credentials)).c_str());
|
||||
}
|
||||
|
||||
// Create configured esp_http_client with small TLS buffers
|
||||
esp_http_client_handle_t createClient(const char* url, ResponseBuffer* buf,
|
||||
esp_http_client_method_t method = HTTP_METHOD_GET) {
|
||||
@@ -256,14 +265,7 @@ esp_http_client_handle_t createClient(const char* url, ResponseBuffer* buf,
|
||||
esp_http_client_set_url(g_sessionClient, url);
|
||||
esp_http_client_set_method(g_sessionClient, method);
|
||||
|
||||
// KOSync auth headers
|
||||
esp_http_client_set_header(g_sessionClient, "Accept", "application/vnd.koreader.v1+json");
|
||||
esp_http_client_set_header(g_sessionClient, "x-auth-user", KOREADER_STORE.getUsername().c_str());
|
||||
esp_http_client_set_header(g_sessionClient, "x-auth-key", KOREADER_STORE.getMd5Password().c_str());
|
||||
|
||||
std::string credentials = KOREADER_STORE.getUsername() + ":" + KOREADER_STORE.getPassword();
|
||||
std::string authHeader = "Basic " + base64Encode(credentials);
|
||||
esp_http_client_set_header(g_sessionClient, "Authorization", authHeader.c_str());
|
||||
applyAuthHeaders(g_sessionClient);
|
||||
return g_sessionClient;
|
||||
}
|
||||
|
||||
@@ -285,15 +287,7 @@ esp_http_client_handle_t createClient(const char* url, ResponseBuffer* buf,
|
||||
esp_http_client_handle_t client = esp_http_client_init(&config);
|
||||
if (!client) return nullptr;
|
||||
|
||||
// KOSync auth headers
|
||||
esp_http_client_set_header(client, "Accept", "application/vnd.koreader.v1+json");
|
||||
esp_http_client_set_header(client, "x-auth-user", KOREADER_STORE.getUsername().c_str());
|
||||
esp_http_client_set_header(client, "x-auth-key", KOREADER_STORE.getMd5Password().c_str());
|
||||
|
||||
// HTTP Basic Auth for Calibre-Web-Automated compatibility
|
||||
std::string credentials = KOREADER_STORE.getUsername() + ":" + KOREADER_STORE.getPassword();
|
||||
std::string authHeader = "Basic " + base64Encode(credentials);
|
||||
esp_http_client_set_header(client, "Authorization", authHeader.c_str());
|
||||
applyAuthHeaders(client);
|
||||
|
||||
if (g_keepSessionOpen) {
|
||||
g_sessionClient = client;
|
||||
@@ -303,6 +297,13 @@ esp_http_client_handle_t createClient(const char* url, ResponseBuffer* buf,
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// Returns true if credentials are present; logs and returns false otherwise.
|
||||
static inline bool hasCredentials() {
|
||||
if (KOREADER_STORE.hasCredentials()) return true;
|
||||
LOG_INF("KOSync", "No credentials configured");
|
||||
return false;
|
||||
}
|
||||
|
||||
void KOReaderSyncClient::beginPersistentSession() {
|
||||
g_keepSessionOpen = true;
|
||||
clearResponseBuffer(&g_sessionResponseBuf);
|
||||
@@ -318,16 +319,13 @@ void KOReaderSyncClient::endPersistentSession() {
|
||||
}
|
||||
|
||||
KOReaderSyncClient::Error KOReaderSyncClient::registerUser() {
|
||||
if (!KOREADER_STORE.hasCredentials()) {
|
||||
LOG_DBG("KOSync", "No credentials configured");
|
||||
return NO_CREDENTIALS;
|
||||
}
|
||||
if (!hasCredentials()) return NO_CREDENTIALS;
|
||||
|
||||
beginRequest("register");
|
||||
if (!checkHeapForTls()) return NETWORK_ERROR;
|
||||
|
||||
std::string url = KOREADER_STORE.getBaseUrl() + "/users/create";
|
||||
LOG_DBG("KOSync", "Registering user: %s (heap: %u, contig: %u)", url.c_str(), lastHeapAtFailure,
|
||||
LOG_INF("KOSync", "Registering user: %s (heap: %u, contig: %u)", url.c_str(), lastHeapAtFailure,
|
||||
lastContigHeapAtFailure);
|
||||
|
||||
JsonDocument doc;
|
||||
@@ -390,10 +388,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::registerUser() {
|
||||
}
|
||||
|
||||
KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
|
||||
if (!KOREADER_STORE.hasCredentials()) {
|
||||
LOG_DBG("KOSync", "No credentials configured");
|
||||
return NO_CREDENTIALS;
|
||||
}
|
||||
if (!hasCredentials()) return NO_CREDENTIALS;
|
||||
|
||||
beginRequest("auth");
|
||||
if (!checkHeapForTls()) return NETWORK_ERROR;
|
||||
@@ -436,10 +431,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
|
||||
|
||||
KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash,
|
||||
KOReaderProgress& outProgress) {
|
||||
if (!KOREADER_STORE.hasCredentials()) {
|
||||
LOG_DBG("KOSync", "No credentials configured");
|
||||
return NO_CREDENTIALS;
|
||||
}
|
||||
if (!hasCredentials()) return NO_CREDENTIALS;
|
||||
|
||||
beginRequest("get progress");
|
||||
if (!checkHeapForTls()) return NETWORK_ERROR;
|
||||
@@ -522,7 +514,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
|
||||
if (doc["progress"].isNull()) {
|
||||
std::string jsonDump;
|
||||
serializeJson(doc, jsonDump);
|
||||
LOG_DBG("KOSync", "Empty progress payload — treating as not found | payload=%s", jsonDump.c_str());
|
||||
LOG_INF("KOSync", "Empty progress payload — treating as not found | payload=%s", jsonDump.c_str());
|
||||
return NOT_FOUND;
|
||||
}
|
||||
|
||||
@@ -533,23 +525,20 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
|
||||
outProgress.deviceId = doc["device_id"].as<std::string>();
|
||||
outProgress.timestamp = doc["timestamp"].as<int64_t>();
|
||||
|
||||
LOG_DBG("KOSync", "Got progress: %.2f%% at %s", outProgress.percentage * 100, outProgress.progress.c_str());
|
||||
LOG_INF("KOSync", "Got progress: %.2f%% at %s", outProgress.percentage * 100, outProgress.progress.c_str());
|
||||
return OK;
|
||||
}
|
||||
|
||||
if (httpCode == 401) return AUTH_FAILED;
|
||||
if (httpCode == 404) {
|
||||
LOG_DBG("KOSync", "GET progress returned 404 for %s - treating as NOT_FOUND", url.c_str());
|
||||
LOG_INF("KOSync", "GET progress returned 404 for %s - treating as NOT_FOUND", url.c_str());
|
||||
return NOT_FOUND;
|
||||
}
|
||||
return SERVER_ERROR;
|
||||
}
|
||||
|
||||
KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgress& progress) {
|
||||
if (!KOREADER_STORE.hasCredentials()) {
|
||||
LOG_DBG("KOSync", "No credentials configured");
|
||||
return NO_CREDENTIALS;
|
||||
}
|
||||
if (!hasCredentials()) return NO_CREDENTIALS;
|
||||
|
||||
beginRequest("update progress");
|
||||
if (!checkHeapForTls()) return NETWORK_ERROR;
|
||||
@@ -569,7 +558,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr
|
||||
std::string body;
|
||||
serializeJson(doc, body);
|
||||
|
||||
LOG_DBG("KOSync", "Request body: %s", body.c_str());
|
||||
LOG_INF("KOSync", "Request body: %s", body.c_str());
|
||||
|
||||
ResponseBuffer buf;
|
||||
ResponseBuffer* activeBuf = effectiveResponseBuffer(&buf);
|
||||
|
||||
@@ -57,24 +57,11 @@ KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, c
|
||||
// Calculate overall book progress (0.0-1.0)
|
||||
result.percentage = epub->calculateProgress(pos.spineIndex, intraSpineProgress);
|
||||
|
||||
// Generate XPath for the current position.
|
||||
// When we have a paragraph index from the section LUT, target that specific <p> element
|
||||
// directly — this produces a structurally precise full-ancestry path even for chapters
|
||||
// where paragraphs are nested inside divs/sections. Fall back to the progress-based
|
||||
// scan (which works for any content) when no paragraph index is available.
|
||||
if (pos.hasParagraphIndex && pos.paragraphIndex > 0) {
|
||||
// When a seek hint is set, the LUT entry's paragraphIndex equals pos.paragraphIndex
|
||||
// (both describe the same page). The byte offset now points at the body-child element
|
||||
// that was current at the page break, so re-parsing from there will re-encounter that
|
||||
// paragraph — seed startParagraphCount with paragraphIndex-1 to avoid double counting.
|
||||
const uint16_t startCount =
|
||||
pos.xhtmlSeekHint > 0 && pos.paragraphIndex > 0 ? static_cast<uint16_t>(pos.paragraphIndex - 1) : 0;
|
||||
result.xpath = ChapterXPathIndexer::findXPathForParagraph(epub, pos.spineIndex, pos.paragraphIndex,
|
||||
pos.xhtmlSeekHint, startCount);
|
||||
}
|
||||
if (result.xpath.empty()) {
|
||||
result.xpath = ChapterXPathIndexer::findXPathForProgress(epub, pos.spineIndex, intraSpineProgress);
|
||||
}
|
||||
// Generate XPath for the current position via byte-offset scan. Targeting the
|
||||
// paragraph LUT entry instead would snap to the start of the paragraph the user
|
||||
// is inside, which causes pulled positions to land at the start of the chapter
|
||||
// when an opening paragraph spans many pages.
|
||||
result.xpath = ChapterXPathIndexer::findXPathForProgress(epub, pos.spineIndex, intraSpineProgress);
|
||||
if (result.xpath.empty()) {
|
||||
result.xpath = generateXPath(pos.spineIndex);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
#include "MdParser.h"
|
||||
|
||||
#include <cctype>
|
||||
|
||||
namespace MdParser {
|
||||
|
||||
static EpdFontFamily::Style combineFlags(bool bold, bool italic) {
|
||||
if (bold && italic) return EpdFontFamily::BOLD_ITALIC;
|
||||
if (bold) return EpdFontFamily::BOLD;
|
||||
if (italic) return EpdFontFamily::ITALIC;
|
||||
return EpdFontFamily::REGULAR;
|
||||
}
|
||||
|
||||
static constexpr int TAB_WIDTH = 4;
|
||||
|
||||
static std::string trimLeft(const std::string& s) {
|
||||
size_t i = 0;
|
||||
while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) i++;
|
||||
return s.substr(i);
|
||||
}
|
||||
|
||||
static uint8_t parseListIndentLevel(size_t leadingSpaces) {
|
||||
// Top-level list markers may be preceded by up to 3 spaces.
|
||||
// Nested list items require at least 4 spaces before the marker.
|
||||
if (leadingSpaces < TAB_WIDTH) {
|
||||
return 0;
|
||||
}
|
||||
return static_cast<uint8_t>((leadingSpaces - TAB_WIDTH) / TAB_WIDTH + 1);
|
||||
}
|
||||
|
||||
static bool isWordChar(char c) { return std::isalnum(static_cast<unsigned char>(c)) || c == '_'; }
|
||||
|
||||
static bool isUnderscoreEmphasis(const std::string& text, size_t pos, size_t count) {
|
||||
if (pos == 0 || pos + count >= text.size()) {
|
||||
return true;
|
||||
}
|
||||
const char before = text[pos - 1];
|
||||
const char after = text[pos + count];
|
||||
return !(isWordChar(before) && isWordChar(after));
|
||||
}
|
||||
|
||||
static bool isHorizontalRuleLine(const std::string& line) {
|
||||
// Must be at least 3 chars of the same marker (-, *, _) with optional spaces
|
||||
if (line.size() < 3) return false;
|
||||
|
||||
char marker = 0;
|
||||
int count = 0;
|
||||
for (char c : line) {
|
||||
if (c == ' ' || c == '\t') continue;
|
||||
if (c == '-' || c == '*' || c == '_') {
|
||||
if (marker == 0) marker = c;
|
||||
if (c != marker) return false;
|
||||
count++;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return count >= 3;
|
||||
}
|
||||
|
||||
std::vector<Span> parseInline(const std::string& text) {
|
||||
std::vector<Span> spans;
|
||||
std::string current;
|
||||
bool bold = false;
|
||||
bool italic = false;
|
||||
size_t i = 0;
|
||||
|
||||
auto emitSpan = [&]() {
|
||||
if (!current.empty()) {
|
||||
spans.push_back({std::move(current), combineFlags(bold, italic)});
|
||||
current.clear();
|
||||
}
|
||||
};
|
||||
|
||||
char boldMarker = 0;
|
||||
char italicMarker = 0;
|
||||
|
||||
while (i < text.size()) {
|
||||
char c = text[i];
|
||||
|
||||
// Escaped character
|
||||
if (c == '\\' && i + 1 < text.size()) {
|
||||
char next = text[i + 1];
|
||||
if (next == '*' || next == '_' || next == '`' || next == '[' || next == '!' || next == '\\') {
|
||||
current += next;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// *** or ___ — toggle both bold and italic when marker matches the current open markers
|
||||
if ((c == '*' || c == '_') && i + 2 < text.size() && text[i + 1] == c && text[i + 2] == c) {
|
||||
if (c == '_' && !isUnderscoreEmphasis(text, i, 3)) {
|
||||
current.append(3, c);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
emitSpan();
|
||||
if (bold && italic && boldMarker == c && italicMarker == c) {
|
||||
bold = false;
|
||||
italic = false;
|
||||
boldMarker = 0;
|
||||
italicMarker = 0;
|
||||
} else {
|
||||
bold = true;
|
||||
italic = true;
|
||||
boldMarker = c;
|
||||
italicMarker = c;
|
||||
}
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ** or __ — toggle bold
|
||||
if ((c == '*' || c == '_') && i + 1 < text.size() && text[i + 1] == c) {
|
||||
if (c == '_' && !isUnderscoreEmphasis(text, i, 2)) {
|
||||
current.append(2, c);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
emitSpan();
|
||||
if (bold && boldMarker == c) {
|
||||
bold = false;
|
||||
boldMarker = 0;
|
||||
} else {
|
||||
bold = true;
|
||||
boldMarker = c;
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// * or _ — toggle italic
|
||||
if (c == '*' || c == '_') {
|
||||
if (c == '_' && !isUnderscoreEmphasis(text, i, 1)) {
|
||||
current.push_back(c);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
emitSpan();
|
||||
if (italic && italicMarker == c) {
|
||||
italic = false;
|
||||
italicMarker = 0;
|
||||
} else {
|
||||
italic = true;
|
||||
italicMarker = c;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Backtick code span — strip backticks, render as regular
|
||||
if (c == '`') {
|
||||
size_t end = text.find('`', i + 1);
|
||||
if (end != std::string::npos) {
|
||||
emitSpan();
|
||||
spans.push_back({text.substr(i + 1, end - i - 1), EpdFontFamily::REGULAR});
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
current += c;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Image  — show [alt]
|
||||
if (c == '!' && i + 1 < text.size() && text[i + 1] == '[') {
|
||||
size_t closeBracket = text.find(']', i + 2);
|
||||
if (closeBracket != std::string::npos && closeBracket + 1 < text.size() && text[closeBracket + 1] == '(') {
|
||||
size_t closeParen = text.find(')', closeBracket + 2);
|
||||
if (closeParen != std::string::npos) {
|
||||
std::string alt = text.substr(i + 2, closeBracket - i - 2);
|
||||
current += "[";
|
||||
current += alt;
|
||||
current += "]";
|
||||
i = closeParen + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
current += c;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Link [text](url) — show text only
|
||||
if (c == '[') {
|
||||
size_t closeBracket = text.find(']', i + 1);
|
||||
if (closeBracket != std::string::npos && closeBracket + 1 < text.size() && text[closeBracket + 1] == '(') {
|
||||
size_t closeParen = text.find(')', closeBracket + 2);
|
||||
if (closeParen != std::string::npos) {
|
||||
current += text.substr(i + 1, closeBracket - i - 1);
|
||||
i = closeParen + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
current += c;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
current += c;
|
||||
i++;
|
||||
}
|
||||
|
||||
emitSpan();
|
||||
|
||||
// If bold/italic were left open, the text had unmatched markers.
|
||||
// The spans are still usable — the trailing text just keeps the toggled style.
|
||||
return spans;
|
||||
}
|
||||
|
||||
bool isCodeFence(const std::string& line) {
|
||||
auto trimmed = trimLeft(line);
|
||||
if (trimmed.size() < 3) return false;
|
||||
// Must start with ``` (with optional language tag after)
|
||||
if (trimmed[0] == '`' && trimmed[1] == '`' && trimmed[2] == '`') return true;
|
||||
// Also support ~~~ fences
|
||||
if (trimmed[0] == '~' && trimmed[1] == '~' && trimmed[2] == '~') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Detect task list checkbox at start of list content, update prefix accordingly.
|
||||
// Returns content with the checkbox marker stripped.
|
||||
static std::string handleTaskList(const std::string& content, std::string& listPrefix) {
|
||||
if (content.size() >= 3 && content[0] == '[' && content[2] == ']') {
|
||||
char mark = content[1];
|
||||
if (mark == 'x' || mark == 'X') {
|
||||
listPrefix = "☑ ";
|
||||
} else if (mark == ' ') {
|
||||
listPrefix = "☐ ";
|
||||
} else {
|
||||
return content; // Not a checkbox — keep content as-is
|
||||
}
|
||||
size_t skip = 3;
|
||||
if (skip < content.size() && content[skip] == ' ') skip++;
|
||||
return content.substr(skip);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
ParsedLine parseLine(const std::string& rawLine, bool inCodeBlock) {
|
||||
ParsedLine result;
|
||||
|
||||
// Inside a code block: either closing fence or verbatim text
|
||||
if (inCodeBlock) {
|
||||
if (isCodeFence(rawLine)) {
|
||||
result.blockType = BlockType::CodeBlock;
|
||||
return result;
|
||||
}
|
||||
result.blockType = BlockType::CodeBlock;
|
||||
result.spans.push_back({rawLine, EpdFontFamily::REGULAR});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Opening code fence
|
||||
if (isCodeFence(rawLine)) {
|
||||
result.blockType = BlockType::CodeBlock;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Count leading whitespace for nesting level before trimming.
|
||||
// Up to 3 spaces before a list marker are still top-level in CommonMark.
|
||||
size_t leadingSpaces = 0;
|
||||
for (size_t i = 0; i < rawLine.size(); i++) {
|
||||
if (rawLine[i] == ' ')
|
||||
leadingSpaces++;
|
||||
else if (rawLine[i] == '\t')
|
||||
leadingSpaces += TAB_WIDTH; // Treat tab as 4 spaces to match CommonMark nesting rules
|
||||
else
|
||||
break;
|
||||
}
|
||||
result.indentLevel = parseListIndentLevel(leadingSpaces);
|
||||
|
||||
std::string trimmed = trimLeft(rawLine);
|
||||
|
||||
// Blank line
|
||||
if (trimmed.empty()) {
|
||||
result.blockType = BlockType::BlankLine;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Horizontal rule (must check BEFORE unordered list since --- and *** overlap)
|
||||
if (isHorizontalRuleLine(trimmed)) {
|
||||
result.blockType = BlockType::HorizontalRule;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ATX headers: # H1, ## H2, ### H3+
|
||||
if (trimmed[0] == '#') {
|
||||
int level = 0;
|
||||
size_t pos = 0;
|
||||
while (pos < trimmed.size() && trimmed[pos] == '#') {
|
||||
level++;
|
||||
pos++;
|
||||
}
|
||||
if (pos < trimmed.size() && trimmed[pos] == ' ') {
|
||||
std::string content = trimmed.substr(pos + 1);
|
||||
// Strip optional trailing # sequence
|
||||
size_t trail = content.size();
|
||||
while (trail > 0 && content[trail - 1] == '#') trail--;
|
||||
while (trail > 0 && content[trail - 1] == ' ') trail--;
|
||||
if (trail < content.size()) content = content.substr(0, trail);
|
||||
|
||||
if (level <= 1)
|
||||
result.blockType = BlockType::Header1;
|
||||
else if (level == 2)
|
||||
result.blockType = BlockType::Header2;
|
||||
else
|
||||
result.blockType = BlockType::Header3;
|
||||
|
||||
result.spans = parseInline(content);
|
||||
// Force bold on all header spans
|
||||
for (auto& span : result.spans) {
|
||||
if (span.style == EpdFontFamily::REGULAR)
|
||||
span.style = EpdFontFamily::BOLD;
|
||||
else if (span.style == EpdFontFamily::ITALIC)
|
||||
span.style = EpdFontFamily::BOLD_ITALIC;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Unordered list: - , * , + (marker followed by space)
|
||||
if (trimmed.size() > 1 && trimmed[1] == ' ' && (trimmed[0] == '-' || trimmed[0] == '*' || trimmed[0] == '+')) {
|
||||
result.blockType = BlockType::UnorderedList;
|
||||
result.listPrefix = "\xe2\x80\xa2 "; // "• "
|
||||
std::string content = handleTaskList(trimmed.substr(2), result.listPrefix);
|
||||
result.spans = parseInline(content);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Ordered list: 1. , 2. , etc. (up to 3-digit number)
|
||||
{
|
||||
size_t dotPos = trimmed.find(". ");
|
||||
if (dotPos != std::string::npos && dotPos <= 3 && dotPos > 0) {
|
||||
bool allDigits = true;
|
||||
for (size_t j = 0; j < dotPos; j++) {
|
||||
if (!std::isdigit(static_cast<unsigned char>(trimmed[j]))) {
|
||||
allDigits = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allDigits) {
|
||||
result.blockType = BlockType::OrderedList;
|
||||
result.listPrefix = trimmed.substr(0, dotPos + 2); // e.g. "1. "
|
||||
std::string content = handleTaskList(trimmed.substr(dotPos + 2), result.listPrefix);
|
||||
result.spans = parseInline(content);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Blockquote: > text
|
||||
if (trimmed[0] == '>') {
|
||||
result.blockType = BlockType::Blockquote;
|
||||
std::string content = trimmed.substr(1);
|
||||
if (!content.empty() && content[0] == ' ') content = content.substr(1);
|
||||
result.spans = parseInline(content);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Default: paragraph
|
||||
result.blockType = BlockType::Paragraph;
|
||||
result.spans = parseInline(trimmed);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace MdParser
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <EpdFontFamily.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace MdParser {
|
||||
|
||||
struct Span {
|
||||
std::string text;
|
||||
EpdFontFamily::Style style;
|
||||
};
|
||||
|
||||
enum class BlockType : uint8_t {
|
||||
Paragraph,
|
||||
Header1,
|
||||
Header2,
|
||||
Header3,
|
||||
UnorderedList,
|
||||
OrderedList,
|
||||
Blockquote,
|
||||
CodeBlock,
|
||||
HorizontalRule,
|
||||
BlankLine
|
||||
};
|
||||
|
||||
struct ParsedLine {
|
||||
BlockType blockType = BlockType::Paragraph;
|
||||
std::vector<Span> spans;
|
||||
std::string listPrefix; // "• " or "1. " etc.
|
||||
uint8_t indentLevel = 0; // Nesting depth (each 4 spaces = 1 level)
|
||||
};
|
||||
|
||||
// Parse a single raw line of markdown into block type and styled spans.
|
||||
// |inCodeBlock| indicates whether the line is inside a fenced code block.
|
||||
ParsedLine parseLine(const std::string& rawLine, bool inCodeBlock);
|
||||
|
||||
// Returns true if the line is a code fence (``` with optional language tag).
|
||||
bool isCodeFence(const std::string& line);
|
||||
|
||||
// Parse inline markdown formatting (bold, italic, code spans, links, images).
|
||||
std::vector<Span> parseInline(const std::string& text);
|
||||
|
||||
} // namespace MdParser
|
||||
+3
-1
@@ -40,9 +40,11 @@ std::string Txt::getTitle() const {
|
||||
size_t lastSlash = filepath.find_last_of('/');
|
||||
std::string filename = (lastSlash != std::string::npos) ? filepath.substr(lastSlash + 1) : filepath;
|
||||
|
||||
// Remove .txt extension
|
||||
// Remove .txt or .md extension
|
||||
if (FsHelpers::hasTxtExtension(filename)) {
|
||||
filename = filename.substr(0, filename.length() - 4);
|
||||
} else if (FsHelpers::hasMarkdownExtension(filename)) {
|
||||
filename = filename.substr(0, filename.length() - 3);
|
||||
}
|
||||
|
||||
return filename;
|
||||
|
||||
+1
-1
Submodule open-x4-sdk updated: a931d452d4...ed5cb2f99d
+1
-1
@@ -3,7 +3,7 @@ default_envs = default
|
||||
extra_configs = platformio.local.ini
|
||||
|
||||
[crosspoint]
|
||||
version = 1.38
|
||||
version = 1.39
|
||||
|
||||
[base]
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip
|
||||
|
||||
@@ -237,6 +237,8 @@ class CrossPointSettings {
|
||||
uint8_t sleepTimeout = SLEEP_10_MIN;
|
||||
// E-ink refresh frequency (default 15 pages)
|
||||
uint8_t refreshFrequency = REFRESH_15;
|
||||
// Perform a half refresh on the page immediately following an EPUB page that displayed images.
|
||||
uint8_t halfRefreshAfterImagePage = 1;
|
||||
uint8_t hyphenationEnabled = 0;
|
||||
|
||||
// Reader screen margin settings
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "SettingsList.h"
|
||||
#include "WifiCredentialStore.h"
|
||||
@@ -393,6 +394,8 @@ bool JsonSettingsIO::saveRecentBooks(const RecentBooksStore& store, const char*
|
||||
obj["coverBmpPath"] = book.coverBmpPath;
|
||||
obj["embeddedStyleOverride"] = book.embeddedStyleOverride;
|
||||
obj["imageRenderingOverride"] = book.imageRenderingOverride;
|
||||
obj["fontFamilyOverride"] = book.fontFamilyOverride;
|
||||
obj["fontSizeOverride"] = book.fontSizeOverride;
|
||||
}
|
||||
|
||||
String json;
|
||||
@@ -427,9 +430,65 @@ bool JsonSettingsIO::loadRecentBooks(RecentBooksStore& store, const char* json)
|
||||
book.coverBmpPath = obj["coverBmpPath"] | std::string("");
|
||||
book.embeddedStyleOverride = clampInt8(obj["embeddedStyleOverride"] | -1, -1, 1, -1);
|
||||
book.imageRenderingOverride = clampInt8(obj["imageRenderingOverride"] | -1, -1, 2, -1);
|
||||
book.fontFamilyOverride =
|
||||
clampInt8(obj["fontFamilyOverride"] | -1, -1, CrossPointSettings::FONT_FAMILY_COUNT - 1, -1);
|
||||
book.fontSizeOverride = clampInt8(obj["fontSizeOverride"] | -1, -1, CrossPointSettings::FONT_SIZE_COUNT - 1, -1);
|
||||
store.recentBooks.push_back(book);
|
||||
}
|
||||
|
||||
LOG_DBG("RBS", "Recent books loaded from file (%d entries)", store.getCount());
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- OpdsServerStore ----
|
||||
// Follows the same save/load pattern as WifiCredentialStore above.
|
||||
// Passwords are XOR-obfuscated with the device MAC and base64-encoded ("password_obf" key).
|
||||
|
||||
bool JsonSettingsIO::saveOpds(const OpdsServerStore& store, const char* path) {
|
||||
JsonDocument doc;
|
||||
|
||||
JsonArray arr = doc["servers"].to<JsonArray>();
|
||||
for (const auto& server : store.getServers()) {
|
||||
JsonObject obj = arr.add<JsonObject>();
|
||||
obj["name"] = server.name;
|
||||
obj["url"] = server.url;
|
||||
obj["username"] = server.username;
|
||||
obj["password_obf"] = obfuscation::obfuscateToBase64(server.password);
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
return Storage.writeFile(path, json);
|
||||
}
|
||||
|
||||
bool JsonSettingsIO::loadOpds(OpdsServerStore& store, const char* json, bool* needsResave) {
|
||||
if (needsResave) *needsResave = false;
|
||||
JsonDocument doc;
|
||||
auto error = deserializeJson(doc, json);
|
||||
if (error) {
|
||||
LOG_ERR("OPS", "JSON parse error: %s", error.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
store.servers.clear();
|
||||
JsonArray arr = doc["servers"].as<JsonArray>();
|
||||
for (JsonObject obj : arr) {
|
||||
if (store.servers.size() >= OpdsServerStore::MAX_SERVERS) break;
|
||||
OpdsServer server;
|
||||
server.name = obj["name"] | std::string("");
|
||||
server.url = obj["url"] | std::string("");
|
||||
server.username = obj["username"] | std::string("");
|
||||
// Try the obfuscated key first; fall back to plaintext "password" for
|
||||
// files written before obfuscation was added (or hand-edited JSON).
|
||||
bool ok = false;
|
||||
server.password = obfuscation::deobfuscateFromBase64(obj["password_obf"] | "", &ok);
|
||||
if (!ok || server.password.empty()) {
|
||||
server.password = obj["password"] | std::string("");
|
||||
if (!server.password.empty() && needsResave) *needsResave = true;
|
||||
}
|
||||
store.servers.push_back(std::move(server));
|
||||
}
|
||||
|
||||
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", store.servers.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ class CrossPointState;
|
||||
class WifiCredentialStore;
|
||||
class KOReaderCredentialStore;
|
||||
class RecentBooksStore;
|
||||
class OpdsServerStore;
|
||||
|
||||
namespace JsonSettingsIO {
|
||||
|
||||
@@ -28,4 +29,8 @@ bool loadKOReader(KOReaderCredentialStore& store, const char* json, bool* needsR
|
||||
bool saveRecentBooks(const RecentBooksStore& store, const char* path);
|
||||
bool loadRecentBooks(RecentBooksStore& store, const char* json);
|
||||
|
||||
// OpdsServerStore
|
||||
bool saveOpds(const OpdsServerStore& store, const char* path);
|
||||
bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave = nullptr);
|
||||
|
||||
} // namespace JsonSettingsIO
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
#include "OpdsServerStore.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <JsonSettingsIO.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "util/UrlUtils.h"
|
||||
|
||||
OpdsServerStore OpdsServerStore::instance;
|
||||
|
||||
namespace {
|
||||
constexpr char OPDS_FILE_JSON[] = "/.crosspoint/opds.json";
|
||||
|
||||
bool containsWhitespace(const std::string& value) {
|
||||
return std::any_of(value.begin(), value.end(), [](unsigned char ch) { return std::isspace(ch); });
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace OpdsServerValidation {
|
||||
|
||||
std::optional<std::string> normalizeUrl(const std::string& url) {
|
||||
if (url.empty() || containsWhitespace(url)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string normalized = url;
|
||||
if (normalized.find("://") == std::string::npos) {
|
||||
normalized = "https://" + normalized;
|
||||
}
|
||||
|
||||
const bool hasHttpScheme = normalized.rfind("http://", 0) == 0 || normalized.rfind("https://", 0) == 0;
|
||||
if (!hasHttpScheme || UrlUtils::extractHostname(normalized).empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
} // namespace OpdsServerValidation
|
||||
|
||||
bool OpdsServerStore::saveToFile() const {
|
||||
Storage.mkdir("/.crosspoint");
|
||||
return JsonSettingsIO::saveOpds(*this, OPDS_FILE_JSON);
|
||||
}
|
||||
|
||||
bool OpdsServerStore::loadFromFile() {
|
||||
if (Storage.exists(OPDS_FILE_JSON)) {
|
||||
String json = Storage.readFile(OPDS_FILE_JSON);
|
||||
if (json.isEmpty()) {
|
||||
LOG_ERR("OPS", "Failed to parse %s", OPDS_FILE_JSON);
|
||||
return false;
|
||||
}
|
||||
|
||||
// resave flag is set when passwords were stored in plaintext and need re-obfuscation
|
||||
bool resave = false;
|
||||
bool result = JsonSettingsIO::loadOpds(*this, json.c_str(), &resave);
|
||||
if (!result) {
|
||||
LOG_ERR("OPS", "Failed to parse %s", OPDS_FILE_JSON);
|
||||
return false;
|
||||
}
|
||||
if (resave) {
|
||||
LOG_DBG("OPS", "Resaving JSON with obfuscated passwords");
|
||||
if (!saveToFile()) {
|
||||
LOG_ERR("OPS", "Failed to resave %s after password migration", OPDS_FILE_JSON);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// No opds.json found — attempt one-time migration from the legacy single-server
|
||||
// fields in CrossPointSettings (opdsServerUrl/opdsUsername/opdsPassword).
|
||||
if (migrateFromSettings()) {
|
||||
LOG_DBG("OPS", "Migrated legacy OPDS settings");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool OpdsServerStore::migrateFromSettings() {
|
||||
if (strlen(SETTINGS.opdsServerUrl) == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
OpdsServer server;
|
||||
server.name = "OPDS Server";
|
||||
server.url = SETTINGS.opdsServerUrl;
|
||||
server.username = SETTINGS.opdsUsername;
|
||||
server.password = SETTINGS.opdsPassword;
|
||||
servers.push_back(std::move(server));
|
||||
|
||||
if (saveToFile()) {
|
||||
// Clear legacy fields so migration won't run again on next boot
|
||||
SETTINGS.opdsServerUrl[0] = '\0';
|
||||
SETTINGS.opdsUsername[0] = '\0';
|
||||
SETTINGS.opdsPassword[0] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
LOG_DBG("OPS", "Migrated single-server OPDS config to opds.json");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Save failed — roll back in-memory state so we don't have a partial migration
|
||||
servers.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
std::optional<size_t> OpdsServerStore::addServer(const OpdsServer& server) {
|
||||
if (servers.size() >= MAX_SERVERS) {
|
||||
LOG_DBG("OPS", "Cannot add more servers, limit of %zu reached", MAX_SERVERS);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const auto originalServers = servers;
|
||||
servers.push_back(server);
|
||||
if (!saveToFile()) {
|
||||
servers = originalServers;
|
||||
LOG_ERR("OPS", "Failed to persist added server, rolled back in-memory state");
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const size_t insertedIndex = servers.size() - 1;
|
||||
LOG_DBG("OPS", "Added server at index %zu: %s", insertedIndex, server.name.c_str());
|
||||
return insertedIndex;
|
||||
}
|
||||
|
||||
bool OpdsServerStore::updateServer(size_t index, const OpdsServer& server) {
|
||||
if (index >= servers.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto originalServers = servers;
|
||||
servers[index] = server;
|
||||
if (!saveToFile()) {
|
||||
servers = originalServers;
|
||||
LOG_ERR("OPS", "Failed to persist updated server at index %zu, rolled back in-memory state", index);
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DBG("OPS", "Updated server at index %zu: %s", index, server.name.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OpdsServerStore::removeServer(size_t index) {
|
||||
if (index >= servers.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto originalServers = servers;
|
||||
const std::string removedName = servers[index].name;
|
||||
servers.erase(servers.begin() + static_cast<ptrdiff_t>(index));
|
||||
if (!saveToFile()) {
|
||||
servers = originalServers;
|
||||
LOG_ERR("OPS", "Failed to persist removed server at index %zu, rolled back in-memory state", index);
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DBG("OPS", "Removed server at index %zu: %s", index, removedName.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
const OpdsServer* OpdsServerStore::getServer(size_t index) const {
|
||||
if (index >= servers.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &servers[index];
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct OpdsServer {
|
||||
std::string name;
|
||||
std::string url;
|
||||
std::string username;
|
||||
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
|
||||
};
|
||||
|
||||
class OpdsServerStore;
|
||||
namespace JsonSettingsIO {
|
||||
bool saveOpds(const OpdsServerStore& store, const char* path);
|
||||
bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave);
|
||||
} // namespace JsonSettingsIO
|
||||
|
||||
namespace OpdsServerValidation {
|
||||
std::optional<std::string> normalizeUrl(const std::string& url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton class for storing OPDS server configurations on the SD card.
|
||||
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
|
||||
* and base64-encoded before writing to JSON.
|
||||
*/
|
||||
class OpdsServerStore {
|
||||
private:
|
||||
static OpdsServerStore instance;
|
||||
std::vector<OpdsServer> servers;
|
||||
|
||||
OpdsServerStore() = default;
|
||||
|
||||
friend bool JsonSettingsIO::saveOpds(const OpdsServerStore&, const char*);
|
||||
friend bool JsonSettingsIO::loadOpds(OpdsServerStore&, const char*, bool*);
|
||||
|
||||
public:
|
||||
static constexpr size_t MAX_SERVERS = 8;
|
||||
static constexpr size_t MAX_NAME_LENGTH = 63;
|
||||
static constexpr size_t MAX_URL_LENGTH = 127;
|
||||
static constexpr size_t MAX_USERNAME_LENGTH = 63;
|
||||
static constexpr size_t MAX_PASSWORD_LENGTH = 63;
|
||||
|
||||
OpdsServerStore(const OpdsServerStore&) = delete;
|
||||
OpdsServerStore& operator=(const OpdsServerStore&) = delete;
|
||||
|
||||
static OpdsServerStore& getInstance() { return instance; }
|
||||
|
||||
bool saveToFile() const;
|
||||
bool loadFromFile();
|
||||
|
||||
std::optional<size_t> addServer(const OpdsServer& server);
|
||||
bool updateServer(size_t index, const OpdsServer& server);
|
||||
bool removeServer(size_t index);
|
||||
|
||||
const std::vector<OpdsServer>& getServers() const { return servers; }
|
||||
const OpdsServer* getServer(size_t index) const;
|
||||
size_t getCount() const { return servers.size(); }
|
||||
bool hasServers() const { return !servers.empty(); }
|
||||
|
||||
/**
|
||||
* Migrate from legacy single-server settings in CrossPointSettings.
|
||||
* Called once during first load if no opds.json exists.
|
||||
*/
|
||||
bool migrateFromSettings();
|
||||
};
|
||||
|
||||
#define OPDS_STORE OpdsServerStore::getInstance()
|
||||
@@ -24,6 +24,8 @@ void RecentBooksStore::addBook(const std::string& path, const std::string& title
|
||||
const std::string& series, const std::string& coverBmpPath) {
|
||||
int8_t embeddedStyleOverride = -1;
|
||||
int8_t imageRenderingOverride = -1;
|
||||
int8_t fontFamilyOverride = -1;
|
||||
int8_t fontSizeOverride = -1;
|
||||
|
||||
// Remove existing entry if present
|
||||
auto it =
|
||||
@@ -31,12 +33,14 @@ void RecentBooksStore::addBook(const std::string& path, const std::string& title
|
||||
if (it != recentBooks.end()) {
|
||||
embeddedStyleOverride = it->embeddedStyleOverride;
|
||||
imageRenderingOverride = it->imageRenderingOverride;
|
||||
fontFamilyOverride = it->fontFamilyOverride;
|
||||
fontSizeOverride = it->fontSizeOverride;
|
||||
recentBooks.erase(it);
|
||||
}
|
||||
|
||||
// Add to front
|
||||
recentBooks.insert(recentBooks.begin(),
|
||||
{path, title, author, series, coverBmpPath, embeddedStyleOverride, imageRenderingOverride});
|
||||
recentBooks.insert(recentBooks.begin(), {path, title, author, series, coverBmpPath, embeddedStyleOverride,
|
||||
imageRenderingOverride, fontFamilyOverride, fontSizeOverride});
|
||||
|
||||
// Trim to max size
|
||||
if (recentBooks.size() > MAX_RECENT_BOOKS) {
|
||||
@@ -85,9 +89,23 @@ bool RecentBooksStore::setReaderOverrides(const std::string& path, const int8_t
|
||||
if (it == recentBooks.end()) {
|
||||
return false;
|
||||
}
|
||||
return setReaderOverrides(path, embeddedStyleOverride, imageRenderingOverride, it->fontFamilyOverride,
|
||||
it->fontSizeOverride);
|
||||
}
|
||||
|
||||
bool RecentBooksStore::setReaderOverrides(const std::string& path, const int8_t embeddedStyleOverride,
|
||||
const int8_t imageRenderingOverride, const int8_t fontFamilyOverride,
|
||||
const int8_t fontSizeOverride) {
|
||||
auto it =
|
||||
std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; });
|
||||
if (it == recentBooks.end()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
it->embeddedStyleOverride = embeddedStyleOverride;
|
||||
it->imageRenderingOverride = imageRenderingOverride;
|
||||
it->fontFamilyOverride = fontFamilyOverride;
|
||||
it->fontSizeOverride = fontSizeOverride;
|
||||
return saveToFile();
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ struct RecentBook {
|
||||
int8_t embeddedStyleOverride = -1;
|
||||
// -1 = use global setting, otherwise CrossPointSettings::IMAGE_RENDERING value.
|
||||
int8_t imageRenderingOverride = -1;
|
||||
// -1 = use global setting, otherwise CrossPointSettings::FONT_FAMILY value.
|
||||
int8_t fontFamilyOverride = -1;
|
||||
// -1 = use global setting, otherwise CrossPointSettings::FONT_SIZE value.
|
||||
int8_t fontSizeOverride = -1;
|
||||
|
||||
bool operator==(const RecentBook& other) const { return path == other.path; }
|
||||
};
|
||||
@@ -58,6 +62,8 @@ class RecentBooksStore {
|
||||
RecentBook getDataFromBook(std::string path) const;
|
||||
RecentBook getBookByPath(const std::string& path) const;
|
||||
bool setReaderOverrides(const std::string& path, int8_t embeddedStyleOverride, int8_t imageRenderingOverride);
|
||||
bool setReaderOverrides(const std::string& path, int8_t embeddedStyleOverride, int8_t imageRenderingOverride,
|
||||
int8_t fontFamilyOverride, int8_t fontSizeOverride);
|
||||
|
||||
private:
|
||||
bool loadFromBinaryFile();
|
||||
|
||||
@@ -72,6 +72,9 @@ inline const std::vector<SettingInfo> list = {
|
||||
{StrId::STR_PAGES_1, StrId::STR_PAGES_5, StrId::STR_PAGES_10, StrId::STR_PAGES_15, StrId::STR_PAGES_30},
|
||||
"refreshFrequency", StrId::STR_CAT_DISPLAY)
|
||||
.withSubcategory(StrId::STR_MENU_DISP_REFRESH),
|
||||
SettingInfo::Toggle(StrId::STR_REFRESH_AFTER_IMAGE_PAGES, &CrossPointSettings::halfRefreshAfterImagePage,
|
||||
"halfRefreshAfterImagePage", StrId::STR_CAT_DISPLAY)
|
||||
.withSubcategory(StrId::STR_MENU_DISP_REFRESH),
|
||||
SettingInfo::Toggle(StrId::STR_SUNLIGHT_FADING_FIX, &CrossPointSettings::fadingFix, "fadingFix",
|
||||
StrId::STR_CAT_DISPLAY),
|
||||
SettingInfo::Enum(StrId::STR_UI_THEME, &CrossPointSettings::uiTheme,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <esp_system.h>
|
||||
|
||||
#include "CrossPointState.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "boot_sleep/BootActivity.h"
|
||||
#include "boot_sleep/SleepActivity.h"
|
||||
#include "browser/OpdsBookBrowserActivity.h"
|
||||
@@ -18,6 +19,7 @@
|
||||
#include "network/CrossPointWebServerActivity.h"
|
||||
#include "reader/KOReaderSyncActivity.h"
|
||||
#include "reader/ReaderActivity.h"
|
||||
#include "settings/OpdsServerListActivity.h"
|
||||
#include "settings/SettingsActivity.h"
|
||||
#include "util/FullScreenMessageActivity.h"
|
||||
#include "weather/WeatherActivity.h"
|
||||
@@ -268,7 +270,13 @@ void ActivityManager::goToGlobalBookmarks(ReturnHint hint) {
|
||||
}
|
||||
|
||||
void ActivityManager::goToBrowser() {
|
||||
replaceActivity(std::make_unique<OpdsBookBrowserActivity>(renderer, mappedInput));
|
||||
const auto& servers = OPDS_STORE.getServers();
|
||||
// Skip the server picker when there's only one server configured
|
||||
if (servers.size() == 1) {
|
||||
replaceActivity(std::make_unique<OpdsBookBrowserActivity>(renderer, mappedInput, servers[0]));
|
||||
} else {
|
||||
replaceActivity(std::make_unique<OpdsServerListActivity>(renderer, mappedInput, true));
|
||||
}
|
||||
}
|
||||
|
||||
void ActivityManager::goToReader(std::string path) {
|
||||
|
||||
@@ -20,10 +20,13 @@ struct KeyboardResult {
|
||||
|
||||
struct MenuResult {
|
||||
int action = -1;
|
||||
int nameId = -1;
|
||||
uint8_t orientation = 0;
|
||||
uint8_t pageTurnOption = 0;
|
||||
int8_t embeddedStyleOverride = -1;
|
||||
int8_t imageRenderingOverride = -1;
|
||||
int8_t fontFamilyOverride = -1;
|
||||
int8_t fontSizeOverride = -1;
|
||||
uint8_t textDarkness = 1;
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "settings/SettingsSubmenuActivity.h"
|
||||
|
||||
void MenuListActivity::initMenuList() {
|
||||
const int count = static_cast<int>(menuItems.size());
|
||||
@@ -16,6 +17,10 @@ void MenuListActivity::initMenuList() {
|
||||
|
||||
void MenuListActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
if (!submenusPrepared) {
|
||||
prepareSubmenus();
|
||||
submenusPrepared = true;
|
||||
}
|
||||
initMenuList();
|
||||
requestUpdate();
|
||||
}
|
||||
@@ -37,6 +42,10 @@ void MenuListActivity::toggleCurrentItem() {
|
||||
if (item.isSeparator) return;
|
||||
|
||||
if (item.type == SettingType::ACTION) {
|
||||
if (item.action == SettingAction::Submenu) {
|
||||
openSubmenu(item);
|
||||
return;
|
||||
}
|
||||
onActionSelected(selectedIndex);
|
||||
return;
|
||||
}
|
||||
@@ -55,6 +64,18 @@ void MenuListActivity::drawMenuList(const Rect& rect) {
|
||||
[this](int index) { return getItemValueString(index); }, true);
|
||||
}
|
||||
|
||||
void MenuListActivity::prepareSubmenus() { SettingInfo::prepareSubmenus(menuItems, submenuData); }
|
||||
|
||||
void MenuListActivity::openSubmenu(const SettingInfo& submenuEntry) {
|
||||
auto it = std::find_if(submenuData.begin(), submenuData.end(),
|
||||
[&submenuEntry](const SettingInfo::SubmenuData& d) { return d.id == submenuEntry.nameId; });
|
||||
if (it == submenuData.end()) return;
|
||||
|
||||
startActivityForResult(
|
||||
std::make_unique<SettingsSubmenuActivity>(renderer, mappedInput, submenuEntry.nameId, it->items),
|
||||
[this](const ActivityResult&) { requestUpdate(); });
|
||||
}
|
||||
|
||||
void MenuListActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onBackPressed();
|
||||
|
||||
@@ -70,17 +70,23 @@ struct Rect;
|
||||
class MenuListActivity : public Activity {
|
||||
protected:
|
||||
std::vector<SettingInfo> menuItems;
|
||||
std::vector<SettingInfo::SubmenuData> submenuData;
|
||||
int selectedIndex = 0;
|
||||
ButtonNavigator buttonNavigator;
|
||||
bool submenusPrepared = false;
|
||||
|
||||
// Call after building/rebuilding menuItems to wire up the selectable predicate.
|
||||
void initMenuList();
|
||||
|
||||
// Process SettingInfo items marked with withSubmenu() into submenu placeholders.
|
||||
void prepareSubmenus();
|
||||
void openSubmenu(const SettingInfo& submenuEntry);
|
||||
|
||||
// Handle up/down navigation via buttonNavigator. Call from loop() if overriding.
|
||||
void handleNavigation();
|
||||
|
||||
// Toggle/cycle the currently selected item. For ACTION items, delegates to onActionSelected().
|
||||
void toggleCurrentItem();
|
||||
virtual void toggleCurrentItem();
|
||||
|
||||
// Draw the list into the given rect using GUI.drawList().
|
||||
void drawMenuList(const Rect& rect);
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
#include <cstdio>
|
||||
#include <memory>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsFormatLabel.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
@@ -191,7 +190,9 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
|
||||
const Rect contentRect = UITheme::getContentRect(renderer, true, false);
|
||||
const int midY = contentRect.y + contentRect.height / 2;
|
||||
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_OPDS_BROWSER), true, EpdFontFamily::BOLD);
|
||||
// Show server name in header if available, otherwise generic title
|
||||
const char* headerTitle = server.name.empty() ? tr(STR_OPDS_BROWSER) : server.name.c_str();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, headerTitle, true, EpdFontFamily::BOLD);
|
||||
|
||||
if (state == BrowserState::CHECK_WIFI) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, midY, statusMessage.c_str());
|
||||
@@ -303,21 +304,21 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
|
||||
if (strlen(SETTINGS.opdsServerUrl) == 0) {
|
||||
if (server.url.empty()) {
|
||||
state = BrowserState::ERROR;
|
||||
errorMessage = tr(STR_NO_SERVER_URL);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
std::string url = (path.rfind("http", 0) == 0) ? path : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, path);
|
||||
std::string url = (path.find("http") == 0) ? path : UrlUtils::buildUrl(server.url, path);
|
||||
LOG_DBG("OPDS", "Fetching: %s", url.c_str());
|
||||
|
||||
OpdsParser parser;
|
||||
|
||||
{
|
||||
OpdsParserStream stream{parser};
|
||||
if (!HttpDownloader::fetchUrl(url, stream)) {
|
||||
if (!HttpDownloader::fetchUrl(url, stream, server.username, server.password)) {
|
||||
state = BrowserState::ERROR;
|
||||
errorMessage = tr(STR_FETCH_FEED_FAILED);
|
||||
requestUpdate();
|
||||
@@ -419,12 +420,14 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book, const OpdsAcqu
|
||||
|
||||
LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());
|
||||
|
||||
const auto result =
|
||||
HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) {
|
||||
const auto result = HttpDownloader::downloadToFile(
|
||||
downloadUrl, filename,
|
||||
[this](const size_t downloaded, const size_t total) {
|
||||
downloadProgress = downloaded;
|
||||
downloadTotal = total;
|
||||
requestUpdate(true);
|
||||
});
|
||||
},
|
||||
server.username, server.password);
|
||||
|
||||
if (result == HttpDownloader::OK) {
|
||||
FsFile downloadedFile;
|
||||
@@ -564,7 +567,6 @@ void OpdsBookBrowserActivity::checkAndConnectWifi() {
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::launchWifiSelection() {
|
||||
consumeBack = consumeConfirm = true;
|
||||
state = BrowserState::WIFI_SELECTION;
|
||||
requestUpdate();
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
#include <OpdsParser.h>
|
||||
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
/**
|
||||
@@ -24,8 +26,8 @@ class OpdsBookBrowserActivity final : public Activity {
|
||||
SEARCH_INPUT
|
||||
};
|
||||
|
||||
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
: Activity("OpdsBookBrowser", renderer, mappedInput), buttonNavigator() {}
|
||||
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, OpdsServer server)
|
||||
: Activity("OpdsBookBrowser", renderer, mappedInput), buttonNavigator(), server(std::move(server)) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
@@ -50,6 +52,8 @@ class OpdsBookBrowserActivity final : public Activity {
|
||||
size_t downloadProgress = 0;
|
||||
size_t downloadTotal = 0;
|
||||
|
||||
OpdsServer server; // Copied at construction — safe even if the store changes during browsing
|
||||
|
||||
void checkAndConnectWifi();
|
||||
void launchWifiSelection();
|
||||
void onWifiSelectionComplete(bool connected);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "CrossPointState.h"
|
||||
#include "GlobalBookmarkIndex.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
@@ -111,7 +112,7 @@ void HomeActivity::rebuildMenuEntries() {
|
||||
if (!GLOBAL_BOOKMARKS.isEmpty()) {
|
||||
menuEntries.push_back({MenuAction::GlobalBookmarks, StrId::STR_GLOBAL_BOOKMARKS, Book});
|
||||
}
|
||||
if (hasOpdsUrl) {
|
||||
if (hasOpdsServers) {
|
||||
menuEntries.push_back({MenuAction::OpdsBrowser, StrId::STR_OPDS_BROWSER, Library});
|
||||
}
|
||||
menuEntries.push_back({MenuAction::FileTransfer, StrId::STR_FILE_TRANSFER, Transfer});
|
||||
@@ -195,8 +196,7 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
|
||||
void HomeActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
// Check if OPDS browser URL is configured
|
||||
hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0;
|
||||
hasOpdsServers = OPDS_STORE.hasServers();
|
||||
|
||||
selectorIndex = 0;
|
||||
recentsLoading = false;
|
||||
|
||||
@@ -35,7 +35,7 @@ class HomeActivity final : public Activity {
|
||||
bool recentsLoading = false;
|
||||
bool recentsLoaded = false;
|
||||
bool firstRenderDone = false;
|
||||
bool hasOpdsUrl = false;
|
||||
bool hasOpdsServers = false;
|
||||
bool coverRendered = false; // Track if cover has been rendered once
|
||||
bool coverBufferStored = false; // Track if cover buffer is stored
|
||||
size_t nextRecentCoverIndex = 0;
|
||||
|
||||
@@ -148,6 +148,8 @@ void EpubReaderActivity::onEnter() {
|
||||
const RecentBook currentBook = RECENT_BOOKS.getBookByPath(epub->getPath());
|
||||
bookEmbeddedStyleOverride = currentBook.embeddedStyleOverride;
|
||||
bookImageRenderingOverride = currentBook.imageRenderingOverride;
|
||||
bookFontFamilyOverride = currentBook.fontFamilyOverride;
|
||||
bookFontSizeOverride = currentBook.fontSizeOverride;
|
||||
logReaderMemSnapshot("onEnter_after_recent_books");
|
||||
|
||||
// Trigger first update
|
||||
@@ -239,22 +241,23 @@ void EpubReaderActivity::loop() {
|
||||
const bool isCurrentPageStarred = section && bookmarkStore.has(static_cast<uint16_t>(currentSpineIndex),
|
||||
static_cast<uint16_t>(section->currentPage));
|
||||
ReaderUtils::enforceExitFullRefresh(renderer);
|
||||
startActivityForResult(
|
||||
std::make_unique<EpubReaderMenuActivity>(
|
||||
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, SETTINGS.orientation,
|
||||
!currentPageFootnotes.empty(), bookEmbeddedStyleOverride, bookImageRenderingOverride, SETTINGS.textDarkness,
|
||||
!bookmarkStore.isEmpty(), isCurrentPageStarred),
|
||||
[this](const ActivityResult& result) {
|
||||
// Always apply orientation/darkness change even if the menu was cancelled
|
||||
const auto& menu = std::get<MenuResult>(result.data);
|
||||
applyOrientation(menu.orientation);
|
||||
applyTextDarkness(menu.textDarkness);
|
||||
toggleAutoPageTurn(menu.pageTurnOption);
|
||||
applyBookReaderOverrides(menu.embeddedStyleOverride, menu.imageRenderingOverride);
|
||||
if (!result.isCancelled) {
|
||||
onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(menu.action));
|
||||
}
|
||||
});
|
||||
startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
|
||||
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
||||
SETTINGS.orientation, !currentPageFootnotes.empty(), bookEmbeddedStyleOverride,
|
||||
bookImageRenderingOverride, bookFontFamilyOverride, bookFontSizeOverride,
|
||||
SETTINGS.textDarkness, !bookmarkStore.isEmpty(), isCurrentPageStarred),
|
||||
[this](const ActivityResult& result) {
|
||||
// Always apply orientation/darkness change even if the menu was cancelled
|
||||
const auto& menu = std::get<MenuResult>(result.data);
|
||||
applyOrientation(menu.orientation);
|
||||
applyTextDarkness(menu.textDarkness);
|
||||
toggleAutoPageTurn(menu.pageTurnOption);
|
||||
applyBookReaderOverrides(menu.embeddedStyleOverride, menu.imageRenderingOverride,
|
||||
menu.fontFamilyOverride, menu.fontSizeOverride);
|
||||
if (!result.isCancelled) {
|
||||
onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(menu.action));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Long press BACK (1s+) goes to home screen
|
||||
@@ -814,18 +817,23 @@ void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption
|
||||
}
|
||||
|
||||
void EpubReaderActivity::applyBookReaderOverrides(const int8_t embeddedStyleOverride,
|
||||
const int8_t imageRenderingOverride) {
|
||||
const int8_t imageRenderingOverride, const int8_t fontFamilyOverride,
|
||||
const int8_t fontSizeOverride) {
|
||||
if (!epub) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bookEmbeddedStyleOverride == embeddedStyleOverride && bookImageRenderingOverride == imageRenderingOverride) {
|
||||
if (bookEmbeddedStyleOverride == embeddedStyleOverride && bookImageRenderingOverride == imageRenderingOverride &&
|
||||
bookFontFamilyOverride == fontFamilyOverride && bookFontSizeOverride == fontSizeOverride) {
|
||||
return;
|
||||
}
|
||||
|
||||
bookEmbeddedStyleOverride = embeddedStyleOverride;
|
||||
bookImageRenderingOverride = imageRenderingOverride;
|
||||
RECENT_BOOKS.setReaderOverrides(epub->getPath(), bookEmbeddedStyleOverride, bookImageRenderingOverride);
|
||||
bookFontFamilyOverride = fontFamilyOverride;
|
||||
bookFontSizeOverride = fontSizeOverride;
|
||||
RECENT_BOOKS.setReaderOverrides(epub->getPath(), bookEmbeddedStyleOverride, bookImageRenderingOverride,
|
||||
bookFontFamilyOverride, bookFontSizeOverride);
|
||||
|
||||
RenderLock lock(*this);
|
||||
if (section) {
|
||||
@@ -850,6 +858,51 @@ uint8_t EpubReaderActivity::getEffectiveImageRendering() const {
|
||||
return SETTINGS.imageRendering;
|
||||
}
|
||||
|
||||
int EpubReaderActivity::getEffectiveReaderFontId() const {
|
||||
const uint8_t fontFamily =
|
||||
(bookFontFamilyOverride >= 0) ? static_cast<uint8_t>(bookFontFamilyOverride) : SETTINGS.fontFamily;
|
||||
const uint8_t fontSize = (bookFontSizeOverride >= 0) ? static_cast<uint8_t>(bookFontSizeOverride) : SETTINGS.fontSize;
|
||||
switch (fontFamily) {
|
||||
case CrossPointSettings::NOTOSANS:
|
||||
switch (fontSize) {
|
||||
case CrossPointSettings::SMALL:
|
||||
return NOTOSANS_12_FONT_ID;
|
||||
case CrossPointSettings::MEDIUM:
|
||||
default:
|
||||
return NOTOSANS_14_FONT_ID;
|
||||
case CrossPointSettings::LARGE:
|
||||
return NOTOSANS_16_FONT_ID;
|
||||
case CrossPointSettings::EXTRA_LARGE:
|
||||
return NOTOSANS_18_FONT_ID;
|
||||
}
|
||||
case CrossPointSettings::OPENDYSLEXIC:
|
||||
switch (fontSize) {
|
||||
case CrossPointSettings::SMALL:
|
||||
return OPENDYSLEXIC_8_FONT_ID;
|
||||
case CrossPointSettings::MEDIUM:
|
||||
default:
|
||||
return OPENDYSLEXIC_10_FONT_ID;
|
||||
case CrossPointSettings::LARGE:
|
||||
return OPENDYSLEXIC_12_FONT_ID;
|
||||
case CrossPointSettings::EXTRA_LARGE:
|
||||
return OPENDYSLEXIC_14_FONT_ID;
|
||||
}
|
||||
case CrossPointSettings::BOOKERLY:
|
||||
default:
|
||||
switch (fontSize) {
|
||||
case CrossPointSettings::SMALL:
|
||||
return BOOKERLY_12_FONT_ID;
|
||||
case CrossPointSettings::MEDIUM:
|
||||
default:
|
||||
return BOOKERLY_14_FONT_ID;
|
||||
case CrossPointSettings::LARGE:
|
||||
return BOOKERLY_16_FONT_ID;
|
||||
case CrossPointSettings::EXTRA_LARGE:
|
||||
return BOOKERLY_18_FONT_ID;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReaderActivity::pageTurn(bool isForwardTurn) {
|
||||
if (isForwardTurn) {
|
||||
if (section->currentPage < section->pageCount - 1) {
|
||||
@@ -934,7 +987,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex);
|
||||
section = std::make_unique<Section>(epub, currentSpineIndex, renderer);
|
||||
|
||||
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
if (!section->loadSectionFile(getEffectiveReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, embeddedStyle, imageRendering)) {
|
||||
LOG_DBG("ERS", "Cache not found, building...");
|
||||
@@ -947,7 +1000,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
GUI.fillPopupProgress(renderer, popupRect, progress);
|
||||
};
|
||||
|
||||
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
if (!section->createSectionFile(getEffectiveReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, embeddedStyle, imageRendering,
|
||||
progressFn)) {
|
||||
@@ -1090,14 +1143,14 @@ void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportW
|
||||
const uint8_t imageRendering = getEffectiveImageRendering();
|
||||
|
||||
Section nextSection(epub, nextSpineIndex, renderer);
|
||||
if (nextSection.loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
if (nextSection.loadSectionFile(getEffectiveReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, embeddedStyle, imageRendering)) {
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DBG("ERS", "Silently indexing next chapter: %d", nextSpineIndex);
|
||||
if (!nextSection.createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
if (!nextSection.createSectionFile(getEffectiveReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, embeddedStyle, imageRendering)) {
|
||||
LOG_ERR("ERS", "Failed silent indexing for chapter: %d", nextSpineIndex);
|
||||
@@ -1133,7 +1186,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
// Font prewarm: scan pass accumulates text, then prewarm, then real render
|
||||
const uint32_t heapBefore = esp_get_free_heap_size();
|
||||
auto scope = fcm->createPrewarmScope();
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); // scan pass
|
||||
page->render(renderer, getEffectiveReaderFontId(), orientedMarginLeft, orientedMarginTop); // scan pass
|
||||
scope.endScanAndPrewarm();
|
||||
const uint32_t heapAfter = esp_get_free_heap_size();
|
||||
fcm->logStats("prewarm");
|
||||
@@ -1145,9 +1198,11 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
|
||||
// Force special handling for pages with images when anti-aliasing is on
|
||||
bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing;
|
||||
bool forceHalfRefreshThisPage = pendingHalfRefreshAfterImagePage && SETTINGS.halfRefreshAfterImagePage;
|
||||
pendingHalfRefreshAfterImagePage = false;
|
||||
|
||||
logReaderMemSnapshot("before_bw_render");
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
page->render(renderer, getEffectiveReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderStatusBar();
|
||||
fcm->logStats("bw_render");
|
||||
const auto tBwRender = millis();
|
||||
@@ -1166,12 +1221,18 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
|
||||
// Re-render page content to restore images into the blanked area
|
||||
// Status bar is not re-rendered here to avoid reading stale dynamic values (e.g. battery %)
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
page->render(renderer, getEffectiveReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
} else {
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
}
|
||||
// Double FAST_REFRESH handles ghosting for image pages; don't count toward full refresh cadence
|
||||
if (forceHalfRefreshThisPage) {
|
||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||
}
|
||||
} else if (forceHalfRefreshThisPage) {
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||
} else {
|
||||
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
|
||||
}
|
||||
@@ -1183,13 +1244,17 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
const auto tBwStore = millis();
|
||||
logReaderMemSnapshot("bw_store_end");
|
||||
|
||||
if (page->hasImages() && getEffectiveImageRendering() != CrossPointSettings::IMAGES_SUPPRESS) {
|
||||
pendingHalfRefreshAfterImagePage = true;
|
||||
}
|
||||
|
||||
// grayscale rendering
|
||||
// TODO: Only do this if font supports it
|
||||
if (SETTINGS.textAntiAliasing) {
|
||||
logReaderMemSnapshot("gray_lsb_begin");
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
page->render(renderer, getEffectiveReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderer.copyGrayscaleLsbBuffers();
|
||||
const auto tGrayLsb = millis();
|
||||
logReaderMemSnapshot("gray_lsb_end");
|
||||
@@ -1198,7 +1263,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
logReaderMemSnapshot("gray_msb_begin");
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
page->render(renderer, getEffectiveReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderer.copyGrayscaleMsbBuffers();
|
||||
const auto tGrayMsb = millis();
|
||||
logReaderMemSnapshot("gray_msb_end");
|
||||
@@ -1373,16 +1438,63 @@ bool EpubReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, Gf
|
||||
|
||||
// Load or rebuild the section cache. Rebuilding is needed when the cache is missing or stale
|
||||
// (e.g. after a firmware update). A no-op popup callback avoids any UI during sleep preparation.
|
||||
const RecentBook currentBook = RECENT_BOOKS.getBookByPath(filePath);
|
||||
const uint8_t effectiveFontFamily =
|
||||
currentBook.fontFamilyOverride >= 0 ? static_cast<uint8_t>(currentBook.fontFamilyOverride) : SETTINGS.fontFamily;
|
||||
const uint8_t effectiveFontSize =
|
||||
currentBook.fontSizeOverride >= 0 ? static_cast<uint8_t>(currentBook.fontSizeOverride) : SETTINGS.fontSize;
|
||||
auto getEffectiveFontId = [&](uint8_t family, uint8_t size) {
|
||||
switch (family) {
|
||||
case CrossPointSettings::NOTOSANS:
|
||||
switch (size) {
|
||||
case CrossPointSettings::SMALL:
|
||||
return NOTOSANS_12_FONT_ID;
|
||||
case CrossPointSettings::LARGE:
|
||||
return NOTOSANS_16_FONT_ID;
|
||||
case CrossPointSettings::EXTRA_LARGE:
|
||||
return NOTOSANS_18_FONT_ID;
|
||||
case CrossPointSettings::MEDIUM:
|
||||
default:
|
||||
return NOTOSANS_14_FONT_ID;
|
||||
}
|
||||
case CrossPointSettings::OPENDYSLEXIC:
|
||||
switch (size) {
|
||||
case CrossPointSettings::SMALL:
|
||||
return OPENDYSLEXIC_8_FONT_ID;
|
||||
case CrossPointSettings::LARGE:
|
||||
return OPENDYSLEXIC_12_FONT_ID;
|
||||
case CrossPointSettings::EXTRA_LARGE:
|
||||
return OPENDYSLEXIC_14_FONT_ID;
|
||||
case CrossPointSettings::MEDIUM:
|
||||
default:
|
||||
return OPENDYSLEXIC_10_FONT_ID;
|
||||
}
|
||||
case CrossPointSettings::BOOKERLY:
|
||||
default:
|
||||
switch (size) {
|
||||
case CrossPointSettings::SMALL:
|
||||
return BOOKERLY_12_FONT_ID;
|
||||
case CrossPointSettings::LARGE:
|
||||
return BOOKERLY_16_FONT_ID;
|
||||
case CrossPointSettings::EXTRA_LARGE:
|
||||
return BOOKERLY_18_FONT_ID;
|
||||
case CrossPointSettings::MEDIUM:
|
||||
default:
|
||||
return BOOKERLY_14_FONT_ID;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
auto section = std::make_unique<Section>(epub, spineIndex, renderer);
|
||||
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering)) {
|
||||
if (!section->loadSectionFile(getEffectiveFontId(effectiveFontFamily, effectiveFontSize),
|
||||
SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing,
|
||||
SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled,
|
||||
SETTINGS.embeddedStyle, SETTINGS.imageRendering)) {
|
||||
LOG_DBG("SLP", "EPUB: section cache not found for spine %d, rebuilding", spineIndex);
|
||||
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering)) {
|
||||
if (!section->createSectionFile(getEffectiveFontId(effectiveFontFamily, effectiveFontSize),
|
||||
SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing,
|
||||
SETTINGS.paragraphAlignment, viewportWidth, viewportHeight,
|
||||
SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, SETTINGS.imageRendering)) {
|
||||
LOG_ERR("SLP", "EPUB: failed to rebuild section cache for spine %d", spineIndex);
|
||||
return false;
|
||||
}
|
||||
@@ -1398,7 +1510,7 @@ bool EpubReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, Gf
|
||||
}
|
||||
|
||||
renderer.clearScreen();
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), marginLeft, marginTop);
|
||||
page->render(renderer, getEffectiveFontId(effectiveFontFamily, effectiveFontSize), marginLeft, marginTop);
|
||||
// No displayBuffer call — caller (SleepActivity) handles that after compositing the overlay
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ class EpubReaderActivity final : public Activity {
|
||||
int cachedChapterTotalPageCount = 0;
|
||||
unsigned long lastPageTurnTime = 0UL;
|
||||
unsigned long pageTurnDuration = 0UL;
|
||||
bool pendingHalfRefreshAfterImagePage = false;
|
||||
// Signals that the next render should reposition within the newly loaded section
|
||||
// based on a cross-book percentage jump.
|
||||
bool pendingPercentJump = false;
|
||||
@@ -52,6 +53,8 @@ class EpubReaderActivity final : public Activity {
|
||||
// -1 means use global SETTINGS value.
|
||||
int8_t bookEmbeddedStyleOverride = -1;
|
||||
int8_t bookImageRenderingOverride = -1;
|
||||
int8_t bookFontFamilyOverride = -1;
|
||||
int8_t bookFontSizeOverride = -1;
|
||||
|
||||
// Bookmarks (starred pages)
|
||||
BookmarkStore bookmarkStore;
|
||||
@@ -87,9 +90,11 @@ class EpubReaderActivity final : public Activity {
|
||||
void applyOrientation(uint8_t orientation);
|
||||
void applyTextDarkness(uint8_t textDarkness);
|
||||
void toggleAutoPageTurn(uint8_t selectedPageTurnOption);
|
||||
void applyBookReaderOverrides(int8_t embeddedStyleOverride, int8_t imageRenderingOverride);
|
||||
void applyBookReaderOverrides(int8_t embeddedStyleOverride, int8_t imageRenderingOverride, int8_t fontFamilyOverride,
|
||||
int8_t fontSizeOverride);
|
||||
bool getEffectiveEmbeddedStyle() const;
|
||||
uint8_t getEffectiveImageRendering() const;
|
||||
int getEffectiveReaderFontId() const;
|
||||
void pageTurn(bool isForwardTurn);
|
||||
|
||||
// Footnote navigation
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/settings/SettingsSubmenuActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
@@ -13,13 +14,16 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInpu
|
||||
const int bookProgressPercent, const uint8_t currentOrientation,
|
||||
const bool hasFootnotes, const int8_t initialEmbeddedStyleOverride,
|
||||
const int8_t initialImageRenderingOverride,
|
||||
const uint8_t initialTextDarkness, const bool hasStarredPages,
|
||||
const bool isCurrentPageStarred)
|
||||
const int8_t initialFontFamilyOverride,
|
||||
const int8_t initialFontSizeOverride, const uint8_t initialTextDarkness,
|
||||
const bool hasStarredPages, const bool isCurrentPageStarred)
|
||||
: MenuListActivity("EpubReaderMenu", renderer, mappedInput),
|
||||
currentPageStarred(isCurrentPageStarred),
|
||||
pendingOrientation(currentOrientation),
|
||||
pendingEmbeddedStyleOverride(initialEmbeddedStyleOverride),
|
||||
pendingImageRenderingOverride(initialImageRenderingOverride),
|
||||
pendingFontFamilyOverride(initialFontFamilyOverride),
|
||||
pendingFontSizeOverride(initialFontSizeOverride),
|
||||
pendingTextDarkness(initialTextDarkness),
|
||||
title(title),
|
||||
currentPage(currentPage),
|
||||
@@ -51,50 +55,6 @@ void EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes, bool hasStarredPa
|
||||
menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_APPEARANCE));
|
||||
|
||||
auto* self = this;
|
||||
|
||||
// Embedded style: cycles default(-1) -> ON(1) -> OFF(0) via DynamicEnum indices 0/1/2
|
||||
menuItems.push_back(SettingInfo::DynamicEnumCtx(
|
||||
StrId::STR_EMBEDDED_STYLE, {StrId::STR_DEFAULT_VALUE, StrId::STR_STATE_ON, StrId::STR_STATE_OFF}, self,
|
||||
[](const void* ctx) -> uint8_t {
|
||||
const auto* s = static_cast<const EpubReaderMenuActivity*>(ctx);
|
||||
if (s->pendingEmbeddedStyleOverride < 0) return 0;
|
||||
if (s->pendingEmbeddedStyleOverride > 0) return 1;
|
||||
return 2;
|
||||
},
|
||||
[](void* ctx, uint8_t v) {
|
||||
auto* s = static_cast<EpubReaderMenuActivity*>(ctx);
|
||||
if (v == 0)
|
||||
s->pendingEmbeddedStyleOverride = -1;
|
||||
else if (v == 1)
|
||||
s->pendingEmbeddedStyleOverride = 1;
|
||||
else
|
||||
s->pendingEmbeddedStyleOverride = 0;
|
||||
}));
|
||||
|
||||
// Image rendering: cycles default(-1) -> display(0) -> placeholder(1) -> suppress(2)
|
||||
menuItems.push_back(SettingInfo::DynamicEnumCtx(
|
||||
StrId::STR_IMAGES,
|
||||
{StrId::STR_DEFAULT_VALUE, StrId::STR_IMAGES_DISPLAY, StrId::STR_IMAGES_PLACEHOLDER, StrId::STR_IMAGES_SUPPRESS},
|
||||
self,
|
||||
[](const void* ctx) -> uint8_t {
|
||||
const auto* s = static_cast<const EpubReaderMenuActivity*>(ctx);
|
||||
return (s->pendingImageRenderingOverride < 0) ? 0 : (s->pendingImageRenderingOverride + 1);
|
||||
},
|
||||
[](void* ctx, uint8_t v) {
|
||||
auto* s = static_cast<EpubReaderMenuActivity*>(ctx);
|
||||
s->pendingImageRenderingOverride = (v == 0) ? -1 : static_cast<int8_t>(v - 1);
|
||||
}));
|
||||
|
||||
// Text darkness: straightforward 0-3 cycle
|
||||
menuItems.push_back(SettingInfo::DynamicEnumCtx(
|
||||
StrId::STR_TEXT_DARKNESS, {StrId::STR_NORMAL, StrId::STR_DARK, StrId::STR_EXTRA_DARK, StrId::STR_MAX_DARK}, self,
|
||||
[](const void* ctx) -> uint8_t { return static_cast<const EpubReaderMenuActivity*>(ctx)->pendingTextDarkness; },
|
||||
[](void* ctx, uint8_t v) { static_cast<EpubReaderMenuActivity*>(ctx)->pendingTextDarkness = v; }));
|
||||
|
||||
// Helper functions, reading ruler, auto page turn, orientation
|
||||
menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_UTILS));
|
||||
// Auto page turn: ACTION type with custom cycling in onActionSelected
|
||||
menuItems.push_back(SettingInfo::Action(StrId::STR_AUTO_TURN_PAGES_PER_MIN, SettingAction::None));
|
||||
// Orientation: straightforward 0-3 cycle
|
||||
menuItems.push_back(SettingInfo::DynamicEnumCtx(
|
||||
StrId::STR_ORIENTATION,
|
||||
@@ -102,6 +62,89 @@ void EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes, bool hasStarredPa
|
||||
[](const void* ctx) -> uint8_t { return static_cast<const EpubReaderMenuActivity*>(ctx)->pendingOrientation; },
|
||||
[](void* ctx, uint8_t v) { static_cast<EpubReaderMenuActivity*>(ctx)->pendingOrientation = v; }));
|
||||
|
||||
// Embedded style: cycles default(-1) -> ON(1) -> OFF(0) via DynamicEnum indices 0/1/2
|
||||
menuItems.push_back(SettingInfo::DynamicEnumCtx(
|
||||
StrId::STR_EMBEDDED_STYLE,
|
||||
{StrId::STR_DEFAULT_VALUE, StrId::STR_STATE_ON, StrId::STR_STATE_OFF}, self,
|
||||
[](const void* ctx) -> uint8_t {
|
||||
const auto* s = static_cast<const EpubReaderMenuActivity*>(ctx);
|
||||
if (s->pendingEmbeddedStyleOverride < 0) return 0;
|
||||
if (s->pendingEmbeddedStyleOverride > 0) return 1;
|
||||
return 2;
|
||||
},
|
||||
[](void* ctx, uint8_t v) {
|
||||
auto* s = static_cast<EpubReaderMenuActivity*>(ctx);
|
||||
if (v == 0)
|
||||
s->pendingEmbeddedStyleOverride = -1;
|
||||
else if (v == 1)
|
||||
s->pendingEmbeddedStyleOverride = 1;
|
||||
else
|
||||
s->pendingEmbeddedStyleOverride = 0;
|
||||
})
|
||||
.withSubmenu(StrId::STR_READER_OVERRIDES));
|
||||
|
||||
// Image rendering: cycles default(-1) -> display(0) -> placeholder(1) -> suppress(2)
|
||||
menuItems.push_back(SettingInfo::DynamicEnumCtx(
|
||||
StrId::STR_IMAGES,
|
||||
{StrId::STR_DEFAULT_VALUE, StrId::STR_IMAGES_DISPLAY, StrId::STR_IMAGES_PLACEHOLDER,
|
||||
StrId::STR_IMAGES_SUPPRESS},
|
||||
self,
|
||||
[](const void* ctx) -> uint8_t {
|
||||
const auto* s = static_cast<const EpubReaderMenuActivity*>(ctx);
|
||||
return (s->pendingImageRenderingOverride < 0) ? 0 : (s->pendingImageRenderingOverride + 1);
|
||||
},
|
||||
[](void* ctx, uint8_t v) {
|
||||
auto* s = static_cast<EpubReaderMenuActivity*>(ctx);
|
||||
s->pendingImageRenderingOverride = (v == 0) ? -1 : static_cast<int8_t>(v - 1);
|
||||
})
|
||||
.withSubmenu(StrId::STR_READER_OVERRIDES));
|
||||
|
||||
// Reader font family: cycles default(-1) -> Bookerly(0) -> Noto Sans(1) -> Open Dyslexic(2)
|
||||
menuItems.push_back(
|
||||
SettingInfo::DynamicEnumCtx(
|
||||
StrId::STR_FONT_FAMILY,
|
||||
{StrId::STR_DEFAULT_VALUE, StrId::STR_BOOKERLY, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC}, self,
|
||||
[](const void* ctx) -> uint8_t {
|
||||
const auto* s = static_cast<const EpubReaderMenuActivity*>(ctx);
|
||||
return (s->pendingFontFamilyOverride < 0) ? 0 : static_cast<uint8_t>(s->pendingFontFamilyOverride + 1);
|
||||
},
|
||||
[](void* ctx, uint8_t v) {
|
||||
auto* s = static_cast<EpubReaderMenuActivity*>(ctx);
|
||||
s->pendingFontFamilyOverride = (v == 0) ? -1 : static_cast<int8_t>(v - 1);
|
||||
})
|
||||
.withSubmenu(StrId::STR_READER_OVERRIDES));
|
||||
|
||||
// Reader font size: cycles default(-1) -> Small(0) -> Medium(1) -> Large(2) -> X Large(3)
|
||||
menuItems.push_back(
|
||||
SettingInfo::DynamicEnumCtx(
|
||||
StrId::STR_FONT_SIZE,
|
||||
{StrId::STR_DEFAULT_VALUE, StrId::STR_SMALL, StrId::STR_MEDIUM, StrId::STR_LARGE, StrId::STR_X_LARGE}, self,
|
||||
[](const void* ctx) -> uint8_t {
|
||||
const auto* s = static_cast<const EpubReaderMenuActivity*>(ctx);
|
||||
return (s->pendingFontSizeOverride < 0) ? 0 : static_cast<uint8_t>(s->pendingFontSizeOverride + 1);
|
||||
},
|
||||
[](void* ctx, uint8_t v) {
|
||||
auto* s = static_cast<EpubReaderMenuActivity*>(ctx);
|
||||
s->pendingFontSizeOverride = (v == 0) ? -1 : static_cast<int8_t>(v - 1);
|
||||
})
|
||||
.withSubmenu(StrId::STR_READER_OVERRIDES));
|
||||
|
||||
// Text darkness: straightforward 0-3 cycle
|
||||
menuItems.push_back(
|
||||
SettingInfo::DynamicEnumCtx(
|
||||
StrId::STR_TEXT_DARKNESS, {StrId::STR_NORMAL, StrId::STR_DARK, StrId::STR_EXTRA_DARK, StrId::STR_MAX_DARK},
|
||||
self,
|
||||
[](const void* ctx) -> uint8_t {
|
||||
return static_cast<const EpubReaderMenuActivity*>(ctx)->pendingTextDarkness;
|
||||
},
|
||||
[](void* ctx, uint8_t v) { static_cast<EpubReaderMenuActivity*>(ctx)->pendingTextDarkness = v; })
|
||||
.withSubmenu(StrId::STR_READER_OVERRIDES));
|
||||
|
||||
// Helper functions, reading ruler, auto page turn, orientation
|
||||
menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_UTILS));
|
||||
// Auto page turn: ACTION type with custom cycling in onActionSelected
|
||||
menuItems.push_back(SettingInfo::Action(StrId::STR_AUTO_TURN_PAGES_PER_MIN, SettingAction::None));
|
||||
|
||||
// --- Synchronisation (only if credentials are set) ---
|
||||
if (KOREADER_STORE.hasCredentials()) {
|
||||
menuItems.push_back(SettingInfo::Separator(StrId::STR_KOREADER_SYNC));
|
||||
@@ -111,9 +154,12 @@ void EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes, bool hasStarredPa
|
||||
|
||||
// --- Tools ---
|
||||
menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_TOOLS));
|
||||
menuItems.push_back(SettingInfo::Action(StrId::STR_SCREENSHOT_BUTTON, SettingAction::None));
|
||||
menuItems.push_back(SettingInfo::Action(StrId::STR_DISPLAY_QR, SettingAction::None));
|
||||
menuItems.push_back(SettingInfo::Action(StrId::STR_DELETE_CACHE, SettingAction::None));
|
||||
menuItems.push_back(
|
||||
SettingInfo::Action(StrId::STR_SCREENSHOT_BUTTON, SettingAction::None).withSubmenu(StrId::STR_READER_TOOLS));
|
||||
menuItems.push_back(
|
||||
SettingInfo::Action(StrId::STR_DISPLAY_QR, SettingAction::None).withSubmenu(StrId::STR_READER_TOOLS));
|
||||
menuItems.push_back(
|
||||
SettingInfo::Action(StrId::STR_DELETE_CACHE, SettingAction::None).withSubmenu(StrId::STR_READER_TOOLS));
|
||||
menuItems.push_back(SettingInfo::Action(StrId::STR_GO_HOME_BUTTON, SettingAction::None));
|
||||
}
|
||||
|
||||
@@ -156,9 +202,20 @@ EpubReaderMenuActivity::MenuAction EpubReaderMenuActivity::actionForNameId(StrId
|
||||
}
|
||||
}
|
||||
|
||||
EpubReaderMenuActivity::MenuAction EpubReaderMenuActivity::actionForSettingAction(SettingAction action) {
|
||||
switch (action) {
|
||||
case SettingAction::None:
|
||||
case SettingAction::Submenu:
|
||||
return MenuAction::NONE;
|
||||
default:
|
||||
return MenuAction::NONE;
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReaderMenuActivity::finishWithAction(MenuAction action) {
|
||||
setResult(MenuResult{static_cast<int>(action), pendingOrientation, selectedPageTurnOption,
|
||||
pendingEmbeddedStyleOverride, pendingImageRenderingOverride, pendingTextDarkness});
|
||||
setResult(MenuResult{static_cast<int>(action), -1, pendingOrientation, selectedPageTurnOption,
|
||||
pendingEmbeddedStyleOverride, pendingImageRenderingOverride, pendingFontFamilyOverride,
|
||||
pendingFontSizeOverride, pendingTextDarkness});
|
||||
finish();
|
||||
}
|
||||
|
||||
@@ -184,10 +241,13 @@ void EpubReaderMenuActivity::onBackPressed() {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
result.data = MenuResult{-1,
|
||||
-1,
|
||||
pendingOrientation,
|
||||
selectedPageTurnOption,
|
||||
pendingEmbeddedStyleOverride,
|
||||
pendingImageRenderingOverride,
|
||||
pendingFontFamilyOverride,
|
||||
pendingFontSizeOverride,
|
||||
pendingTextDarkness};
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
@@ -207,13 +267,119 @@ std::string EpubReaderMenuActivity::getItemValueString(int index) const {
|
||||
return currentPageStarred ? std::string(tr(STR_STATE_ON)) : std::string(tr(STR_STATE_OFF));
|
||||
}
|
||||
|
||||
// Plain ACTION items (select chapter, screenshot, etc.) show no value
|
||||
if (item.type == SettingType::ACTION) return {};
|
||||
if (item.type == SettingType::ACTION) {
|
||||
if (item.action == SettingAction::Submenu) {
|
||||
return MenuListActivity::getItemValueString(index);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
if (item.type == SettingType::ENUM) {
|
||||
if (item.nameId == StrId::STR_EMBEDDED_STYLE && pendingEmbeddedStyleOverride < 0) {
|
||||
const auto defaultEffective = (SETTINGS.embeddedStyle != 0) ? tr(STR_STATE_ON) : tr(STR_STATE_OFF);
|
||||
return std::string(tr(STR_DEFAULT_VALUE)) + " (" + defaultEffective + ")";
|
||||
}
|
||||
if (item.nameId == StrId::STR_IMAGES && pendingImageRenderingOverride < 0) {
|
||||
const auto defaultIndex = static_cast<size_t>(SETTINGS.imageRendering + 1);
|
||||
if (defaultIndex < item.enumValues.size()) {
|
||||
return std::string(tr(STR_DEFAULT_VALUE)) + " (" + I18N.get(item.enumValues[defaultIndex]) + ")";
|
||||
}
|
||||
}
|
||||
if (item.nameId == StrId::STR_FONT_FAMILY && pendingFontFamilyOverride < 0) {
|
||||
const auto defaultIndex = static_cast<size_t>(SETTINGS.fontFamily + 1);
|
||||
if (defaultIndex < item.enumValues.size()) {
|
||||
return std::string(tr(STR_DEFAULT_VALUE)) + " (" + I18N.get(item.enumValues[defaultIndex]) + ")";
|
||||
}
|
||||
}
|
||||
if (item.nameId == StrId::STR_FONT_SIZE && pendingFontSizeOverride < 0) {
|
||||
const auto defaultIndex = static_cast<size_t>(SETTINGS.fontSize + 1);
|
||||
if (defaultIndex < item.enumValues.size()) {
|
||||
return std::string(tr(STR_DEFAULT_VALUE)) + " (" + I18N.get(item.enumValues[defaultIndex]) + ")";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DynamicEnum items use the standard display
|
||||
return MenuListActivity::getItemValueString(index);
|
||||
}
|
||||
|
||||
void EpubReaderMenuActivity::openSubmenu(const SettingInfo& submenuEntry) {
|
||||
auto it = std::find_if(submenuData.begin(), submenuData.end(),
|
||||
[&submenuEntry](const SettingInfo::SubmenuData& d) { return d.id == submenuEntry.nameId; });
|
||||
if (it == submenuData.end()) return;
|
||||
|
||||
auto itemValueStringOverride = [this](const SettingInfo& item) -> std::string {
|
||||
if (item.nameId == StrId::STR_EMBEDDED_STYLE && pendingEmbeddedStyleOverride < 0) {
|
||||
const auto defaultEffective = (SETTINGS.embeddedStyle != 0) ? tr(STR_STATE_ON) : tr(STR_STATE_OFF);
|
||||
return std::string(tr(STR_DEFAULT_VALUE)) + " (" + defaultEffective + ")";
|
||||
}
|
||||
if (item.nameId == StrId::STR_IMAGES && pendingImageRenderingOverride < 0) {
|
||||
const auto valueIndex = static_cast<size_t>(SETTINGS.imageRendering + 1);
|
||||
if (valueIndex < item.enumValues.size()) {
|
||||
return std::string(tr(STR_DEFAULT_VALUE)) + " (" + I18N.get(item.enumValues[valueIndex]) + ")";
|
||||
}
|
||||
}
|
||||
if (item.nameId == StrId::STR_FONT_FAMILY && pendingFontFamilyOverride < 0) {
|
||||
const auto valueIndex = static_cast<size_t>(SETTINGS.fontFamily + 1);
|
||||
if (valueIndex < item.enumValues.size()) {
|
||||
return std::string(tr(STR_DEFAULT_VALUE)) + " (" + I18N.get(item.enumValues[valueIndex]) + ")";
|
||||
}
|
||||
}
|
||||
if (item.nameId == StrId::STR_FONT_SIZE && pendingFontSizeOverride < 0) {
|
||||
const auto valueIndex = static_cast<size_t>(SETTINGS.fontSize + 1);
|
||||
if (valueIndex < item.enumValues.size()) {
|
||||
return std::string(tr(STR_DEFAULT_VALUE)) + " (" + I18N.get(item.enumValues[valueIndex]) + ")";
|
||||
}
|
||||
}
|
||||
return item.getDisplayValue();
|
||||
};
|
||||
|
||||
startActivityForResult(std::make_unique<SettingsSubmenuActivity>(renderer, mappedInput, submenuEntry.nameId,
|
||||
it->items, std::move(itemValueStringOverride)),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto* menuResult = std::get_if<MenuResult>(&result.data);
|
||||
if (menuResult) {
|
||||
if (menuResult->action != -1) {
|
||||
const auto action =
|
||||
actionForSettingAction(static_cast<SettingAction>(menuResult->action));
|
||||
if (action != MenuAction::NONE) {
|
||||
finishWithAction(action);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (menuResult->nameId != -1) {
|
||||
const auto action = actionForNameId(static_cast<StrId>(menuResult->nameId));
|
||||
if (action != MenuAction::NONE) {
|
||||
finishWithAction(action);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
void EpubReaderMenuActivity::toggleCurrentItem() {
|
||||
if (selectedIndex < 0 || selectedIndex >= static_cast<int>(menuItems.size())) return;
|
||||
const auto& item = menuItems[selectedIndex];
|
||||
if (item.isSeparator) return;
|
||||
|
||||
if (item.type == SettingType::ACTION) {
|
||||
if (item.action == SettingAction::Submenu) {
|
||||
openSubmenu(item);
|
||||
return;
|
||||
}
|
||||
onActionSelected(selectedIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
menuItems[selectedIndex].toggleValue();
|
||||
onSettingToggled(selectedIndex);
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void EpubReaderMenuActivity::onEnter() { MenuListActivity::onEnter(); }
|
||||
|
||||
void EpubReaderMenuActivity::render(RenderLock&&) {
|
||||
|
||||
@@ -35,6 +35,7 @@ class EpubReaderMenuActivity final : public MenuListActivity {
|
||||
const int currentPage, const int totalPages, const int bookProgressPercent,
|
||||
const uint8_t currentOrientation, const bool hasFootnotes,
|
||||
const int8_t initialEmbeddedStyleOverride, const int8_t initialImageRenderingOverride,
|
||||
const int8_t initialFontFamilyOverride, const int8_t initialFontSizeOverride,
|
||||
const uint8_t initialTextDarkness, const bool hasStarredPages,
|
||||
const bool isCurrentPageStarred);
|
||||
|
||||
@@ -52,15 +53,20 @@ class EpubReaderMenuActivity final : public MenuListActivity {
|
||||
void onActionSelected(int index) override;
|
||||
void onBackPressed() override;
|
||||
void onSettingToggled(int index) override;
|
||||
void toggleCurrentItem() override;
|
||||
void openSubmenu(const SettingInfo& submenuEntry);
|
||||
|
||||
// Map from StrId to MenuAction for result passing
|
||||
static MenuAction actionForNameId(StrId nameId);
|
||||
static MenuAction actionForSettingAction(SettingAction action);
|
||||
|
||||
// Pending state (mutated locally, returned to parent on finish)
|
||||
uint8_t pendingOrientation = 0;
|
||||
uint8_t selectedPageTurnOption = 0;
|
||||
int8_t pendingEmbeddedStyleOverride = -1;
|
||||
int8_t pendingImageRenderingOverride = -1;
|
||||
int8_t pendingFontFamilyOverride = -1;
|
||||
int8_t pendingFontSizeOverride = -1;
|
||||
uint8_t pendingTextDarkness = 1;
|
||||
|
||||
static constexpr const char* pageTurnLabels[] = {"", "1", "3", "6", "12"};
|
||||
|
||||
@@ -0,0 +1,895 @@
|
||||
#include "MdReaderActivity.h"
|
||||
|
||||
#include <FontCacheManager.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Serialization.h>
|
||||
#include <Utf8.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <functional>
|
||||
#include <numeric>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "MdReaderTocSelectionActivity.h"
|
||||
#include "ReaderUtils.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
constexpr size_t CHUNK_SIZE = 8 * 1024;
|
||||
constexpr size_t MAX_LINE_LENGTH = 64 * 1024;
|
||||
constexpr unsigned long HEADING_SKIP_MS = 700;
|
||||
constexpr uint32_t CACHE_MAGIC = 0x4D4B4449; // "MKDI"
|
||||
constexpr uint8_t CACHE_VERSION = 3; // Bumped: nested list indent + task checkboxes
|
||||
|
||||
static std::string flattenHeadingText(const MdParser::ParsedLine& parsed) {
|
||||
std::string result;
|
||||
for (const auto& span : parsed.spans) {
|
||||
result += span.text;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void MdReaderActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
if (!txt) {
|
||||
return;
|
||||
}
|
||||
|
||||
ReaderUtils::applyOrientation(renderer, SETTINGS.orientation);
|
||||
|
||||
txt->setupCacheDir();
|
||||
|
||||
auto filePath = txt->getPath();
|
||||
auto fileName = filePath.substr(filePath.rfind('/') + 1);
|
||||
APP_STATE.openEpubPath = filePath;
|
||||
APP_STATE.saveToFile();
|
||||
RECENT_BOOKS.addBook(filePath, fileName, "", "", "");
|
||||
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void MdReaderActivity::assignHeadingPageNumbers() {
|
||||
if (pageOffsets.empty()) {
|
||||
return;
|
||||
}
|
||||
for (auto& heading : headings) {
|
||||
const auto it = std::upper_bound(pageOffsets.begin(), pageOffsets.end(), heading.offset);
|
||||
heading.pageIndex = static_cast<int>((it == pageOffsets.begin()) ? 0 : (it - pageOffsets.begin() - 1));
|
||||
}
|
||||
}
|
||||
|
||||
int MdReaderActivity::getHeadingIndexForOffset(size_t offset) const {
|
||||
if (headings.empty()) {
|
||||
return -1;
|
||||
}
|
||||
int index = -1;
|
||||
for (int i = 0; i < static_cast<int>(headings.size()); i++) {
|
||||
if (headings[i].offset <= offset) {
|
||||
index = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
void MdReaderActivity::jumpToHeading(bool next) {
|
||||
if (headings.empty() || pageOffsets.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t currentOffset = (currentPage >= 0 && currentPage < totalPages) ? pageOffsets[currentPage] : 0;
|
||||
int headingIndex = getHeadingIndexForOffset(currentOffset);
|
||||
|
||||
if (headingIndex < 0) {
|
||||
headingIndex = next ? 0 : static_cast<int>(headings.size()) - 1;
|
||||
} else {
|
||||
headingIndex += next ? 1 : -1;
|
||||
}
|
||||
|
||||
if (headingIndex < 0) {
|
||||
headingIndex = 0;
|
||||
} else if (headingIndex >= static_cast<int>(headings.size())) {
|
||||
headingIndex = static_cast<int>(headings.size()) - 1;
|
||||
}
|
||||
|
||||
const size_t headingOffset = headings[headingIndex].offset;
|
||||
const auto it = std::upper_bound(pageOffsets.begin(), pageOffsets.end(), headingOffset);
|
||||
if (it == pageOffsets.begin()) {
|
||||
currentPage = 0;
|
||||
} else {
|
||||
currentPage = static_cast<int>(it - pageOffsets.begin() - 1);
|
||||
}
|
||||
currentHeadingIndex = headingIndex;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void MdReaderActivity::scanHeadings() {
|
||||
headings.clear();
|
||||
if (!txt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t fileSize = txt->getFileSize();
|
||||
if (fileSize == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::string pending;
|
||||
size_t pendingOffset = 0;
|
||||
bool hasPending = false;
|
||||
bool inCodeBlock = false;
|
||||
|
||||
pageBuffer.resize(CHUNK_SIZE + 1);
|
||||
size_t offset = 0;
|
||||
|
||||
while (offset < fileSize) {
|
||||
const size_t toRead = std::min(CHUNK_SIZE, fileSize - offset);
|
||||
if (!txt->readContent(pageBuffer.data(), offset, toRead)) {
|
||||
return;
|
||||
}
|
||||
pageBuffer[toRead] = '\0';
|
||||
|
||||
size_t pos = 0;
|
||||
while (pos < toRead) {
|
||||
size_t lineEnd = pos;
|
||||
while (lineEnd < toRead && pageBuffer[lineEnd] != '\n') {
|
||||
lineEnd++;
|
||||
}
|
||||
|
||||
const bool hasNewline = (lineEnd < toRead && pageBuffer[lineEnd] == '\n');
|
||||
const bool fileHasMore = (offset + toRead < fileSize);
|
||||
const size_t rawLen = lineEnd - pos;
|
||||
const bool hasCR = (rawLen > 0 && pageBuffer[pos + rawLen - 1] == '\r');
|
||||
const size_t displayLen = hasCR ? rawLen - 1 : rawLen;
|
||||
const size_t lineStartOffset = hasPending ? pendingOffset : (offset + pos);
|
||||
|
||||
std::string rawLine;
|
||||
if (hasPending) {
|
||||
rawLine = std::move(pending);
|
||||
pending.clear();
|
||||
hasPending = false;
|
||||
}
|
||||
rawLine.append(reinterpret_cast<char*>(pageBuffer.data() + pos), displayLen);
|
||||
|
||||
if (!hasNewline && fileHasMore) {
|
||||
if (!hasPending) {
|
||||
pendingOffset = lineStartOffset;
|
||||
}
|
||||
pending = std::move(rawLine);
|
||||
hasPending = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (MdParser::isCodeFence(rawLine)) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
}
|
||||
const MdParser::ParsedLine parsed = MdParser::parseLine(rawLine, inCodeBlock);
|
||||
if (parsed.blockType == MdParser::BlockType::Header1 || parsed.blockType == MdParser::BlockType::Header2 ||
|
||||
parsed.blockType == MdParser::BlockType::Header3) {
|
||||
int level = 1;
|
||||
if (parsed.blockType == MdParser::BlockType::Header2) {
|
||||
level = 2;
|
||||
} else if (parsed.blockType == MdParser::BlockType::Header3) {
|
||||
level = 3;
|
||||
}
|
||||
headings.push_back({lineStartOffset, level, flattenHeadingText(parsed)});
|
||||
}
|
||||
|
||||
pos = hasNewline ? lineEnd + 1 : lineEnd;
|
||||
}
|
||||
|
||||
offset += toRead;
|
||||
}
|
||||
|
||||
if (hasPending) {
|
||||
if (MdParser::isCodeFence(pending)) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
}
|
||||
const MdParser::ParsedLine parsed = MdParser::parseLine(pending, inCodeBlock);
|
||||
if (parsed.blockType == MdParser::BlockType::Header1 || parsed.blockType == MdParser::BlockType::Header2 ||
|
||||
parsed.blockType == MdParser::BlockType::Header3) {
|
||||
int level = 1;
|
||||
if (parsed.blockType == MdParser::BlockType::Header2) {
|
||||
level = 2;
|
||||
} else if (parsed.blockType == MdParser::BlockType::Header3) {
|
||||
level = 3;
|
||||
}
|
||||
headings.push_back({pendingOffset, level, flattenHeadingText(parsed)});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MdReaderActivity::onExit() {
|
||||
Activity::onExit();
|
||||
|
||||
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||
|
||||
pageOffsets.clear();
|
||||
pageCodeBlockState.clear();
|
||||
currentPageLines.clear();
|
||||
APP_STATE.readerActivityLoadCount = 0;
|
||||
APP_STATE.saveToFile();
|
||||
txt.reset();
|
||||
}
|
||||
|
||||
void MdReaderActivity::loop() {
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
|
||||
activityManager.goToFileBrowser(txt ? txt->getPath() : "");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
|
||||
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
|
||||
onGoHome();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) && !headings.empty()) {
|
||||
currentHeadingIndex = getHeadingIndexForOffset(pageOffsets[currentPage]);
|
||||
ReaderUtils::enforceExitFullRefresh(renderer);
|
||||
startActivityForResult(
|
||||
std::make_unique<MdReaderTocSelectionActivity>(renderer, mappedInput, headings, currentHeadingIndex),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
currentPage = std::get<PageResult>(result.data).page;
|
||||
currentHeadingIndex = getHeadingIndexForOffset(pageOffsets[currentPage]);
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
auto [prevTriggered, nextTriggered] = ReaderUtils::detectPageTurn(mappedInput);
|
||||
if (!prevTriggered && !nextTriggered) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool headingSkip = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > HEADING_SKIP_MS;
|
||||
if (headingSkip && !headings.empty()) {
|
||||
jumpToHeading(nextTriggered);
|
||||
return;
|
||||
}
|
||||
|
||||
if (prevTriggered && currentPage > 0) {
|
||||
currentPage--;
|
||||
currentHeadingIndex = getHeadingIndexForOffset(pageOffsets[currentPage]);
|
||||
requestUpdate();
|
||||
} else if (nextTriggered) {
|
||||
if (currentPage < totalPages - 1) {
|
||||
currentPage++;
|
||||
currentHeadingIndex = getHeadingIndexForOffset(pageOffsets[currentPage]);
|
||||
requestUpdate();
|
||||
} else {
|
||||
onGoHome();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MdReaderActivity::initializeReader() {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
cachedFontId = SETTINGS.getReaderFontId();
|
||||
cachedScreenMargin = SETTINGS.screenMargin;
|
||||
cachedParagraphAlignment = SETTINGS.paragraphAlignment;
|
||||
|
||||
renderer.getOrientedViewableTRBL(&cachedOrientedMarginTop, &cachedOrientedMarginRight, &cachedOrientedMarginBottom,
|
||||
&cachedOrientedMarginLeft);
|
||||
cachedOrientedMarginTop += cachedScreenMargin;
|
||||
cachedOrientedMarginLeft += cachedScreenMargin;
|
||||
cachedOrientedMarginRight += cachedScreenMargin;
|
||||
cachedOrientedMarginBottom +=
|
||||
std::max(cachedScreenMargin, static_cast<uint8_t>(UITheme::getInstance().getStatusBarHeight()));
|
||||
|
||||
viewportWidth = renderer.getScreenWidth() - cachedOrientedMarginLeft - cachedOrientedMarginRight;
|
||||
const int viewportHeight = renderer.getScreenHeight() - cachedOrientedMarginTop - cachedOrientedMarginBottom;
|
||||
const int lineHeight = renderer.getLineHeight(cachedFontId);
|
||||
|
||||
pageBuffer.reserve(CHUNK_SIZE + 1);
|
||||
scanHeadings();
|
||||
|
||||
linesPerPage = viewportHeight / lineHeight;
|
||||
if (linesPerPage < 1) linesPerPage = 1;
|
||||
|
||||
LOG_DBG("MDR", "Viewport: %dx%d, lines per page: %d", viewportWidth, viewportHeight, linesPerPage);
|
||||
|
||||
if (!loadPageIndexCache()) {
|
||||
buildPageIndex();
|
||||
savePageIndexCache();
|
||||
}
|
||||
assignHeadingPageNumbers();
|
||||
|
||||
loadProgress();
|
||||
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
int MdReaderActivity::measureSpans(const std::vector<MdParser::Span>& spans) const {
|
||||
return std::accumulate(spans.begin(), spans.end(), 0, [this](int acc, const MdParser::Span& span) {
|
||||
return acc + (span.text.empty() ? 0 : renderer.getTextAdvanceX(cachedFontId, span.text.c_str(), span.style));
|
||||
});
|
||||
}
|
||||
|
||||
bool MdReaderActivity::wordWrapParsedLine(const MdParser::ParsedLine& parsed, int indent,
|
||||
std::vector<RenderedLine>& outLines, int maxLines, bool isCodeBlock) {
|
||||
const size_t startSize = outLines.size();
|
||||
|
||||
if (parsed.spans.empty()) {
|
||||
RenderedLine rl;
|
||||
rl.indent = indent;
|
||||
rl.isHR = (parsed.blockType == MdParser::BlockType::HorizontalRule);
|
||||
outLines.push_back(std::move(rl));
|
||||
return true;
|
||||
}
|
||||
|
||||
const int availableWidth = viewportWidth - indent;
|
||||
if (availableWidth <= 0) return true;
|
||||
|
||||
// Build a flat list of all spans, prepending the list prefix if present.
|
||||
std::vector<MdParser::Span> allSpans;
|
||||
if (!parsed.listPrefix.empty()) {
|
||||
allSpans.push_back({parsed.listPrefix, EpdFontFamily::REGULAR});
|
||||
}
|
||||
allSpans.insert(allSpans.end(), parsed.spans.begin(), parsed.spans.end());
|
||||
|
||||
const int listPrefixIndent =
|
||||
!parsed.listPrefix.empty()
|
||||
? renderer.getTextAdvanceX(cachedFontId, parsed.listPrefix.c_str(), EpdFontFamily::REGULAR)
|
||||
: 0;
|
||||
|
||||
// Check if everything fits on one line
|
||||
int totalWidth = measureSpans(allSpans);
|
||||
if (totalWidth <= availableWidth) {
|
||||
RenderedLine rl;
|
||||
rl.spans = std::move(allSpans);
|
||||
rl.indent = indent;
|
||||
rl.isCodeBlock = isCodeBlock;
|
||||
outLines.push_back(std::move(rl));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Word-wrap across spans
|
||||
const int continuationIndent = indent + listPrefixIndent;
|
||||
RenderedLine currentLine;
|
||||
currentLine.indent = indent;
|
||||
currentLine.isCodeBlock = isCodeBlock;
|
||||
int currentWidth = 0;
|
||||
bool fullyConsumed = true;
|
||||
|
||||
for (size_t si = 0; si < allSpans.size(); si++) {
|
||||
const auto& span = allSpans[si];
|
||||
if (span.text.empty()) continue;
|
||||
|
||||
int spanWidth = renderer.getTextAdvanceX(cachedFontId, span.text.c_str(), span.style);
|
||||
|
||||
if (currentWidth + spanWidth <= availableWidth) {
|
||||
currentLine.spans.push_back(span);
|
||||
currentWidth += spanWidth;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Need to break within this span
|
||||
std::string remaining = span.text;
|
||||
auto style = span.style;
|
||||
|
||||
while (!remaining.empty()) {
|
||||
// Check line limit (using lines added, not total size)
|
||||
if (static_cast<int>(outLines.size() - startSize) >= maxLines) {
|
||||
fullyConsumed = false;
|
||||
goto done;
|
||||
}
|
||||
|
||||
int remWidth = renderer.getTextAdvanceX(cachedFontId, remaining.c_str(), style);
|
||||
|
||||
if (currentWidth + remWidth <= availableWidth) {
|
||||
currentLine.spans.push_back({remaining, style});
|
||||
currentWidth += remWidth;
|
||||
remaining.clear();
|
||||
break;
|
||||
}
|
||||
|
||||
size_t breakPos = remaining.size();
|
||||
|
||||
while (breakPos > 0 && renderer.getTextAdvanceX(cachedFontId, remaining.substr(0, breakPos).c_str(), style) >
|
||||
availableWidth - currentWidth) {
|
||||
size_t spacePos = remaining.rfind(' ', breakPos - 1);
|
||||
if (spacePos != std::string::npos && spacePos > 0) {
|
||||
breakPos = spacePos;
|
||||
} else {
|
||||
breakPos--;
|
||||
while (breakPos > 0 && (remaining[breakPos] & 0xC0) == 0x80) {
|
||||
breakPos--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (breakPos == 0) {
|
||||
if (currentLine.spans.empty()) {
|
||||
breakPos = 1;
|
||||
while (breakPos < remaining.size() && (remaining[breakPos] & 0xC0) == 0x80) {
|
||||
breakPos++;
|
||||
}
|
||||
} else {
|
||||
outLines.push_back(std::move(currentLine));
|
||||
currentLine = RenderedLine();
|
||||
currentLine.indent = continuationIndent;
|
||||
currentLine.isCodeBlock = isCodeBlock;
|
||||
currentWidth = 0;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
currentLine.spans.push_back({remaining.substr(0, breakPos), style});
|
||||
outLines.push_back(std::move(currentLine));
|
||||
currentLine = RenderedLine();
|
||||
currentLine.indent = continuationIndent;
|
||||
currentLine.isCodeBlock = isCodeBlock;
|
||||
currentWidth = 0;
|
||||
|
||||
size_t skip = breakPos;
|
||||
if (skip < remaining.size() && remaining[skip] == ' ') {
|
||||
skip++;
|
||||
}
|
||||
remaining = remaining.substr(skip);
|
||||
}
|
||||
}
|
||||
|
||||
done:
|
||||
if (!currentLine.spans.empty()) {
|
||||
outLines.push_back(std::move(currentLine));
|
||||
}
|
||||
return fullyConsumed;
|
||||
}
|
||||
|
||||
bool MdReaderActivity::loadPageAtOffset(size_t offset, bool startInCodeBlock, std::vector<RenderedLine>& outLines,
|
||||
size_t& nextOffset, bool& endInCodeBlock) {
|
||||
outLines.clear();
|
||||
endInCodeBlock = startInCodeBlock;
|
||||
const size_t fileSize = txt->getFileSize();
|
||||
|
||||
if (offset >= fileSize) {
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t bufferSize = std::min(CHUNK_SIZE, fileSize - offset);
|
||||
pageBuffer.resize(bufferSize + 1);
|
||||
if (!txt->readContent(pageBuffer.data(), offset, bufferSize)) {
|
||||
return false;
|
||||
}
|
||||
pageBuffer[bufferSize] = '\0';
|
||||
|
||||
bool inCodeBlock = startInCodeBlock;
|
||||
size_t pos = 0;
|
||||
|
||||
while (pos < bufferSize && static_cast<int>(outLines.size()) < linesPerPage) {
|
||||
// Find end of line and extend the read buffer if we are at chunk boundary.
|
||||
size_t lineEnd = pos;
|
||||
while (lineEnd < bufferSize && pageBuffer[lineEnd] != '\n') {
|
||||
lineEnd++;
|
||||
}
|
||||
|
||||
while (lineEnd == bufferSize && offset + bufferSize < fileSize && bufferSize < MAX_LINE_LENGTH) {
|
||||
size_t extra = std::min(CHUNK_SIZE, fileSize - offset - bufferSize);
|
||||
if (bufferSize + extra > MAX_LINE_LENGTH) {
|
||||
extra = MAX_LINE_LENGTH - bufferSize;
|
||||
}
|
||||
if (extra == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
pageBuffer.resize(bufferSize + extra + 1);
|
||||
if (!txt->readContent(pageBuffer.data() + bufferSize, offset + bufferSize, extra)) {
|
||||
return false;
|
||||
}
|
||||
bufferSize += extra;
|
||||
pageBuffer[bufferSize] = '\0';
|
||||
|
||||
while (lineEnd < bufferSize && pageBuffer[lineEnd] != '\n') {
|
||||
lineEnd++;
|
||||
}
|
||||
}
|
||||
|
||||
const bool isAtBufferEnd = (lineEnd == bufferSize);
|
||||
const bool isEOF = (offset + bufferSize >= fileSize);
|
||||
const bool lineComplete = (lineEnd < bufferSize) || isEOF || (isAtBufferEnd && bufferSize >= MAX_LINE_LENGTH);
|
||||
if (!lineComplete && !outLines.empty()) {
|
||||
// Incomplete line at chunk boundary and we already have content — stop here
|
||||
break;
|
||||
}
|
||||
|
||||
size_t lineContentLen = lineEnd - pos;
|
||||
bool hasCR = (lineContentLen > 0 && pageBuffer[pos + lineContentLen - 1] == '\r');
|
||||
size_t displayLen = hasCR ? lineContentLen - 1 : lineContentLen;
|
||||
|
||||
std::string rawLine(reinterpret_cast<char*>(pageBuffer.data() + pos), displayLen);
|
||||
|
||||
// Check for code fence toggle
|
||||
bool wasFence = false;
|
||||
if (MdParser::isCodeFence(rawLine)) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
wasFence = true;
|
||||
}
|
||||
|
||||
// Parse the markdown line
|
||||
MdParser::ParsedLine parsed;
|
||||
if (wasFence) {
|
||||
// Fence lines produce no visible output
|
||||
parsed.blockType = MdParser::BlockType::CodeBlock;
|
||||
} else {
|
||||
parsed = MdParser::parseLine(rawLine, inCodeBlock);
|
||||
}
|
||||
|
||||
// Determine indent (base + nesting level)
|
||||
int indent = 0;
|
||||
switch (parsed.blockType) {
|
||||
case MdParser::BlockType::UnorderedList:
|
||||
case MdParser::BlockType::OrderedList:
|
||||
indent = LIST_INDENT + parsed.indentLevel * LIST_INDENT;
|
||||
break;
|
||||
case MdParser::BlockType::Blockquote:
|
||||
indent = BLOCKQUOTE_INDENT;
|
||||
break;
|
||||
case MdParser::BlockType::CodeBlock:
|
||||
if (!wasFence) indent = CODE_INDENT;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Word-wrap and add to output (skip fence lines)
|
||||
if (!wasFence) {
|
||||
size_t linesBefore = outLines.size();
|
||||
int remainingLines = linesPerPage - static_cast<int>(outLines.size());
|
||||
bool fullyConsumed = wordWrapParsedLine(parsed, indent, outLines, remainingLines,
|
||||
parsed.blockType == MdParser::BlockType::CodeBlock);
|
||||
|
||||
if (!fullyConsumed) {
|
||||
if (linesBefore > 0) {
|
||||
// Page was partially filled — rollback this line and save it for next page
|
||||
outLines.resize(linesBefore);
|
||||
// Don't advance pos — next page re-processes this source line
|
||||
} else {
|
||||
// First line on page is longer than a full page — accept truncation, advance past it
|
||||
pos = lineComplete ? lineEnd + 1 : lineEnd;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Advance past the newline (only if source line was fully consumed)
|
||||
pos = lineEnd + 1;
|
||||
}
|
||||
|
||||
// Ensure progress
|
||||
if (pos == 0 && !outLines.empty()) {
|
||||
pos = 1;
|
||||
}
|
||||
|
||||
nextOffset = offset + pos;
|
||||
if (nextOffset > fileSize) {
|
||||
nextOffset = fileSize;
|
||||
}
|
||||
|
||||
endInCodeBlock = inCodeBlock;
|
||||
return !outLines.empty();
|
||||
}
|
||||
|
||||
void MdReaderActivity::buildPageIndex() {
|
||||
pageOffsets.clear();
|
||||
pageCodeBlockState.clear();
|
||||
|
||||
const size_t fileSize = txt->getFileSize();
|
||||
if (fileSize == 0) {
|
||||
totalPages = 0;
|
||||
LOG_DBG("MDR", "Empty markdown file, no pages");
|
||||
return;
|
||||
}
|
||||
|
||||
pageOffsets.push_back(0);
|
||||
pageCodeBlockState.push_back(0);
|
||||
|
||||
size_t offset = 0;
|
||||
bool inCodeBlock = false;
|
||||
|
||||
LOG_DBG("MDR", "Building page index for %zu bytes...", fileSize);
|
||||
|
||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||
|
||||
while (offset < fileSize) {
|
||||
std::vector<RenderedLine> tempLines;
|
||||
size_t nextOffset = offset;
|
||||
bool nextCodeBlock = inCodeBlock;
|
||||
|
||||
if (!loadPageAtOffset(offset, inCodeBlock, tempLines, nextOffset, nextCodeBlock)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (nextOffset <= offset) {
|
||||
break;
|
||||
}
|
||||
|
||||
offset = nextOffset;
|
||||
inCodeBlock = nextCodeBlock;
|
||||
|
||||
if (offset < fileSize) {
|
||||
pageOffsets.push_back(offset);
|
||||
pageCodeBlockState.push_back(inCodeBlock ? 1 : 0);
|
||||
}
|
||||
|
||||
if (pageOffsets.size() % 20 == 0) {
|
||||
vTaskDelay(1);
|
||||
}
|
||||
}
|
||||
|
||||
totalPages = pageOffsets.size();
|
||||
LOG_DBG("MDR", "Built page index: %d pages", totalPages);
|
||||
}
|
||||
|
||||
void MdReaderActivity::render(RenderLock&&) {
|
||||
if (!txt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!initialized) {
|
||||
initializeReader();
|
||||
}
|
||||
|
||||
if (pageOffsets.empty()) {
|
||||
renderer.clearScreen();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_EMPTY_FILE), true, EpdFontFamily::BOLD);
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPage < 0) currentPage = 0;
|
||||
if (currentPage >= totalPages) currentPage = totalPages - 1;
|
||||
|
||||
// Load current page
|
||||
size_t offset = pageOffsets[currentPage];
|
||||
bool startCodeBlock =
|
||||
(currentPage < static_cast<int>(pageCodeBlockState.size())) ? pageCodeBlockState[currentPage] : false;
|
||||
size_t nextOffset;
|
||||
bool endCodeBlock;
|
||||
currentPageLines.clear();
|
||||
loadPageAtOffset(offset, startCodeBlock, currentPageLines, nextOffset, endCodeBlock);
|
||||
|
||||
renderer.clearScreen();
|
||||
renderPage();
|
||||
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
void MdReaderActivity::renderPage() {
|
||||
const int lineHeight = renderer.getLineHeight(cachedFontId);
|
||||
|
||||
std::function<void()> renderLines = [&]() {
|
||||
int y = cachedOrientedMarginTop;
|
||||
for (const auto& line : currentPageLines) {
|
||||
if (line.isHR) {
|
||||
// Draw horizontal rule as a thin line
|
||||
int hrY = y + lineHeight / 2;
|
||||
renderer.drawLine(cachedOrientedMarginLeft + line.indent, hrY, cachedOrientedMarginLeft + viewportWidth, hrY);
|
||||
y += lineHeight;
|
||||
} else {
|
||||
if (line.isCodeBlock) {
|
||||
const int barX = cachedOrientedMarginLeft + std::max(line.indent - 6, 0);
|
||||
renderer.drawLine(barX, y + 2, barX, y + lineHeight - 2);
|
||||
}
|
||||
if (!line.spans.empty()) {
|
||||
int x = cachedOrientedMarginLeft + line.indent;
|
||||
|
||||
// Apply text alignment for non-indented lines
|
||||
if (line.indent == 0) {
|
||||
int contentWidth = viewportWidth;
|
||||
switch (cachedParagraphAlignment) {
|
||||
case CrossPointSettings::CENTER_ALIGN: {
|
||||
x = cachedOrientedMarginLeft + (contentWidth - measureSpans(line.spans)) / 2;
|
||||
break;
|
||||
}
|
||||
case CrossPointSettings::RIGHT_ALIGN: {
|
||||
x = cachedOrientedMarginLeft + contentWidth - measureSpans(line.spans);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Render each span
|
||||
for (const auto& span : line.spans) {
|
||||
if (!span.text.empty()) {
|
||||
renderer.drawText(cachedFontId, x, y, span.text.c_str(), true, span.style);
|
||||
x += renderer.getTextAdvanceX(cachedFontId, span.text.c_str(), span.style);
|
||||
}
|
||||
}
|
||||
}
|
||||
y += lineHeight;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Font prewarm: scan pass accumulates text, then prewarm, then real render
|
||||
auto* fcm = renderer.getFontCacheManager();
|
||||
auto scope = fcm->createPrewarmScope();
|
||||
renderLines();
|
||||
scope.endScanAndPrewarm();
|
||||
|
||||
// BW rendering
|
||||
renderLines();
|
||||
renderStatusBar();
|
||||
|
||||
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
|
||||
|
||||
if (SETTINGS.textAntiAliasing) {
|
||||
ReaderUtils::renderAntiAliased(renderer, [&]() { renderLines(); });
|
||||
}
|
||||
}
|
||||
|
||||
void MdReaderActivity::renderStatusBar() const {
|
||||
const float progress = totalPages > 0 ? (currentPage + 1) * 100.0f / totalPages : 0;
|
||||
std::string title;
|
||||
if (SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE) {
|
||||
title = txt->getTitle();
|
||||
}
|
||||
GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title);
|
||||
}
|
||||
|
||||
void MdReaderActivity::saveProgress() const {
|
||||
FsFile f;
|
||||
if (Storage.openFileForWrite("MDR", txt->getCachePath() + "/progress.bin", f)) {
|
||||
uint32_t page = static_cast<uint32_t>(currentPage < 0 ? 0 : currentPage);
|
||||
uint8_t data[4];
|
||||
data[0] = page & 0xFF;
|
||||
data[1] = (page >> 8) & 0xFF;
|
||||
data[2] = (page >> 16) & 0xFF;
|
||||
data[3] = (page >> 24) & 0xFF;
|
||||
f.write(data, 4);
|
||||
}
|
||||
}
|
||||
|
||||
void MdReaderActivity::loadProgress() {
|
||||
FsFile f;
|
||||
if (Storage.openFileForRead("MDR", txt->getCachePath() + "/progress.bin", f)) {
|
||||
uint8_t data[4];
|
||||
if (f.read(data, 4) == 4) {
|
||||
uint32_t loadedPage = static_cast<uint32_t>(data[0]) | (static_cast<uint32_t>(data[1]) << 8) |
|
||||
(static_cast<uint32_t>(data[2]) << 16) | (static_cast<uint32_t>(data[3]) << 24);
|
||||
if (totalPages == 0) {
|
||||
currentPage = 0;
|
||||
} else if (loadedPage >= static_cast<uint32_t>(totalPages)) {
|
||||
currentPage = totalPages - 1;
|
||||
} else {
|
||||
currentPage = static_cast<int>(loadedPage);
|
||||
}
|
||||
LOG_DBG("MDR", "Loaded progress: page %d/%d", currentPage, totalPages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool MdReaderActivity::loadPageIndexCache() {
|
||||
std::string cachePath = txt->getCachePath() + "/index.bin";
|
||||
FsFile f;
|
||||
if (!Storage.openFileForRead("MDR", cachePath, f)) {
|
||||
LOG_DBG("MDR", "No page index cache found");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t magic;
|
||||
serialization::readPod(f, magic);
|
||||
if (magic != CACHE_MAGIC) {
|
||||
LOG_DBG("MDR", "Cache magic mismatch, rebuilding");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(f, version);
|
||||
if (version != CACHE_VERSION) {
|
||||
LOG_DBG("MDR", "Cache version mismatch (%d != %d), rebuilding", version, CACHE_VERSION);
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t fileSize;
|
||||
serialization::readPod(f, fileSize);
|
||||
if (fileSize != txt->getFileSize()) {
|
||||
LOG_DBG("MDR", "Cache file size mismatch, rebuilding");
|
||||
return false;
|
||||
}
|
||||
|
||||
int32_t cachedWidth;
|
||||
serialization::readPod(f, cachedWidth);
|
||||
if (cachedWidth != viewportWidth) {
|
||||
LOG_DBG("MDR", "Cache viewport width mismatch, rebuilding");
|
||||
return false;
|
||||
}
|
||||
|
||||
int32_t cachedLines;
|
||||
serialization::readPod(f, cachedLines);
|
||||
if (cachedLines != linesPerPage) {
|
||||
LOG_DBG("MDR", "Cache lines per page mismatch, rebuilding");
|
||||
return false;
|
||||
}
|
||||
|
||||
int32_t fontId;
|
||||
serialization::readPod(f, fontId);
|
||||
if (fontId != cachedFontId) {
|
||||
LOG_DBG("MDR", "Cache font ID mismatch, rebuilding");
|
||||
return false;
|
||||
}
|
||||
|
||||
int32_t margin;
|
||||
serialization::readPod(f, margin);
|
||||
if (margin != cachedScreenMargin) {
|
||||
LOG_DBG("MDR", "Cache screen margin mismatch, rebuilding");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t alignment;
|
||||
serialization::readPod(f, alignment);
|
||||
if (alignment != cachedParagraphAlignment) {
|
||||
LOG_DBG("MDR", "Cache paragraph alignment mismatch, rebuilding");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t numPages;
|
||||
serialization::readPod(f, numPages);
|
||||
|
||||
pageOffsets.clear();
|
||||
// Sanity check: reject corrupt cache with absurd page count
|
||||
if (numPages == 0 || numPages > 100000) {
|
||||
LOG_DBG("MDR", "Cache page count out of range (%u), rebuilding", numPages);
|
||||
return false;
|
||||
}
|
||||
|
||||
pageOffsets.reserve(numPages);
|
||||
pageCodeBlockState.clear();
|
||||
pageCodeBlockState.reserve(numPages);
|
||||
|
||||
for (uint32_t i = 0; i < numPages; i++) {
|
||||
uint32_t pageOffset;
|
||||
serialization::readPod(f, pageOffset);
|
||||
uint8_t codeState;
|
||||
serialization::readPod(f, codeState);
|
||||
pageOffsets.push_back(pageOffset);
|
||||
pageCodeBlockState.push_back(codeState);
|
||||
}
|
||||
|
||||
totalPages = pageOffsets.size();
|
||||
LOG_DBG("MDR", "Loaded page index cache: %d pages", totalPages);
|
||||
return true;
|
||||
}
|
||||
|
||||
void MdReaderActivity::savePageIndexCache() const {
|
||||
std::string cachePath = txt->getCachePath() + "/index.bin";
|
||||
FsFile f;
|
||||
if (!Storage.openFileForWrite("MDR", cachePath, f)) {
|
||||
LOG_ERR("MDR", "Failed to save page index cache");
|
||||
return;
|
||||
}
|
||||
|
||||
serialization::writePod(f, CACHE_MAGIC);
|
||||
serialization::writePod(f, CACHE_VERSION);
|
||||
serialization::writePod(f, static_cast<uint32_t>(txt->getFileSize()));
|
||||
serialization::writePod(f, static_cast<int32_t>(viewportWidth));
|
||||
serialization::writePod(f, static_cast<int32_t>(linesPerPage));
|
||||
serialization::writePod(f, static_cast<int32_t>(cachedFontId));
|
||||
serialization::writePod(f, static_cast<int32_t>(cachedScreenMargin));
|
||||
serialization::writePod(f, cachedParagraphAlignment);
|
||||
serialization::writePod(f, static_cast<uint32_t>(pageOffsets.size()));
|
||||
|
||||
for (size_t i = 0; i < pageOffsets.size(); i++) {
|
||||
serialization::writePod(f, static_cast<uint32_t>(pageOffsets[i]));
|
||||
serialization::writePod(f, pageCodeBlockState[i]);
|
||||
}
|
||||
|
||||
LOG_DBG("MDR", "Saved page index cache: %d pages", totalPages);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
#pragma once
|
||||
|
||||
#include <MdParser.h>
|
||||
#include <Txt.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "activities/Activity.h"
|
||||
|
||||
struct MdHeading {
|
||||
size_t offset = 0;
|
||||
int level = 1;
|
||||
std::string title;
|
||||
int pageIndex = -1;
|
||||
};
|
||||
|
||||
class MdReaderActivity final : public Activity {
|
||||
std::unique_ptr<Txt> txt;
|
||||
|
||||
int currentPage = 0;
|
||||
int totalPages = 1;
|
||||
int pagesUntilFullRefresh = 0;
|
||||
|
||||
// A single rendered line on screen (after word-wrapping)
|
||||
struct RenderedLine {
|
||||
std::vector<MdParser::Span> spans;
|
||||
int indent = 0; // left indent in pixels
|
||||
bool isHR = false; // draw as horizontal rule
|
||||
bool isCodeBlock = false;
|
||||
};
|
||||
|
||||
// Streaming reader state
|
||||
std::vector<size_t> pageOffsets;
|
||||
std::vector<uint8_t> pageCodeBlockState; // 1 if page starts inside a code block
|
||||
std::vector<RenderedLine> currentPageLines;
|
||||
std::vector<uint8_t> pageBuffer;
|
||||
|
||||
std::vector<MdHeading> headings;
|
||||
int currentHeadingIndex = -1;
|
||||
|
||||
int linesPerPage = 0;
|
||||
int viewportWidth = 0;
|
||||
bool initialized = false;
|
||||
|
||||
// Cached settings for cache validation
|
||||
int cachedFontId = 0;
|
||||
uint8_t cachedScreenMargin = 0;
|
||||
uint8_t cachedParagraphAlignment = CrossPointSettings::LEFT_ALIGN;
|
||||
int cachedOrientedMarginTop = 0;
|
||||
int cachedOrientedMarginRight = 0;
|
||||
int cachedOrientedMarginBottom = 0;
|
||||
int cachedOrientedMarginLeft = 0;
|
||||
|
||||
// Indent constants (in pixels)
|
||||
static constexpr int LIST_INDENT = 20;
|
||||
static constexpr int BLOCKQUOTE_INDENT = 16;
|
||||
static constexpr int CODE_INDENT = 8;
|
||||
|
||||
void renderPage();
|
||||
void renderStatusBar() const;
|
||||
|
||||
void initializeReader();
|
||||
bool loadPageAtOffset(size_t offset, bool startInCodeBlock, std::vector<RenderedLine>& outLines, size_t& nextOffset,
|
||||
bool& endInCodeBlock);
|
||||
void buildPageIndex();
|
||||
bool loadPageIndexCache();
|
||||
void savePageIndexCache() const;
|
||||
void saveProgress() const;
|
||||
void loadProgress();
|
||||
void scanHeadings();
|
||||
void assignHeadingPageNumbers();
|
||||
int getHeadingIndexForOffset(size_t offset) const;
|
||||
void jumpToHeading(bool next);
|
||||
|
||||
// Word-wrap a parsed markdown line into one or more RenderedLines.
|
||||
// Returns true if all content was emitted, false if truncated by maxLines.
|
||||
bool wordWrapParsedLine(const MdParser::ParsedLine& parsed, int indent, std::vector<RenderedLine>& outLines,
|
||||
int maxLines, bool isCodeBlock = false);
|
||||
|
||||
// Measure total pixel width of a span list
|
||||
int measureSpans(const std::vector<MdParser::Span>& spans) const;
|
||||
|
||||
public:
|
||||
explicit MdReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Txt> txt)
|
||||
: Activity("MdReader", renderer, mappedInput), txt(std::move(txt)) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
bool isReaderActivity() const override { return true; }
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
#include "MdReaderTocSelectionActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
int MdReaderTocSelectionActivity::getTotalItems() const { return static_cast<int>(headings.size()); }
|
||||
|
||||
int MdReaderTocSelectionActivity::getPageItems() const {
|
||||
constexpr int lineHeight = 30;
|
||||
const Rect contentRect = UITheme::getContentRect(renderer, true, false);
|
||||
const int startY = 60 + contentRect.y;
|
||||
const int availableHeight = contentRect.y + contentRect.height - startY - lineHeight;
|
||||
return std::max(1, availableHeight / lineHeight);
|
||||
}
|
||||
|
||||
void MdReaderTocSelectionActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
if (selectorIndex < 0 || selectorIndex >= getTotalItems()) {
|
||||
selectorIndex = 0;
|
||||
}
|
||||
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void MdReaderTocSelectionActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void MdReaderTocSelectionActivity::loop() {
|
||||
const int pageItems = getPageItems();
|
||||
const int totalItems = getTotalItems();
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (selectorIndex >= 0 && selectorIndex < totalItems) {
|
||||
setResult(PageResult{static_cast<uint32_t>(headings[selectorIndex].pageIndex)});
|
||||
} else {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
}
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this, totalItems] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousRelease([this, totalItems] {
|
||||
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
void MdReaderTocSelectionActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const Rect contentRect = UITheme::getContentRect(renderer, true, false);
|
||||
const int pageItems = getPageItems();
|
||||
const int totalItems = getTotalItems();
|
||||
|
||||
const int titleX =
|
||||
contentRect.x +
|
||||
(contentRect.width - renderer.getTextWidth(UI_12_FONT_ID, tr(STR_SELECT_CHAPTER), EpdFontFamily::BOLD)) / 2;
|
||||
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentRect.y, tr(STR_SELECT_CHAPTER), true, EpdFontFamily::BOLD);
|
||||
|
||||
const int pageStartIndex = selectorIndex / pageItems * pageItems;
|
||||
renderer.fillRect(contentRect.x, 60 + contentRect.y + (selectorIndex % pageItems) * 30 - 2, contentRect.width - 1,
|
||||
30);
|
||||
|
||||
for (int i = 0; i < pageItems; i++) {
|
||||
int itemIndex = pageStartIndex + i;
|
||||
if (itemIndex >= totalItems) break;
|
||||
const int displayY = 60 + contentRect.y + i * 30;
|
||||
const bool isSelected = (itemIndex == selectorIndex);
|
||||
|
||||
const auto& heading = headings[itemIndex];
|
||||
const int indentRelative = 20 + (heading.level - 1) * 10;
|
||||
const int drawX = contentRect.x + indentRelative;
|
||||
const std::string title =
|
||||
renderer.truncatedText(UI_10_FONT_ID, heading.title.c_str(), contentRect.width - 40 - indentRelative);
|
||||
renderer.drawText(UI_10_FONT_ID, drawX, displayY, title.c_str(), !isSelected);
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
#include "MdReaderActivity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class MdReaderTocSelectionActivity final : public Activity {
|
||||
std::vector<MdHeading> headings;
|
||||
ButtonNavigator buttonNavigator;
|
||||
int selectorIndex = 0;
|
||||
|
||||
int getPageItems() const;
|
||||
int getTotalItems() const;
|
||||
|
||||
public:
|
||||
explicit MdReaderTocSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
std::vector<MdHeading> headings, int currentHeadingIndex)
|
||||
: Activity("MdReaderTocSelection", renderer, mappedInput), headings(std::move(headings)), selectorIndex(0) {
|
||||
if (currentHeadingIndex >= 0 && currentHeadingIndex < static_cast<int>(headings.size())) {
|
||||
selectorIndex = currentHeadingIndex;
|
||||
}
|
||||
}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
};
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "CrossPointState.h"
|
||||
#include "Epub.h"
|
||||
#include "EpubReaderActivity.h"
|
||||
#include "MdReaderActivity.h"
|
||||
#include "Txt.h"
|
||||
#include "TxtReaderActivity.h"
|
||||
#include "Xtc.h"
|
||||
@@ -46,10 +47,9 @@ std::string ReaderActivity::extractFolderPath(const std::string& filePath) {
|
||||
|
||||
bool ReaderActivity::isXtcFile(const std::string& path) { return FsHelpers::hasXtcExtension(path); }
|
||||
|
||||
bool ReaderActivity::isTxtFile(const std::string& path) {
|
||||
return FsHelpers::hasTxtExtension(path) ||
|
||||
FsHelpers::hasMarkdownExtension(path); // Treat .md as txt files (until we have a markdown reader)
|
||||
}
|
||||
bool ReaderActivity::isTxtFile(const std::string& path) { return FsHelpers::hasTxtExtension(path); }
|
||||
|
||||
bool ReaderActivity::isMdFile(const std::string& path) { return FsHelpers::hasMarkdownExtension(path); }
|
||||
|
||||
bool ReaderActivity::isImageFile(const std::string& path) {
|
||||
return FsHelpers::hasBmpExtension(path) || FsHelpers::hasJpgExtension(path) || FsHelpers::hasPngExtension(path);
|
||||
@@ -130,6 +130,12 @@ void ReaderActivity::onGoToTxtReader(std::unique_ptr<Txt> txt) {
|
||||
activityManager.replaceActivity(std::make_unique<TxtReaderActivity>(renderer, mappedInput, std::move(txt)));
|
||||
}
|
||||
|
||||
void ReaderActivity::onGoToMdReader(std::unique_ptr<Txt> txt) {
|
||||
const auto txtPath = txt->getPath();
|
||||
currentBookPath = txtPath;
|
||||
activityManager.replaceActivity(std::make_unique<MdReaderActivity>(renderer, mappedInput, std::move(txt)));
|
||||
}
|
||||
|
||||
void ReaderActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
logReaderLaunchMemSnapshot("onEnter_begin");
|
||||
@@ -161,6 +167,13 @@ void ReaderActivity::onEnter() {
|
||||
return;
|
||||
}
|
||||
onGoToXtcReader(std::move(xtc));
|
||||
} else if (isMdFile(initialBookPath)) {
|
||||
auto txt = loadTxt(initialBookPath);
|
||||
if (!txt) {
|
||||
onGoBack();
|
||||
return;
|
||||
}
|
||||
onGoToMdReader(std::move(txt));
|
||||
} else if (isTxtFile(initialBookPath)) {
|
||||
auto txt = loadTxt(initialBookPath);
|
||||
if (!txt) {
|
||||
|
||||
@@ -16,6 +16,7 @@ class ReaderActivity final : public Activity {
|
||||
static std::unique_ptr<Txt> loadTxt(const std::string& path);
|
||||
static bool isXtcFile(const std::string& path);
|
||||
static bool isTxtFile(const std::string& path);
|
||||
static bool isMdFile(const std::string& path);
|
||||
static bool isImageFile(const std::string& path);
|
||||
|
||||
static std::string extractFolderPath(const std::string& filePath);
|
||||
@@ -23,6 +24,7 @@ class ReaderActivity final : public Activity {
|
||||
void onGoToEpubReader(std::unique_ptr<Epub> epub);
|
||||
void onGoToXtcReader(std::unique_ptr<Xtc> xtc);
|
||||
void onGoToTxtReader(std::unique_ptr<Txt> txt);
|
||||
void onGoToMdReader(std::unique_ptr<Txt> txt);
|
||||
void onGoToBmpViewer(const std::string& path);
|
||||
|
||||
void onGoBack();
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
#include "CalibreSettingsActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
constexpr int MENU_ITEMS = 3;
|
||||
const StrId menuNames[MENU_ITEMS] = {StrId::STR_CALIBRE_WEB_URL, StrId::STR_USERNAME, StrId::STR_PASSWORD};
|
||||
} // namespace
|
||||
|
||||
void CalibreSettingsActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
selectedIndex = 0;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void CalibreSettingsActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void CalibreSettingsActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
handleSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = (selectedIndex + 1) % MENU_ITEMS;
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this] {
|
||||
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
void CalibreSettingsActivity::handleSelection() {
|
||||
if (selectedIndex == 0) {
|
||||
// OPDS Server URL
|
||||
startActivityForResult(std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_CALIBRE_WEB_URL),
|
||||
SETTINGS.opdsServerUrl, 127, InputType::Url),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kb = std::get<KeyboardResult>(result.data);
|
||||
strncpy(SETTINGS.opdsServerUrl, kb.text.c_str(), sizeof(SETTINGS.opdsServerUrl) - 1);
|
||||
SETTINGS.opdsServerUrl[sizeof(SETTINGS.opdsServerUrl) - 1] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
});
|
||||
} else if (selectedIndex == 1) {
|
||||
// Username
|
||||
startActivityForResult(std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_USERNAME),
|
||||
SETTINGS.opdsUsername, 63, InputType::Text),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kb = std::get<KeyboardResult>(result.data);
|
||||
strncpy(SETTINGS.opdsUsername, kb.text.c_str(), sizeof(SETTINGS.opdsUsername) - 1);
|
||||
SETTINGS.opdsUsername[sizeof(SETTINGS.opdsUsername) - 1] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
});
|
||||
} else if (selectedIndex == 2) {
|
||||
// Password
|
||||
startActivityForResult(std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_PASSWORD),
|
||||
SETTINGS.opdsPassword, 63, InputType::Password),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kb = std::get<KeyboardResult>(result.data);
|
||||
strncpy(SETTINGS.opdsPassword, kb.text.c_str(), sizeof(SETTINGS.opdsPassword) - 1);
|
||||
SETTINGS.opdsPassword[sizeof(SETTINGS.opdsPassword) - 1] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreSettingsActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const Rect contentRect = UITheme::getContentRect(renderer, true, false);
|
||||
GUI.drawHeader(renderer, Rect{contentRect.x, metrics.topPadding, contentRect.width, metrics.headerHeight},
|
||||
tr(STR_OPDS_BROWSER));
|
||||
GUI.drawSubHeader(
|
||||
renderer, Rect{contentRect.x, metrics.topPadding + metrics.headerHeight, contentRect.width, metrics.tabBarHeight},
|
||||
tr(STR_CALIBRE_URL_HINT));
|
||||
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.tabBarHeight;
|
||||
const int contentHeight = contentRect.height - contentTop - metrics.verticalSpacing * 2;
|
||||
GUI.drawList(
|
||||
renderer, Rect{contentRect.x, contentTop, contentRect.width, contentHeight}, static_cast<int>(MENU_ITEMS),
|
||||
static_cast<int>(selectedIndex), [](int index) { return std::string(I18N.get(menuNames[index])); }, nullptr,
|
||||
nullptr,
|
||||
[this](int index) {
|
||||
// Draw status for each setting
|
||||
if (index == 0) {
|
||||
return (strlen(SETTINGS.opdsServerUrl) > 0) ? std::string(SETTINGS.opdsServerUrl)
|
||||
: std::string(tr(STR_NOT_SET));
|
||||
} else if (index == 1) {
|
||||
return (strlen(SETTINGS.opdsUsername) > 0) ? std::string(SETTINGS.opdsUsername)
|
||||
: std::string(tr(STR_NOT_SET));
|
||||
} else if (index == 2) {
|
||||
return (strlen(SETTINGS.opdsPassword) > 0) ? std::string("******") : std::string(tr(STR_NOT_SET));
|
||||
}
|
||||
return std::string(tr(STR_NOT_SET));
|
||||
},
|
||||
true);
|
||||
|
||||
// Draw help text at bottom
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
/**
|
||||
* Submenu for OPDS Browser settings.
|
||||
* Shows OPDS Server URL and HTTP authentication options.
|
||||
*/
|
||||
class CalibreSettingsActivity final : public Activity {
|
||||
public:
|
||||
explicit CalibreSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
: Activity("CalibreSettings", renderer, mappedInput) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
size_t selectedIndex = 0;
|
||||
void handleSelection();
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
#include "OpdsServerListActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "OpdsSettingsActivity.h"
|
||||
#include "activities/ActivityManager.h"
|
||||
#include "activities/browser/OpdsBookBrowserActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
int OpdsServerListActivity::getItemCount() const {
|
||||
int count = static_cast<int>(OPDS_STORE.getCount());
|
||||
// In settings mode, append a virtual "Add Server" item; in picker mode, only show real servers
|
||||
if (!pickerMode) {
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
void OpdsServerListActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
// Reload from disk in case servers were added/removed by a subactivity or the web UI
|
||||
OPDS_STORE.loadFromFile();
|
||||
selectedIndex = 0;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void OpdsServerListActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void OpdsServerListActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
if (pickerMode) {
|
||||
activityManager.goHome();
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
handleSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
const int itemCount = getItemCount();
|
||||
if (itemCount > 0) {
|
||||
buttonNavigator.onNext([this, itemCount] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, itemCount);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this, itemCount] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, itemCount);
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsServerListActivity::handleSelection() {
|
||||
const auto serverCount = static_cast<int>(OPDS_STORE.getCount());
|
||||
|
||||
if (pickerMode) {
|
||||
// Picker mode: selecting a server navigates to the OPDS browser
|
||||
if (selectedIndex < serverCount) {
|
||||
const auto* server = OPDS_STORE.getServer(static_cast<size_t>(selectedIndex));
|
||||
if (server) {
|
||||
activityManager.replaceActivity(std::make_unique<OpdsBookBrowserActivity>(renderer, mappedInput, *server));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Settings mode: open editor for selected server, or create a new one
|
||||
auto resultHandler = [this](const ActivityResult&) {
|
||||
// Reload server list when returning from editor
|
||||
OPDS_STORE.loadFromFile();
|
||||
const int itemCount = getItemCount();
|
||||
selectedIndex = itemCount > 0 ? std::min(selectedIndex, itemCount - 1) : 0;
|
||||
};
|
||||
|
||||
if (selectedIndex < serverCount) {
|
||||
startActivityForResult(std::make_unique<OpdsSettingsActivity>(renderer, mappedInput, selectedIndex), resultHandler);
|
||||
} else {
|
||||
startActivityForResult(std::make_unique<OpdsSettingsActivity>(renderer, mappedInput, -1), resultHandler);
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsServerListActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_OPDS_SERVERS));
|
||||
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
|
||||
const int itemCount = getItemCount();
|
||||
|
||||
if (itemCount == 0) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_NO_SERVERS));
|
||||
} else {
|
||||
const auto& servers = OPDS_STORE.getServers();
|
||||
const auto serverCount = static_cast<int>(servers.size());
|
||||
|
||||
// Primary label: server name (falling back to URL if unnamed).
|
||||
// Secondary label: server URL (shown as subtitle when name is set).
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, itemCount, selectedIndex,
|
||||
[&servers, serverCount](int index) {
|
||||
if (index < serverCount) {
|
||||
const auto& server = servers[index];
|
||||
return server.name.empty() ? server.url : server.name;
|
||||
}
|
||||
return std::string(I18n::getInstance().get(StrId::STR_ADD_SERVER));
|
||||
},
|
||||
[&servers, serverCount](int index) {
|
||||
if (index < serverCount && !servers[index].name.empty()) {
|
||||
return servers[index].url;
|
||||
}
|
||||
return std::string("");
|
||||
});
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
/**
|
||||
* Activity showing the list of configured OPDS servers.
|
||||
* Allows adding new servers and editing/deleting existing ones.
|
||||
* When pickerMode is true, selecting a server navigates to the OPDS browser
|
||||
* instead of opening the editor (used from the home screen).
|
||||
*/
|
||||
class OpdsServerListActivity final : public Activity {
|
||||
public:
|
||||
explicit OpdsServerListActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, bool pickerMode = false)
|
||||
: Activity("OpdsServerList", renderer, mappedInput), pickerMode(pickerMode) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
ButtonNavigator buttonNavigator;
|
||||
int selectedIndex = 0;
|
||||
bool pickerMode = false;
|
||||
|
||||
int getItemCount() const;
|
||||
void handleSelection();
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
#include "OpdsSettingsActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
// Editable fields: Name, URL, Username, Password.
|
||||
// Existing servers also show a Delete option (BASE_ITEMS + 1).
|
||||
constexpr int BASE_ITEMS = 4;
|
||||
constexpr char INVALID_OPDS_URL_MESSAGE[] = "Enter a valid OPDS URL";
|
||||
} // namespace
|
||||
|
||||
int OpdsSettingsActivity::getMenuItemCount() const {
|
||||
return isNewServer ? BASE_ITEMS : BASE_ITEMS + 1; // +1 for Delete
|
||||
}
|
||||
|
||||
void OpdsSettingsActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
selectedIndex = 0;
|
||||
isNewServer = (serverIndex < 0);
|
||||
showSaveError = false;
|
||||
popupMessage.clear();
|
||||
|
||||
if (!isNewServer) {
|
||||
// Edit flow: copy the selected server into local editable state.
|
||||
// Changes are persisted field-by-field through saveServer().
|
||||
const auto* server = OPDS_STORE.getServer(static_cast<size_t>(serverIndex));
|
||||
if (server) {
|
||||
editServer = *server;
|
||||
} else {
|
||||
// Server was deleted between navigation and entering this screen — treat as new
|
||||
isNewServer = true;
|
||||
serverIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void OpdsSettingsActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void OpdsSettingsActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
handleSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
const int menuItems = getMenuItemCount();
|
||||
buttonNavigator.onNext([this, menuItems] {
|
||||
selectedIndex = (selectedIndex + 1) % menuItems;
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this, menuItems] {
|
||||
selectedIndex = (selectedIndex + menuItems - 1) % menuItems;
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
bool OpdsSettingsActivity::saveServer() {
|
||||
bool success = false;
|
||||
|
||||
if (isNewServer) {
|
||||
// Create flow: first save inserts a new server record into the multi-server store.
|
||||
const auto insertedIndex = OPDS_STORE.addServer(editServer);
|
||||
success = insertedIndex.has_value();
|
||||
if (success) {
|
||||
// After the first successful save, promote to an existing server so
|
||||
// subsequent field edits update in-place rather than creating duplicates.
|
||||
isNewServer = false;
|
||||
serverIndex = static_cast<int>(*insertedIndex);
|
||||
} else {
|
||||
LOG_ERR("OPS", "Failed to add OPDS server");
|
||||
}
|
||||
} else {
|
||||
// Edit flow: update the same server entry in-place.
|
||||
success = OPDS_STORE.updateServer(static_cast<size_t>(serverIndex), editServer);
|
||||
if (!success) {
|
||||
LOG_ERR("OPS", "Failed to update OPDS server at index %d", serverIndex);
|
||||
}
|
||||
}
|
||||
|
||||
showSaveError = !success;
|
||||
if (success) {
|
||||
popupMessage.clear();
|
||||
}
|
||||
if (showSaveError) {
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
void OpdsSettingsActivity::handleSelection() {
|
||||
// Each field edit is saved immediately so partially configured servers
|
||||
// survive navigation and power-loss scenarios.
|
||||
if (selectedIndex == 0) {
|
||||
// Server Name
|
||||
auto handler = [this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kb = std::get<KeyboardResult>(result.data);
|
||||
editServer.name = kb.text;
|
||||
saveServer();
|
||||
requestUpdate();
|
||||
}
|
||||
};
|
||||
startActivityForResult(
|
||||
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_SERVER_NAME), editServer.name,
|
||||
OpdsServerStore::MAX_NAME_LENGTH, InputType::Text),
|
||||
handler);
|
||||
} else if (selectedIndex == 1) {
|
||||
// Server URL
|
||||
const std::string prefillUrl = editServer.url.empty() ? "https://" : editServer.url;
|
||||
auto handler = [this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kb = std::get<KeyboardResult>(result.data);
|
||||
const auto normalizedUrl = OpdsServerValidation::normalizeUrl(kb.text);
|
||||
if (!normalizedUrl) {
|
||||
popupMessage = INVALID_OPDS_URL_MESSAGE;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
popupMessage.clear();
|
||||
editServer.url = *normalizedUrl;
|
||||
saveServer();
|
||||
requestUpdate();
|
||||
}
|
||||
};
|
||||
startActivityForResult(
|
||||
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_OPDS_SERVER_URL), prefillUrl,
|
||||
OpdsServerStore::MAX_URL_LENGTH, InputType::Url),
|
||||
handler);
|
||||
} else if (selectedIndex == 2) {
|
||||
// Username
|
||||
auto handler = [this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kb = std::get<KeyboardResult>(result.data);
|
||||
editServer.username = kb.text;
|
||||
saveServer();
|
||||
requestUpdate();
|
||||
}
|
||||
};
|
||||
startActivityForResult(
|
||||
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_USERNAME), editServer.username,
|
||||
OpdsServerStore::MAX_USERNAME_LENGTH, InputType::Text),
|
||||
handler);
|
||||
} else if (selectedIndex == 3) {
|
||||
// Password
|
||||
auto handler = [this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kb = std::get<KeyboardResult>(result.data);
|
||||
editServer.password = kb.text;
|
||||
saveServer();
|
||||
requestUpdate();
|
||||
}
|
||||
};
|
||||
startActivityForResult(
|
||||
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_PASSWORD), editServer.password,
|
||||
OpdsServerStore::MAX_PASSWORD_LENGTH, InputType::Password),
|
||||
handler);
|
||||
} else if (selectedIndex == 4 && !isNewServer) {
|
||||
// Delete flow is only available for existing servers.
|
||||
if (!OPDS_STORE.removeServer(static_cast<size_t>(serverIndex))) {
|
||||
LOG_ERR("OPS", "Failed to remove OPDS server at index %d", serverIndex);
|
||||
showSaveError = true;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsSettingsActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
// Reuse STR_OPDS_BROWSER as the "edit existing server" title.
|
||||
// New server creation uses STR_ADD_SERVER.
|
||||
const char* header = isNewServer ? tr(STR_ADD_SERVER) : tr(STR_OPDS_BROWSER);
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, header);
|
||||
GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight},
|
||||
tr(STR_CALIBRE_URL_HINT));
|
||||
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.tabBarHeight;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
|
||||
const int menuItems = getMenuItemCount();
|
||||
|
||||
const StrId fieldNames[] = {StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL, StrId::STR_USERNAME,
|
||||
StrId::STR_PASSWORD};
|
||||
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast<int>(selectedIndex),
|
||||
[this, &fieldNames](int index) {
|
||||
if (index < BASE_ITEMS) {
|
||||
return std::string(I18N.get(fieldNames[index]));
|
||||
}
|
||||
return std::string(tr(STR_DELETE_SERVER));
|
||||
},
|
||||
nullptr, nullptr,
|
||||
[this](int index) {
|
||||
if (index == 0) {
|
||||
return editServer.name.empty() ? std::string(tr(STR_NOT_SET)) : editServer.name;
|
||||
} else if (index == 1) {
|
||||
return editServer.url.empty() ? std::string(tr(STR_NOT_SET)) : editServer.url;
|
||||
} else if (index == 2) {
|
||||
return editServer.username.empty() ? std::string(tr(STR_NOT_SET)) : editServer.username;
|
||||
} else if (index == 3) {
|
||||
return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
|
||||
}
|
||||
return std::string("");
|
||||
},
|
||||
true);
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
if (!popupMessage.empty()) {
|
||||
GUI.drawPopup(renderer, popupMessage.c_str());
|
||||
} else if (showSaveError) {
|
||||
GUI.drawPopup(renderer, tr(STR_ERROR_GENERAL_FAILURE));
|
||||
}
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include "OpdsServerStore.h"
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
/**
|
||||
* Edit screen for a single OPDS server.
|
||||
* Shows Name, URL, Username, Password fields and a Delete option.
|
||||
* Used for both adding new servers and editing existing ones.
|
||||
*/
|
||||
class OpdsSettingsActivity final : public Activity {
|
||||
public:
|
||||
/**
|
||||
* @param serverIndex Index into OpdsServerStore, or -1 for a new server
|
||||
*/
|
||||
explicit OpdsSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, int serverIndex = -1)
|
||||
: Activity("OpdsSettings", renderer, mappedInput), serverIndex(serverIndex) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
size_t selectedIndex = 0;
|
||||
int serverIndex;
|
||||
OpdsServer editServer;
|
||||
bool isNewServer = false;
|
||||
bool showSaveError = false;
|
||||
std::string popupMessage;
|
||||
|
||||
int getMenuItemCount() const;
|
||||
void handleSelection();
|
||||
bool saveServer();
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
#include "SettingActionDispatch.h"
|
||||
|
||||
#include "ButtonRemapActivity.h"
|
||||
#include "CalibreSettingsActivity.h"
|
||||
#include "ClearCacheActivity.h"
|
||||
#include "ClockSettingsActivity.h"
|
||||
#include "DetectTimezoneActivity.h"
|
||||
#include "KOReaderSettingsActivity.h"
|
||||
#include "LanguageSelectActivity.h"
|
||||
#include "OpdsServerListActivity.h"
|
||||
#include "OtaUpdateActivity.h"
|
||||
#include "StatusBarSettingsActivity.h"
|
||||
#include "SyncTimeActivity.h"
|
||||
@@ -26,7 +26,7 @@ std::unique_ptr<Activity> createActivityForAction(SettingAction action, GfxRende
|
||||
case SettingAction::KOReaderSync:
|
||||
return std::make_unique<KOReaderSettingsActivity>(renderer, mappedInput);
|
||||
case SettingAction::OPDSBrowser:
|
||||
return std::make_unique<CalibreSettingsActivity>(renderer, mappedInput);
|
||||
return std::make_unique<OpdsServerListActivity>(renderer, mappedInput);
|
||||
case SettingAction::Network:
|
||||
return std::make_unique<WifiSelectionActivity>(renderer, mappedInput, false);
|
||||
case SettingAction::ClearCache:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#pragma once
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -88,6 +90,13 @@ struct SettingInfo {
|
||||
stringSetter(accessorCtx, v);
|
||||
}
|
||||
|
||||
struct SubmenuData {
|
||||
StrId id = StrId::STR_NONE_OPT;
|
||||
std::vector<SettingInfo> items;
|
||||
};
|
||||
|
||||
static void prepareSubmenus(std::vector<SettingInfo>& items, std::vector<SubmenuData>& submenuData);
|
||||
|
||||
SettingInfo& withObfuscated() {
|
||||
obfuscated = true;
|
||||
return *this;
|
||||
@@ -237,3 +246,41 @@ struct SettingInfo {
|
||||
// not the SettingInfo itself.
|
||||
void toggleValue() const;
|
||||
};
|
||||
|
||||
inline void SettingInfo::prepareSubmenus(std::vector<SettingInfo>& items,
|
||||
std::vector<SettingInfo::SubmenuData>& submenuData) {
|
||||
if (items.empty()) return;
|
||||
|
||||
std::vector<SettingInfo> preparedItems;
|
||||
std::vector<SubmenuData> preparedSubmenus;
|
||||
preparedItems.reserve(items.size());
|
||||
|
||||
for (auto& item : items) {
|
||||
if (item.submenu == StrId::STR_NONE_OPT) {
|
||||
preparedItems.push_back(std::move(item));
|
||||
continue;
|
||||
}
|
||||
|
||||
auto it = std::find_if(preparedSubmenus.begin(), preparedSubmenus.end(),
|
||||
[&item](const SubmenuData& d) { return d.id == item.submenu; });
|
||||
if (it == preparedSubmenus.end()) {
|
||||
preparedItems.push_back(SettingInfo::SubmenuEntry(item.submenu));
|
||||
preparedSubmenus.push_back({item.submenu, {}});
|
||||
it = preparedSubmenus.end() - 1;
|
||||
}
|
||||
it->items.push_back(std::move(item));
|
||||
}
|
||||
|
||||
items.swap(preparedItems);
|
||||
|
||||
for (auto& submenu : preparedSubmenus) {
|
||||
auto it = std::find_if(submenuData.begin(), submenuData.end(),
|
||||
[&submenu](const SubmenuData& d) { return d.id == submenu.id; });
|
||||
if (it == submenuData.end()) {
|
||||
submenuData.push_back(std::move(submenu));
|
||||
} else {
|
||||
it->items.insert(it->items.end(), std::make_move_iterator(submenu.items.begin()),
|
||||
std::make_move_iterator(submenu.items.end()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,33 +41,19 @@ void SettingsActivity::onEnter() {
|
||||
StrId lastControlsSub = StrId::STR_NONE_OPT;
|
||||
StrId lastSystemSub = StrId::STR_NONE_OPT;
|
||||
|
||||
// Shared placement logic — locates submenu target or inserts separator.
|
||||
// Returns the vector the caller should push into (either `vec` or a submenu's items).
|
||||
auto locateTarget = [this](std::vector<SettingInfo>& vec, StrId& lastSub,
|
||||
const SettingInfo& s) -> std::vector<SettingInfo>* {
|
||||
if (s.submenu != StrId::STR_NONE_OPT) {
|
||||
auto it = std::find_if(submenuData.begin(), submenuData.end(),
|
||||
[&s](const SubmenuData& d) { return d.id == s.submenu; });
|
||||
if (it == submenuData.end()) {
|
||||
vec.push_back(SettingInfo::SubmenuEntry(s.submenu));
|
||||
submenuData.push_back({s.submenu, {}});
|
||||
it = submenuData.end() - 1;
|
||||
}
|
||||
return &it->items;
|
||||
}
|
||||
auto addTo = [](std::vector<SettingInfo>& vec, StrId& lastSub, const SettingInfo& s) {
|
||||
if (s.subcategory != StrId::STR_NONE_OPT && s.subcategory != lastSub) {
|
||||
vec.push_back(SettingInfo::Separator(s.subcategory));
|
||||
lastSub = s.subcategory;
|
||||
}
|
||||
return &vec;
|
||||
vec.push_back(s);
|
||||
};
|
||||
|
||||
auto addTo = [&locateTarget](std::vector<SettingInfo>& vec, StrId& lastSub, const SettingInfo& s) {
|
||||
locateTarget(vec, lastSub, s)->push_back(s);
|
||||
};
|
||||
auto addToMoved = [&locateTarget](std::vector<SettingInfo>& vec, StrId& lastSub, SettingInfo&& s) {
|
||||
auto* target = locateTarget(vec, lastSub, s);
|
||||
target->push_back(std::move(s));
|
||||
auto addToMoved = [](std::vector<SettingInfo>& vec, StrId& lastSub, SettingInfo&& s) {
|
||||
if (s.subcategory != StrId::STR_NONE_OPT && s.subcategory != lastSub) {
|
||||
vec.push_back(SettingInfo::Separator(s.subcategory));
|
||||
lastSub = s.subcategory;
|
||||
}
|
||||
vec.push_back(std::move(s));
|
||||
};
|
||||
|
||||
for (const auto& setting : getSettingsList()) {
|
||||
@@ -122,6 +108,11 @@ void SettingsActivity::onEnter() {
|
||||
std::move(SettingInfo::Action(StrId::STR_SYSTEM_INFO, SettingAction::SystemInfo)
|
||||
.withSubcategory(StrId::STR_MENU_SYS_SYSTEM)));
|
||||
|
||||
SettingInfo::prepareSubmenus(displaySettings, submenuData);
|
||||
SettingInfo::prepareSubmenus(readerSettings, submenuData);
|
||||
SettingInfo::prepareSubmenus(controlsSettings, submenuData);
|
||||
SettingInfo::prepareSubmenus(systemSettings, submenuData);
|
||||
|
||||
// Reset selection to first category
|
||||
selectedCategoryIndex = 0;
|
||||
selectedSettingIndex = 0;
|
||||
@@ -222,11 +213,20 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
if (setting.isSeparator) return;
|
||||
|
||||
if (setting.type == SettingType::ACTION) {
|
||||
auto resultHandler = [this](const ActivityResult&) { SETTINGS.saveToFile(); };
|
||||
auto resultHandler = [this](const ActivityResult& result) {
|
||||
SETTINGS.saveToFile();
|
||||
const auto* menuResult = std::get_if<MenuResult>(&result.data);
|
||||
if (menuResult && menuResult->action != -1) {
|
||||
auto activity = createActivityForAction(static_cast<SettingAction>(menuResult->action), renderer, mappedInput);
|
||||
if (activity) {
|
||||
startActivityForResult(std::move(activity), [this](const ActivityResult&) { SETTINGS.saveToFile(); });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (setting.action == SettingAction::Submenu) {
|
||||
auto it = std::find_if(submenuData.begin(), submenuData.end(),
|
||||
[&setting](const SubmenuData& d) { return d.id == setting.nameId; });
|
||||
[&setting](const SettingInfo::SubmenuData& d) { return d.id == setting.nameId; });
|
||||
if (it != submenuData.end()) {
|
||||
startActivityForResult(
|
||||
std::make_unique<SettingsSubmenuActivity>(renderer, mappedInput, setting.nameId, it->items), resultHandler);
|
||||
|
||||
@@ -24,11 +24,7 @@ class SettingsActivity final : public Activity {
|
||||
static constexpr int categoryCount = 4;
|
||||
static const StrId categoryNames[categoryCount];
|
||||
|
||||
struct SubmenuData {
|
||||
StrId id;
|
||||
std::vector<SettingInfo> items;
|
||||
};
|
||||
std::vector<SubmenuData> submenuData;
|
||||
std::vector<SettingInfo::SubmenuData> submenuData;
|
||||
|
||||
void enterCategory(int categoryIndex);
|
||||
void toggleCurrentSetting();
|
||||
|
||||
@@ -9,12 +9,40 @@
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void SettingsSubmenuActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
initMenuList();
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void SettingsSubmenuActivity::onActionSelected(int index) {
|
||||
const auto& setting = menuItems[index];
|
||||
auto resultHandler = [this](const ActivityResult&) { SETTINGS.saveToFile(); };
|
||||
if (setting.isSeparator) return;
|
||||
|
||||
auto activity = createActivityForAction(setting.action, renderer, mappedInput);
|
||||
if (activity) startActivityForResult(std::move(activity), resultHandler);
|
||||
if (setting.type == SettingType::ACTION) {
|
||||
MenuResult menuResult;
|
||||
if (setting.action != SettingAction::None) {
|
||||
menuResult.action = static_cast<int>(setting.action);
|
||||
} else {
|
||||
menuResult.nameId = static_cast<int>(setting.nameId);
|
||||
}
|
||||
setResult(ActivityResult(menuResult));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
onSettingToggled(index);
|
||||
}
|
||||
|
||||
std::string SettingsSubmenuActivity::getItemValueString(int index) const {
|
||||
const auto& item = menuItems[index];
|
||||
if (item.type == SettingType::ACTION && item.action != SettingAction::Submenu) {
|
||||
return {};
|
||||
}
|
||||
if (itemValueStringOverride) {
|
||||
return itemValueStringOverride(item);
|
||||
}
|
||||
return MenuListActivity::getItemValueString(index);
|
||||
}
|
||||
|
||||
void SettingsSubmenuActivity::onSettingToggled(int /*index*/) { SETTINGS.saveToFile(); }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include <I18n.h>
|
||||
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
#include "SettingInfo.h"
|
||||
@@ -10,15 +11,21 @@
|
||||
// Supports subcategory separators (withSubcategory) exactly as the parent settings tabs do.
|
||||
class SettingsSubmenuActivity final : public MenuListActivity {
|
||||
StrId titleId;
|
||||
std::function<std::string(const SettingInfo&)> itemValueStringOverride;
|
||||
|
||||
// MenuListActivity overrides
|
||||
void onEnter() override;
|
||||
void onActionSelected(int index) override;
|
||||
void onSettingToggled(int index) override;
|
||||
std::string getItemValueString(int index) const override;
|
||||
|
||||
public:
|
||||
explicit SettingsSubmenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, StrId titleId,
|
||||
std::vector<SettingInfo> items)
|
||||
: MenuListActivity("SettingsSubmenu", renderer, mappedInput), titleId(titleId) {
|
||||
std::vector<SettingInfo> items,
|
||||
std::function<std::string(const SettingInfo&)> itemValueStringOverride = {})
|
||||
: MenuListActivity("SettingsSubmenu", renderer, mappedInput),
|
||||
titleId(titleId),
|
||||
itemValueStringOverride(std::move(itemValueStringOverride)) {
|
||||
menuItems = std::move(items);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ constexpr int cornerRadius = 6;
|
||||
constexpr int topHintButtonY = 345;
|
||||
constexpr int popupMarginX = 16;
|
||||
constexpr int popupMarginY = 12;
|
||||
constexpr int maxSubtitleWidth = 100;
|
||||
constexpr int maxListValueWidth = 200;
|
||||
constexpr int mainMenuIconSize = 32;
|
||||
constexpr int listIconSize = 24;
|
||||
@@ -307,8 +306,27 @@ void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t
|
||||
renderer.drawText(SMALL_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding, rect.y + 5, clockStr);
|
||||
}
|
||||
|
||||
int maxTitleWidth =
|
||||
rect.width - LyraMetrics::values.contentSidePadding * 2 - (subtitle != nullptr ? maxSubtitleWidth : 0);
|
||||
int maxTitleWidth = title != nullptr ? renderer.getTextWidth(UI_12_FONT_ID, title, EpdFontFamily::BOLD) : 0;
|
||||
int maxSubtitleWidth =
|
||||
subtitle != nullptr ? renderer.getTextWidth(SMALL_FONT_ID, subtitle, EpdFontFamily::REGULAR) : 0;
|
||||
|
||||
// Available space is the distance between the side paddings, and a with side padding between title and subtitle.
|
||||
const int availableSpace = rect.width - LyraMetrics::values.contentSidePadding * 3;
|
||||
|
||||
if (maxTitleWidth + maxSubtitleWidth > availableSpace) {
|
||||
if ((maxTitleWidth > availableSpace / 2) && (maxSubtitleWidth > availableSpace / 2)) {
|
||||
// Both are wider then half the space, truncate both.
|
||||
maxTitleWidth = availableSpace / 2;
|
||||
maxSubtitleWidth = availableSpace / 2;
|
||||
} else {
|
||||
// Truncate the the longest one
|
||||
if (maxTitleWidth > maxSubtitleWidth) {
|
||||
maxTitleWidth = availableSpace - maxSubtitleWidth;
|
||||
} else {
|
||||
maxSubtitleWidth = availableSpace - maxTitleWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (title) {
|
||||
auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title, maxTitleWidth, EpdFontFamily::BOLD);
|
||||
|
||||
+22
-20
@@ -22,6 +22,7 @@
|
||||
#include "GlobalBookmarkIndex.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "WeatherSettingsStore.h"
|
||||
#include "activities/Activity.h"
|
||||
@@ -205,6 +206,26 @@ void setup() {
|
||||
|
||||
LOG_INF("MAIN", "Hardware detect: %s", gpio.deviceIsX3() ? "X3" : "X4");
|
||||
|
||||
// SD Card Initialization
|
||||
// We need 6 open files concurrently when parsing a new chapter
|
||||
if (!Storage.begin()) {
|
||||
LOG_ERR("MAIN", "SD card initialization failed");
|
||||
setupDisplayAndFonts();
|
||||
activityManager.goToFullScreenMessage("SD card error", EpdFontFamily::BOLD);
|
||||
return;
|
||||
}
|
||||
|
||||
HalSystem::checkPanic();
|
||||
SETTINGS.loadFromFile();
|
||||
HalSystem::clearPanic(); // TODO: move this to an activity when we have one to display the panic info
|
||||
HalClock::applyTimezone(SETTINGS.timeZone);
|
||||
I18N.loadSettings();
|
||||
KOREADER_STORE.loadFromFile();
|
||||
OPDS_STORE.loadFromFile();
|
||||
WEATHER_SETTINGS.loadFromFile();
|
||||
UITheme::getInstance().reload();
|
||||
ButtonNavigator::setMappedInputManager(mappedInputManager);
|
||||
|
||||
const auto wakeupReason = gpio.getWakeupReason();
|
||||
LOG_DBG("MAIN", "Wakeup reason: %d, millis=%lu, rawPowerPin=%d", static_cast<int>(wakeupReason), millis(),
|
||||
digitalRead(InputManager::POWER_BUTTON_PIN) == LOW);
|
||||
@@ -233,25 +254,6 @@ void setup() {
|
||||
// First serial output only here to avoid timing inconsistencies for power button press duration verification
|
||||
LOG_DBG("MAIN", "Starting CrossPoint version " CROSSPOINT_VERSION);
|
||||
|
||||
// SD Card Initialization
|
||||
// We need 6 open files concurrently when parsing a new chapter
|
||||
if (!Storage.begin()) {
|
||||
LOG_ERR("MAIN", "SD card initialization failed");
|
||||
setupDisplayAndFonts();
|
||||
activityManager.goToFullScreenMessage("SD card error", EpdFontFamily::BOLD);
|
||||
return;
|
||||
}
|
||||
|
||||
HalSystem::checkPanic();
|
||||
HalSystem::clearPanic(); // TODO: move this to an activity when we have one to display the panic info
|
||||
SETTINGS.loadFromFile();
|
||||
HalClock::applyTimezone(SETTINGS.timeZone);
|
||||
I18N.loadSettings();
|
||||
KOREADER_STORE.loadFromFile();
|
||||
WEATHER_SETTINGS.loadFromFile();
|
||||
UITheme::getInstance().reload();
|
||||
ButtonNavigator::setMappedInputManager(mappedInputManager);
|
||||
|
||||
setupDisplayAndFonts();
|
||||
|
||||
activityManager.goToBoot();
|
||||
@@ -410,4 +412,4 @@ void loop() {
|
||||
delay(10);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <algorithm>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "SettingsList.h"
|
||||
#include "SystemStatus.h"
|
||||
#include "WebDAVHandler.h"
|
||||
@@ -193,6 +194,11 @@ void CrossPointWebServer::begin() {
|
||||
server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); });
|
||||
server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); });
|
||||
|
||||
// OPDS server endpoints
|
||||
server->on("/api/opds", HTTP_GET, [this] { handleGetOpdsServers(); });
|
||||
server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); });
|
||||
server->on("/api/opds/delete", HTTP_POST, [this] { handleDeleteOpdsServer(); });
|
||||
|
||||
server->onNotFound([this] { handleNotFound(); });
|
||||
LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap());
|
||||
|
||||
@@ -1369,6 +1375,167 @@ void CrossPointWebServer::handlePostSettings() {
|
||||
server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s)");
|
||||
}
|
||||
|
||||
// ---- OPDS Server API ----
|
||||
|
||||
void CrossPointWebServer::handleGetOpdsServers() const {
|
||||
const auto& servers = OPDS_STORE.getServers();
|
||||
|
||||
// Stream JSON array incrementally to avoid allocating the full response in memory
|
||||
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||
server->send(200, "application/json", "");
|
||||
server->sendContent("[");
|
||||
|
||||
char output[512];
|
||||
constexpr size_t outputSize = sizeof(output);
|
||||
JsonDocument doc;
|
||||
bool seenFirst = false;
|
||||
|
||||
for (size_t i = 0; i < servers.size(); i++) {
|
||||
doc.clear();
|
||||
doc["index"] = i;
|
||||
doc["name"] = servers[i].name;
|
||||
doc["url"] = servers[i].url;
|
||||
doc["username"] = servers[i].username;
|
||||
// Never expose passwords over the API — only indicate whether one is set
|
||||
doc["hasPassword"] = !servers[i].password.empty();
|
||||
|
||||
const size_t written = serializeJson(doc, output, outputSize);
|
||||
if (written >= outputSize) continue;
|
||||
|
||||
if (seenFirst) {
|
||||
server->sendContent(",");
|
||||
}
|
||||
seenFirst = true;
|
||||
server->sendContent(output);
|
||||
}
|
||||
|
||||
server->sendContent("]");
|
||||
server->sendContent("");
|
||||
LOG_DBG("WEB", "Served OPDS servers API (%zu servers)", servers.size());
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handlePostOpdsServer() {
|
||||
if (!server->hasArg("plain")) {
|
||||
server->send(400, "text/plain", "Missing JSON body");
|
||||
return;
|
||||
}
|
||||
|
||||
const String body = server->arg("plain");
|
||||
JsonDocument doc;
|
||||
const DeserializationError err = deserializeJson(doc, body);
|
||||
if (err) {
|
||||
server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string name = doc["name"] | std::string("");
|
||||
const std::string rawUrl = doc["url"] | std::string("");
|
||||
const std::string username = doc["username"] | std::string("");
|
||||
|
||||
// The password field is optional in the JSON payload. When absent (vs. present but empty),
|
||||
// we preserve the existing password — the web UI omits it when the user hasn't changed it.
|
||||
bool hasPasswordField = doc["password"].is<const char*>() || doc["password"].is<std::string>();
|
||||
std::string password = doc["password"] | std::string("");
|
||||
|
||||
const auto normalizedUrl = OpdsServerValidation::normalizeUrl(rawUrl);
|
||||
if (!normalizedUrl) {
|
||||
server->send(400, "text/plain", "Invalid URL");
|
||||
return;
|
||||
}
|
||||
if (name.size() > OpdsServerStore::MAX_NAME_LENGTH) {
|
||||
server->send(400, "text/plain", "Server name too long");
|
||||
return;
|
||||
}
|
||||
if (normalizedUrl->size() > OpdsServerStore::MAX_URL_LENGTH) {
|
||||
server->send(400, "text/plain", "URL too long");
|
||||
return;
|
||||
}
|
||||
if (username.size() > OpdsServerStore::MAX_USERNAME_LENGTH) {
|
||||
server->send(400, "text/plain", "Username too long");
|
||||
return;
|
||||
}
|
||||
|
||||
OpdsServer opdsServer;
|
||||
opdsServer.name = name;
|
||||
opdsServer.url = *normalizedUrl;
|
||||
opdsServer.username = username;
|
||||
|
||||
if (doc["index"].is<int>()) {
|
||||
int idx = doc["index"].as<int>();
|
||||
if (idx < 0 || idx >= static_cast<int>(OPDS_STORE.getCount())) {
|
||||
server->send(400, "text/plain", "Invalid server index");
|
||||
return;
|
||||
}
|
||||
// Preserve existing password if not explicitly provided
|
||||
if (!hasPasswordField) {
|
||||
const auto* existing = OPDS_STORE.getServer(static_cast<size_t>(idx));
|
||||
if (existing) password = existing->password;
|
||||
}
|
||||
if (password.size() > OpdsServerStore::MAX_PASSWORD_LENGTH) {
|
||||
server->send(400, "text/plain", "Password too long");
|
||||
return;
|
||||
}
|
||||
opdsServer.password = password;
|
||||
if (!OPDS_STORE.updateServer(static_cast<size_t>(idx), opdsServer)) {
|
||||
server->send(500, "text/plain", "Failed to save server");
|
||||
return;
|
||||
}
|
||||
LOG_DBG("WEB", "Updated OPDS server at index %d", idx);
|
||||
} else {
|
||||
if (OPDS_STORE.getCount() >= OpdsServerStore::MAX_SERVERS) {
|
||||
server->send(400, "text/plain", "Cannot add server (limit reached)");
|
||||
return;
|
||||
}
|
||||
if (password.size() > OpdsServerStore::MAX_PASSWORD_LENGTH) {
|
||||
server->send(400, "text/plain", "Password too long");
|
||||
return;
|
||||
}
|
||||
opdsServer.password = password;
|
||||
const auto insertedIndex = OPDS_STORE.addServer(opdsServer);
|
||||
if (!insertedIndex) {
|
||||
server->send(500, "text/plain", "Failed to save server");
|
||||
return;
|
||||
}
|
||||
LOG_DBG("WEB", "Added new OPDS server at index %zu: %s", *insertedIndex, opdsServer.name.c_str());
|
||||
}
|
||||
|
||||
server->send(200, "text/plain", "OK");
|
||||
}
|
||||
|
||||
// Uses POST (not HTTP DELETE) because ESP32 WebServer doesn't support DELETE with body.
|
||||
void CrossPointWebServer::handleDeleteOpdsServer() {
|
||||
if (!server->hasArg("plain")) {
|
||||
server->send(400, "text/plain", "Missing JSON body");
|
||||
return;
|
||||
}
|
||||
|
||||
const String body = server->arg("plain");
|
||||
JsonDocument doc;
|
||||
const DeserializationError err = deserializeJson(doc, body);
|
||||
if (err) {
|
||||
server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!doc["index"].is<int>()) {
|
||||
server->send(400, "text/plain", "Missing index");
|
||||
return;
|
||||
}
|
||||
|
||||
int idx = doc["index"].as<int>();
|
||||
if (idx < 0 || idx >= static_cast<int>(OPDS_STORE.getCount())) {
|
||||
server->send(400, "text/plain", "Invalid server index");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!OPDS_STORE.removeServer(static_cast<size_t>(idx))) {
|
||||
server->send(500, "text/plain", "Failed to delete server");
|
||||
return;
|
||||
}
|
||||
LOG_DBG("WEB", "Deleted OPDS server at index %d", idx);
|
||||
server->send(200, "text/plain", "OK");
|
||||
}
|
||||
|
||||
// WebSocket callback trampoline
|
||||
void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
|
||||
if (wsInstance) {
|
||||
|
||||
@@ -109,4 +109,9 @@ class CrossPointWebServer {
|
||||
void handleSettingsPage() const;
|
||||
void handleGetSettings() const;
|
||||
void handlePostSettings();
|
||||
|
||||
// OPDS server handlers
|
||||
void handleGetOpdsServers() const;
|
||||
void handlePostOpdsServer();
|
||||
void handleDeleteOpdsServer();
|
||||
};
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "util/UrlUtils.h"
|
||||
|
||||
namespace {
|
||||
@@ -52,8 +51,8 @@ class FileWriteStream final : public Stream {
|
||||
};
|
||||
} // namespace
|
||||
|
||||
bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
|
||||
// Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP
|
||||
bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent, const std::string& username,
|
||||
const std::string& password) {
|
||||
std::unique_ptr<NetworkClient> client;
|
||||
if (UrlUtils::isHttpsUrl(url)) {
|
||||
auto* secureClient = new NetworkClientSecure();
|
||||
@@ -71,9 +70,8 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
|
||||
http.setTimeout(30000);
|
||||
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
|
||||
|
||||
// Add Basic HTTP auth if credentials are configured
|
||||
if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) {
|
||||
std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword;
|
||||
if (!username.empty() || !password.empty()) {
|
||||
std::string credentials = username + ":" + password;
|
||||
String encoded = base64::encode(credentials.c_str());
|
||||
http.addHeader("Authorization", "Basic " + encoded);
|
||||
}
|
||||
@@ -93,9 +91,10 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
|
||||
bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent, const std::string& username,
|
||||
const std::string& password) {
|
||||
StreamString stream;
|
||||
if (!fetchUrl(url, stream)) {
|
||||
if (!fetchUrl(url, stream, username, password)) {
|
||||
return false;
|
||||
}
|
||||
outContent = stream.c_str();
|
||||
@@ -103,8 +102,8 @@ bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
|
||||
}
|
||||
|
||||
HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath,
|
||||
ProgressCallback progress) {
|
||||
// Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP
|
||||
ProgressCallback progress, const std::string& username,
|
||||
const std::string& password) {
|
||||
std::unique_ptr<NetworkClient> client;
|
||||
if (UrlUtils::isHttpsUrl(url)) {
|
||||
auto* secureClient = new NetworkClientSecure();
|
||||
@@ -123,9 +122,8 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
|
||||
http.setTimeout(65535); // max uint16_t (~65s) — HTTPClient::setTimeout takes uint16_t ms
|
||||
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
|
||||
|
||||
// Add Basic HTTP auth if credentials are configured
|
||||
if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) {
|
||||
std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword;
|
||||
if (!username.empty() || !password.empty()) {
|
||||
std::string credentials = username + ":" + password;
|
||||
String encoded = base64::encode(credentials.c_str());
|
||||
http.addHeader("Authorization", "Basic " + encoded);
|
||||
}
|
||||
|
||||
@@ -20,22 +20,18 @@ class HttpDownloader {
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch text content from a URL.
|
||||
* @param url The URL to fetch
|
||||
* @param outContent The fetched content (output)
|
||||
* @return true if fetch succeeded, false on error
|
||||
* Fetch text content from a URL with optional credentials.
|
||||
*/
|
||||
static bool fetchUrl(const std::string& url, std::string& outContent);
|
||||
static bool fetchUrl(const std::string& url, std::string& outContent, const std::string& username = "",
|
||||
const std::string& password = "");
|
||||
|
||||
static bool fetchUrl(const std::string& url, Stream& stream);
|
||||
static bool fetchUrl(const std::string& url, Stream& stream, const std::string& username = "",
|
||||
const std::string& password = "");
|
||||
|
||||
/**
|
||||
* Download a file to the SD card.
|
||||
* @param url The URL to download
|
||||
* @param destPath The destination path on SD card
|
||||
* @param progress Optional progress callback
|
||||
* @return DownloadError indicating success or failure type
|
||||
* Download a file to the SD card with optional credentials.
|
||||
*/
|
||||
static DownloadError downloadToFile(const std::string& url, const std::string& destPath,
|
||||
ProgressCallback progress = nullptr);
|
||||
ProgressCallback progress = nullptr, const std::string& username = "",
|
||||
const std::string& password = "");
|
||||
};
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CrossPoint Reader - Files</title>
|
||||
<title>Files - CrossPoint Reader</title>
|
||||
<script src="/js/jszip.min.js"></script>
|
||||
<style>
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-color: #333;
|
||||
--bg: #f5f5f5;
|
||||
@@ -1831,8 +1832,10 @@
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn upload-action-btn" onclick="openUploadModal()">📤 Upload</button>
|
||||
<button class="action-btn folder-action-btn" onclick="openFolderModal()">📁 New Folder</button>
|
||||
<button id="moveActionBtn" class="action-btn move-action-btn" onclick="openMoveSelectedModal()" disabled>➡️ Move</button>
|
||||
<button id="deleteActionBtn" class="action-btn delete-action-btn" onclick="openDeleteSelectedModal()" disabled>🗑️ Delete</button>
|
||||
<button id="moveActionBtn" class="action-btn move-action-btn" onclick="openMoveSelectedModal()" disabled>➡️
|
||||
Move</button>
|
||||
<button id="deleteActionBtn" class="action-btn delete-action-btn" onclick="openDeleteSelectedModal()" disabled>🗑️
|
||||
Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2102,7 +2105,11 @@
|
||||
<script>
|
||||
// get current path from query parameter
|
||||
const currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/');
|
||||
|
||||
if (currentPath !== '/') {
|
||||
const leaf = currentPath.split('/').filter(Boolean).pop();
|
||||
if (leaf) document.title = leaf + ' - Files - CrossPoint Reader';
|
||||
}
|
||||
|
||||
// Network status monitoring
|
||||
let isNetworkOnline = navigator.onLine;
|
||||
|
||||
@@ -2250,7 +2257,10 @@
|
||||
totalSize += file.size;
|
||||
});
|
||||
|
||||
document.getElementById('folder-summary').innerHTML = `${folderCount} folders, ${files.length - folderCount} files, ${formatFileSize(totalSize)}`;
|
||||
const fileCount = files.length - folderCount;
|
||||
const folderLabel = folderCount === 1 ? 'folder' : 'folders';
|
||||
const fileLabel = fileCount === 1 ? 'file' : 'files';
|
||||
document.getElementById('folder-summary').innerHTML = `${folderCount} ${folderLabel}, ${fileCount} ${fileLabel}, ${formatFileSize(totalSize)}`;
|
||||
|
||||
if (files.length === 0) {
|
||||
fileTable.innerHTML = '<div class="no-files">This folder is empty</div>';
|
||||
@@ -2268,9 +2278,10 @@
|
||||
if (file.isDirectory) return 0;
|
||||
const ext = file.name.includes('.') ? file.name.split('.').pop().toLowerCase() : '';
|
||||
if (ext === 'epub') return 1;
|
||||
if (ext === 'xtc') return 2;
|
||||
if (ext === 'xtc' || ext === 'xtch') return 2;
|
||||
if (ext === 'txt') return 3;
|
||||
return 4;
|
||||
if (ext === 'md') return 4;
|
||||
return 5;
|
||||
}
|
||||
|
||||
function sortFiles(files) {
|
||||
@@ -2345,11 +2356,18 @@
|
||||
if (!filePath.endsWith("/")) filePath += "/";
|
||||
filePath += file.name;
|
||||
|
||||
fileTableContent += `<tr class="${file.isEpub ? 'epub-file' : ''}">`;
|
||||
const ext = file.name.includes('.') ? file.name.split('.').pop().toLowerCase() : '';
|
||||
const isXtc = ext === 'xtc' || ext === 'xtch';
|
||||
const isMd = ext === 'md';
|
||||
const fileIcon = file.isEpub ? '📗' : isXtc ? '📘' : isMd ? '📝' : '📄';
|
||||
const fileBadge = file.isEpub ? 'EPUB' : isXtc ? 'XTC' : ext === 'txt' ? 'TXT' : isMd ? 'MD' : '';
|
||||
const rowClass = file.isEpub ? 'epub-file' : isXtc ? 'xtc-file' : '';
|
||||
|
||||
fileTableContent += `<tr class="${rowClass}">`;
|
||||
fileTableContent += `<td style="text-align:center"><input type="checkbox" class="select-item" data-path="${encodeURIComponent(filePath)}" data-name="${escapeHtml(file.name)}" data-type="file"></td>`;
|
||||
fileTableContent += `<td><span class="file-icon">${file.isEpub ? '📗' : '📄'}</span>`;
|
||||
fileTableContent += `<td><span class="file-icon">${fileIcon}</span>`;
|
||||
fileTableContent += `<a rel="noopener noreferrer" target="_blank" href="/download?path=${encodeURIComponent(filePath)}" class="file-link">${escapeHtml(displayFileName(file.name))}</a>`;
|
||||
if (file.isEpub) fileTableContent += '<span class="epub-badge">EPUB</span>';
|
||||
if (fileBadge) fileTableContent += `<span class="epub-badge">${fileBadge}</span>`;
|
||||
fileTableContent += '</td>';
|
||||
fileTableContent += `<td>${file.name.includes('.') ? file.name.split('.').pop().toUpperCase() : '-'}</td>`;
|
||||
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
|
||||
@@ -2364,10 +2382,10 @@
|
||||
|
||||
fileTableContent += '</tbody></table>';
|
||||
fileTable.innerHTML = fileTableContent;
|
||||
document.getElementById('file-table').addEventListener('change', function(e) {
|
||||
document.getElementById('file-table').addEventListener('change', function (e) {
|
||||
if (e.target.classList.contains('select-item')) updateToolbarState();
|
||||
});
|
||||
document.getElementById('file-table').addEventListener('click', function(e) {
|
||||
document.getElementById('file-table').addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('button[data-action]');
|
||||
if (!btn) return;
|
||||
const name = btn.dataset.name;
|
||||
@@ -5794,7 +5812,7 @@
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/move', true);
|
||||
xhr.onload = function() {
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
successfulMoves++;
|
||||
} else {
|
||||
@@ -5803,7 +5821,7 @@
|
||||
}
|
||||
moveNext(index + 1);
|
||||
};
|
||||
xhr.onerror = function() {
|
||||
xhr.onerror = function () {
|
||||
hasErrors = true;
|
||||
showNotification('Failed to move ' + paths[index] + ' - network error', 'error');
|
||||
moveNext(index + 1);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CrossPoint Reader - Settings</title>
|
||||
<title>Settings - CrossPoint Reader</title>
|
||||
<style>
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
@@ -223,6 +223,48 @@
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.opds-server {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.opds-server .setting-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.opds-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.btn-small {
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.btn-add {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
.btn-add:hover {
|
||||
background-color: var(--accent-hover-color);
|
||||
}
|
||||
.btn-delete {
|
||||
background-color: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
.btn-delete:hover {
|
||||
background-color: #c0392b;
|
||||
}
|
||||
.btn-save-server {
|
||||
background-color: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
.btn-save-server:hover {
|
||||
background-color: #219a52;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
@@ -273,6 +315,8 @@
|
||||
<button class="save-btn" id="saveBtn" onclick="saveSettings()">Save Settings</button>
|
||||
</div>
|
||||
|
||||
<div id="opds-container"></div>
|
||||
|
||||
<div class="card">
|
||||
<p style="text-align: center; color: #95a5a6; margin: 0;">
|
||||
CrossPoint E-Reader • Open Source
|
||||
@@ -459,6 +503,127 @@
|
||||
}
|
||||
|
||||
loadSettings();
|
||||
|
||||
// --- OPDS Server Management ---
|
||||
// Dynamically renders an editable list of OPDS servers, communicating with the
|
||||
// /api/opds REST endpoints. Password fields are never pre-filled for security;
|
||||
// the "(unchanged)" placeholder indicates an existing password is preserved on save.
|
||||
let opdsServers = [];
|
||||
const MAX_OPDS_SERVERS = 8;
|
||||
|
||||
function renderOpdsServer(srv, idx) {
|
||||
const isNew = idx === -1;
|
||||
const id = isNew ? 'new' : idx;
|
||||
return '<div class="opds-server" id="opds-' + id + '">' +
|
||||
'<div class="setting-row">' +
|
||||
'<span class="setting-name">Server Name</span>' +
|
||||
'<span class="setting-control"><input type="text" id="opds-name-' + id + '" value="' + escapeHtml(srv.name || '') + '"></span>' +
|
||||
'</div>' +
|
||||
'<div class="setting-row">' +
|
||||
'<span class="setting-name">URL</span>' +
|
||||
'<span class="setting-control"><input type="text" id="opds-url-' + id + '" value="' + escapeHtml(srv.url || '') + '"></span>' +
|
||||
'</div>' +
|
||||
'<div class="setting-row">' +
|
||||
'<span class="setting-name">Username</span>' +
|
||||
'<span class="setting-control"><input type="text" id="opds-user-' + id + '" value="' + escapeHtml(srv.username || '') + '"></span>' +
|
||||
'</div>' +
|
||||
'<div class="setting-row">' +
|
||||
'<span class="setting-name">Password</span>' +
|
||||
'<span class="setting-control"><input type="password" id="opds-pass-' + id + '" placeholder="' + (srv.hasPassword ? '(unchanged)' : '') + '"></span>' +
|
||||
'</div>' +
|
||||
'<div class="opds-actions">' +
|
||||
'<button class="btn-small btn-save-server" onclick="saveOpdsServer(' + idx + ')">Save</button>' +
|
||||
(isNew ? '' : '<button class="btn-small btn-delete" onclick="deleteOpdsServer(' + idx + ')">Delete</button>') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderOpdsSection() {
|
||||
const container = document.getElementById('opds-container');
|
||||
let html = '<div class="card"><h2>OPDS Servers</h2>';
|
||||
const canAddOpdsServer = opdsServers.length < MAX_OPDS_SERVERS;
|
||||
|
||||
if (opdsServers.length === 0) {
|
||||
html += '<p style="color:var(--label-color);text-align:center;">No OPDS servers configured</p>';
|
||||
} else {
|
||||
opdsServers.forEach(function(srv, idx) {
|
||||
html += renderOpdsServer(srv, idx);
|
||||
});
|
||||
}
|
||||
|
||||
html += '<div style="margin-top:12px;text-align:center;">' +
|
||||
(canAddOpdsServer
|
||||
? '<button class="btn-small btn-add" onclick="addOpdsServer()">+ Add Server</button>'
|
||||
: '<span style="color:var(--label-color);">Maximum of 8 servers reached</span>') +
|
||||
'</div></div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadOpdsServers() {
|
||||
try {
|
||||
const resp = await fetch('/api/opds');
|
||||
if (!resp.ok) throw new Error('Failed to load');
|
||||
opdsServers = await resp.json();
|
||||
renderOpdsSection();
|
||||
} catch (e) {
|
||||
console.error('OPDS load error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function addOpdsServer() {
|
||||
if (opdsServers.length >= MAX_OPDS_SERVERS) return;
|
||||
const container = document.getElementById('opds-container');
|
||||
const card = container.querySelector('.card');
|
||||
const addBtn = card.querySelector('.btn-add').parentElement;
|
||||
// Prevent multiple unsaved new-server forms at once (idx -1 → id "new")
|
||||
if (document.getElementById('opds-new')) return;
|
||||
addBtn.insertAdjacentHTML('beforebegin', renderOpdsServer({name:'',url:'',username:'',hasPassword:false}, -1));
|
||||
}
|
||||
|
||||
async function saveOpdsServer(idx) {
|
||||
const id = idx === -1 ? 'new' : idx;
|
||||
const data = {
|
||||
name: document.getElementById('opds-name-' + id).value,
|
||||
url: document.getElementById('opds-url-' + id).value,
|
||||
username: document.getElementById('opds-user-' + id).value,
|
||||
};
|
||||
// Only include password in payload when the user actually typed something;
|
||||
// omitting it tells the server to keep the existing password.
|
||||
const pass = document.getElementById('opds-pass-' + id).value;
|
||||
if (pass) data.password = pass;
|
||||
if (idx >= 0) data.index = idx;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/opds', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
showMessage('OPDS server saved!', false);
|
||||
await loadOpdsServers();
|
||||
} catch (e) {
|
||||
showMessage('Error: ' + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteOpdsServer(idx) {
|
||||
if (!confirm('Delete this OPDS server?')) return;
|
||||
try {
|
||||
const resp = await fetch('/api/opds/delete', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({index: idx})
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
showMessage('OPDS server deleted', false);
|
||||
await loadOpdsServers();
|
||||
} catch (e) {
|
||||
showMessage('Error: ' + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
loadOpdsServers();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../../lib/Md/MdParser.h"
|
||||
|
||||
static int testsPassed = 0;
|
||||
static int testsFailed = 0;
|
||||
|
||||
#define ASSERT_EQ(a, b) \
|
||||
do { \
|
||||
if ((a) != (b)) { \
|
||||
fprintf(stderr, " FAIL: %s:%d: %s != %s\n", __FILE__, __LINE__, #a, #b); \
|
||||
testsFailed++; \
|
||||
return; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define PASS() testsPassed++
|
||||
|
||||
static std::string flattenText(const std::vector<MdParser::Span>& spans) {
|
||||
std::string result;
|
||||
for (const auto& span : spans) {
|
||||
result += span.text;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool allRegular(const std::vector<MdParser::Span>& spans) {
|
||||
for (const auto& span : spans) {
|
||||
if (span.style != EpdFontFamily::REGULAR) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void testSnakeCaseUnderscoresRemainLiteral() {
|
||||
printf("testSnakeCaseUnderscoresRemainLiteral...\n");
|
||||
auto spans = MdParser::parseInline("foo_bar_baz");
|
||||
ASSERT_EQ(flattenText(spans), "foo_bar_baz");
|
||||
ASSERT_EQ(allRegular(spans), true);
|
||||
PASS();
|
||||
}
|
||||
|
||||
void testUnderscoreWithinExpressionRemainsLiteral() {
|
||||
printf("testUnderscoreWithinExpressionRemainsLiteral...\n");
|
||||
auto spans = MdParser::parseInline("a_b + c_d");
|
||||
ASSERT_EQ(flattenText(spans), "a_b + c_d");
|
||||
ASSERT_EQ(allRegular(spans), true);
|
||||
PASS();
|
||||
}
|
||||
|
||||
void testUnderscoreEmphasisStillWorks() {
|
||||
printf("testUnderscoreEmphasisStillWorks...\n");
|
||||
auto spans = MdParser::parseInline("foo _bar_ baz");
|
||||
ASSERT_EQ(flattenText(spans), "foo bar baz");
|
||||
ASSERT_EQ(spans.size(), 3);
|
||||
ASSERT_EQ(spans[1].style, EpdFontFamily::ITALIC);
|
||||
PASS();
|
||||
}
|
||||
|
||||
void testAsteriskEmphasisStillWorks() {
|
||||
printf("testAsteriskEmphasisStillWorks...\n");
|
||||
auto spans = MdParser::parseInline("foo *bar* baz");
|
||||
ASSERT_EQ(flattenText(spans), "foo bar baz");
|
||||
ASSERT_EQ(spans.size(), 3);
|
||||
ASSERT_EQ(spans[1].style, EpdFontFamily::ITALIC);
|
||||
PASS();
|
||||
}
|
||||
|
||||
void testUnderscoreBoldWorks() {
|
||||
printf("testUnderscoreBoldWorks...\n");
|
||||
auto spans = MdParser::parseInline("foo __bar__ baz");
|
||||
ASSERT_EQ(flattenText(spans), "foo bar baz");
|
||||
ASSERT_EQ(spans.size(), 3);
|
||||
ASSERT_EQ(spans[1].style, EpdFontFamily::BOLD);
|
||||
PASS();
|
||||
}
|
||||
|
||||
void testNestedUnorderedListIndentLevel() {
|
||||
printf("testNestedUnorderedListIndentLevel...\n");
|
||||
auto parsed = MdParser::parseLine(" - nested item", false);
|
||||
ASSERT_EQ(parsed.blockType, MdParser::BlockType::UnorderedList);
|
||||
ASSERT_EQ(parsed.listPrefix, "\xe2\x80\xa2 ");
|
||||
ASSERT_EQ(parsed.indentLevel, 1);
|
||||
ASSERT_EQ(flattenText(parsed.spans), "nested item");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void testNestedOrderedListIndentLevel() {
|
||||
printf("testNestedOrderedListIndentLevel...\n");
|
||||
auto parsed = MdParser::parseLine(" 1. nested ordered", false);
|
||||
ASSERT_EQ(parsed.blockType, MdParser::BlockType::OrderedList);
|
||||
ASSERT_EQ(parsed.listPrefix, "1. ");
|
||||
ASSERT_EQ(parsed.indentLevel, 2);
|
||||
ASSERT_EQ(flattenText(parsed.spans), "nested ordered");
|
||||
PASS();
|
||||
}
|
||||
|
||||
int main() {
|
||||
printf("=== Markdown Parser Tests ===\n\n");
|
||||
|
||||
testSnakeCaseUnderscoresRemainLiteral();
|
||||
testUnderscoreWithinExpressionRemainsLiteral();
|
||||
testUnderscoreEmphasisStillWorks();
|
||||
testAsteriskEmphasisStillWorks();
|
||||
testUnderscoreBoldWorks();
|
||||
testNestedUnorderedListIndentLevel();
|
||||
testNestedOrderedListIndentLevel();
|
||||
|
||||
printf("\n=== Results: %d passed, %d failed ===\n", testsPassed, testsFailed);
|
||||
return testsFailed > 0 ? 1 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user