Merge branch 'master' of https://github.com/jpirnay/crosspoint-reader into feat-rendering-perf

This commit is contained in:
jpirnay
2026-04-24 15:47:47 +02:00
70 changed files with 3665 additions and 487 deletions
+26
View File
@@ -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
+41 -12
View File
@@ -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
+5 -3
View File
@@ -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
View File
@@ -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.
+7
View File
@@ -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
+7
View File
@@ -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`.
+5 -2
View File
@@ -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';
+1 -1
View File
@@ -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
+25 -30
View File
@@ -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);
+8
View File
@@ -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"
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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!"
+17 -2
View File
@@ -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"
+27 -38
View File
@@ -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);
+5 -18
View File
@@ -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);
}
+368
View File
@@ -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 ![alt](url) — 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
+46
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
+59
View File
@@ -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
View File
@@ -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
+171
View File
@@ -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];
}
+69
View File
@@ -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()
+20 -2
View File
@@ -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();
}
+6
View File
@@ -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();
+3
View File
@@ -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,
+9 -1
View File
@@ -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) {
+3
View File
@@ -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;
};
+21
View File
@@ -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();
+7 -1
View File
@@ -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);
+3 -3
View File
@@ -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;
+1 -1
View File
@@ -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;
+149 -37
View File
@@ -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;
}
+6 -1
View File
@@ -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
+219 -53
View File
@@ -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"};
+895
View File
@@ -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);
}
+92
View File
@@ -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;
};
+17 -4
View File
@@ -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) {
+2
View File
@@ -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:
+47
View File
@@ -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()));
}
}
}
+24 -24
View File
@@ -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);
+1 -5
View File
@@ -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);
}
+21 -3
View File
@@ -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
View File
@@ -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);
}
}
}
}
+167
View File
@@ -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) {
+5
View File
@@ -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 -13
View File
@@ -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);
}
+8 -12
View File
@@ -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 = "");
};
+32 -14
View File
@@ -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);
+166 -1
View File
@@ -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>
+114
View File
@@ -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;
}