diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 00000000..6943ec36 --- /dev/null +++ b/.githooks/pre-commit @@ -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 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bcd73969..5dc3ab5f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/README.md b/README.md index 6f8527c7..dcd153f1 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 8552cb22..146de364 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -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:///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. diff --git a/bin/clang-format-fix b/bin/clang-format-fix index d4902eb8..0b3d9855 100755 --- a/bin/clang-format-fix +++ b/bin/clang-format-fix @@ -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 \ No newline at end of file diff --git a/docs/contributing/development-workflow.md b/docs/contributing/development-workflow.md index 66a18917..ab7d64f8 100644 --- a/docs/contributing/development-workflow.md +++ b/docs/contributing/development-workflow.md @@ -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 diff --git a/docs/contributing/getting-started.md b/docs/contributing/getting-started.md index 715ab18a..299a23a1 100644 --- a/docs/contributing/getting-started.md +++ b/docs/contributing/getting-started.md @@ -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 diff --git a/docs/contributing/koreader-sync-xpath-mapping.md b/docs/contributing/koreader-sync-xpath-mapping.md index f437dd6b..0c47a3b4 100644 --- a/docs/contributing/koreader-sync-xpath-mapping.md +++ b/docs/contributing/koreader-sync-xpath-mapping.md @@ -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`. diff --git a/lib/Epub/Epub/FootnoteEntry.h b/lib/Epub/Epub/FootnoteEntry.h index 8023df42..6034a6a5 100644 --- a/lib/Epub/Epub/FootnoteEntry.h +++ b/lib/Epub/Epub/FootnoteEntry.h @@ -2,9 +2,12 @@ #include +#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'; diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 0bce47e2..8a157bce 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -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 diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 8f520fae..64f12878 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -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(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(self->currentTextBlock->size()) : 0); @@ -1423,9 +1424,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { anchorData.push_back({std::move(pendingAnchorId), static_cast(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 = diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 8d189f43..2ce5a1f5 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -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> pendingFootnotes; // 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); diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 8b21277c..6635e158 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -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" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index 35d52909..de90093a 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -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" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index 2f04f2b6..241e8c98 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -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!" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index c079ab07..d2cd80bd 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -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" \ No newline at end of file diff --git a/lib/KOReaderSync/KOReaderSyncClient.cpp b/lib/KOReaderSync/KOReaderSyncClient.cpp index 2bc41079..9bfc1087 100644 --- a/lib/KOReaderSync/KOReaderSyncClient.cpp +++ b/lib/KOReaderSync/KOReaderSyncClient.cpp @@ -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(); outProgress.timestamp = doc["timestamp"].as(); - 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); diff --git a/lib/KOReaderSync/ProgressMapper.cpp b/lib/KOReaderSync/ProgressMapper.cpp index eaf7e7ea..b176485a 100644 --- a/lib/KOReaderSync/ProgressMapper.cpp +++ b/lib/KOReaderSync/ProgressMapper.cpp @@ -57,24 +57,11 @@ KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr& 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

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(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); } diff --git a/lib/Md/MdParser.cpp b/lib/Md/MdParser.cpp new file mode 100644 index 00000000..ea4bb3c7 --- /dev/null +++ b/lib/Md/MdParser.cpp @@ -0,0 +1,368 @@ +#include "MdParser.h" + +#include + +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((leadingSpaces - TAB_WIDTH) / TAB_WIDTH + 1); +} + +static bool isWordChar(char c) { return std::isalnum(static_cast(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 parseInline(const std::string& text) { + std::vector 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(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 \ No newline at end of file diff --git a/lib/Md/MdParser.h b/lib/Md/MdParser.h new file mode 100644 index 00000000..a4ba8ce5 --- /dev/null +++ b/lib/Md/MdParser.h @@ -0,0 +1,46 @@ +#pragma once + +#include + +#include +#include +#include + +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 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 parseInline(const std::string& text); + +} // namespace MdParser \ No newline at end of file diff --git a/lib/Txt/Txt.cpp b/lib/Txt/Txt.cpp index 83ef123c..81c4cb3c 100644 --- a/lib/Txt/Txt.cpp +++ b/lib/Txt/Txt.cpp @@ -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; diff --git a/open-x4-sdk b/open-x4-sdk index a931d452..ed5cb2f9 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit a931d452d4bf9f100683705dbb8da6c29283bee0 +Subproject commit ed5cb2f99dc319deaa3c4f2c41e297929f940386 diff --git a/platformio.ini b/platformio.ini index c989c1cc..fcb32ff8 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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 diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index e19eaf29..fa9f3720 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -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 diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index a91495a0..d095010b 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -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(); + for (const auto& server : store.getServers()) { + JsonObject obj = arr.add(); + 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(); + 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; +} diff --git a/src/JsonSettingsIO.h b/src/JsonSettingsIO.h index f1528565..40d6c4d2 100644 --- a/src/JsonSettingsIO.h +++ b/src/JsonSettingsIO.h @@ -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 diff --git a/src/OpdsServerStore.cpp b/src/OpdsServerStore.cpp new file mode 100644 index 00000000..76826f45 --- /dev/null +++ b/src/OpdsServerStore.cpp @@ -0,0 +1,171 @@ +#include "OpdsServerStore.h" + +#include +#include +#include + +#include +#include +#include + +#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 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 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(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]; +} diff --git a/src/OpdsServerStore.h b/src/OpdsServerStore.h new file mode 100644 index 00000000..70c11eb1 --- /dev/null +++ b/src/OpdsServerStore.h @@ -0,0 +1,69 @@ +#pragma once +#include +#include +#include + +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 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 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 addServer(const OpdsServer& server); + bool updateServer(size_t index, const OpdsServer& server); + bool removeServer(size_t index); + + const std::vector& 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() diff --git a/src/RecentBooksStore.cpp b/src/RecentBooksStore.cpp index 08e43168..8fbadd06 100644 --- a/src/RecentBooksStore.cpp +++ b/src/RecentBooksStore.cpp @@ -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(); } diff --git a/src/RecentBooksStore.h b/src/RecentBooksStore.h index afc6c3de..6ca8f7a6 100644 --- a/src/RecentBooksStore.h +++ b/src/RecentBooksStore.h @@ -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(); diff --git a/src/SettingsList.h b/src/SettingsList.h index e09fa64d..03cb30f6 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -72,6 +72,9 @@ inline const std::vector 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, diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 84c8b974..d81cb491 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -8,6 +8,7 @@ #include #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(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(renderer, mappedInput, servers[0])); + } else { + replaceActivity(std::make_unique(renderer, mappedInput, true)); + } } void ActivityManager::goToReader(std::string path) { diff --git a/src/activities/ActivityResult.h b/src/activities/ActivityResult.h index c7c716fe..151137b6 100644 --- a/src/activities/ActivityResult.h +++ b/src/activities/ActivityResult.h @@ -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; }; diff --git a/src/activities/MenuListActivity.cpp b/src/activities/MenuListActivity.cpp index 4fd19076..d3b919b5 100644 --- a/src/activities/MenuListActivity.cpp +++ b/src/activities/MenuListActivity.cpp @@ -4,6 +4,7 @@ #include "MappedInputManager.h" #include "components/UITheme.h" +#include "settings/SettingsSubmenuActivity.h" void MenuListActivity::initMenuList() { const int count = static_cast(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(renderer, mappedInput, submenuEntry.nameId, it->items), + [this](const ActivityResult&) { requestUpdate(); }); +} + void MenuListActivity::loop() { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { onBackPressed(); diff --git a/src/activities/MenuListActivity.h b/src/activities/MenuListActivity.h index f8a8fdda..9d501fed 100644 --- a/src/activities/MenuListActivity.h +++ b/src/activities/MenuListActivity.h @@ -70,17 +70,23 @@ struct Rect; class MenuListActivity : public Activity { protected: std::vector menuItems; + std::vector 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); diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index 95de67af..51ddd97e 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -14,7 +14,6 @@ #include #include -#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(); diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h index 717bf54d..45b20647 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.h +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -2,9 +2,11 @@ #include #include +#include #include #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); diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index a41b349c..858341a8 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -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; diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index dbf29f17..97274215 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -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; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 5f3fa1ff..8293f805 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -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(currentSpineIndex), static_cast(section->currentPage)); ReaderUtils::enforceExitFullRefresh(renderer); - startActivityForResult( - std::make_unique( - 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(result.data); - applyOrientation(menu.orientation); - applyTextDarkness(menu.textDarkness); - toggleAutoPageTurn(menu.pageTurnOption); - applyBookReaderOverrides(menu.embeddedStyleOverride, menu.imageRenderingOverride); - if (!result.isCancelled) { - onReaderMenuConfirm(static_cast(menu.action)); - } - }); + startActivityForResult(std::make_unique( + 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(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(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(bookFontFamilyOverride) : SETTINGS.fontFamily; + const uint8_t fontSize = (bookFontSizeOverride >= 0) ? static_cast(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

(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, 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, 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, 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, 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, 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(currentBook.fontFamilyOverride) : SETTINGS.fontFamily; + const uint8_t effectiveFontSize = + currentBook.fontSizeOverride >= 0 ? static_cast(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
(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; } diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 004e36fc..8a792860 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -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 diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 7c1d3091..6e68462b 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -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(ctx); - if (s->pendingEmbeddedStyleOverride < 0) return 0; - if (s->pendingEmbeddedStyleOverride > 0) return 1; - return 2; - }, - [](void* ctx, uint8_t v) { - auto* s = static_cast(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(ctx); - return (s->pendingImageRenderingOverride < 0) ? 0 : (s->pendingImageRenderingOverride + 1); - }, - [](void* ctx, uint8_t v) { - auto* s = static_cast(ctx); - s->pendingImageRenderingOverride = (v == 0) ? -1 : static_cast(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(ctx)->pendingTextDarkness; }, - [](void* ctx, uint8_t v) { static_cast(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(ctx)->pendingOrientation; }, [](void* ctx, uint8_t v) { static_cast(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(ctx); + if (s->pendingEmbeddedStyleOverride < 0) return 0; + if (s->pendingEmbeddedStyleOverride > 0) return 1; + return 2; + }, + [](void* ctx, uint8_t v) { + auto* s = static_cast(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(ctx); + return (s->pendingImageRenderingOverride < 0) ? 0 : (s->pendingImageRenderingOverride + 1); + }, + [](void* ctx, uint8_t v) { + auto* s = static_cast(ctx); + s->pendingImageRenderingOverride = (v == 0) ? -1 : static_cast(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(ctx); + return (s->pendingFontFamilyOverride < 0) ? 0 : static_cast(s->pendingFontFamilyOverride + 1); + }, + [](void* ctx, uint8_t v) { + auto* s = static_cast(ctx); + s->pendingFontFamilyOverride = (v == 0) ? -1 : static_cast(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(ctx); + return (s->pendingFontSizeOverride < 0) ? 0 : static_cast(s->pendingFontSizeOverride + 1); + }, + [](void* ctx, uint8_t v) { + auto* s = static_cast(ctx); + s->pendingFontSizeOverride = (v == 0) ? -1 : static_cast(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(ctx)->pendingTextDarkness; + }, + [](void* ctx, uint8_t v) { static_cast(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(action), pendingOrientation, selectedPageTurnOption, - pendingEmbeddedStyleOverride, pendingImageRenderingOverride, pendingTextDarkness}); + setResult(MenuResult{static_cast(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(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(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(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(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(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(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(renderer, mappedInput, submenuEntry.nameId, + it->items, std::move(itemValueStringOverride)), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto* menuResult = std::get_if(&result.data); + if (menuResult) { + if (menuResult->action != -1) { + const auto action = + actionForSettingAction(static_cast(menuResult->action)); + if (action != MenuAction::NONE) { + finishWithAction(action); + return; + } + } + if (menuResult->nameId != -1) { + const auto action = actionForNameId(static_cast(menuResult->nameId)); + if (action != MenuAction::NONE) { + finishWithAction(action); + return; + } + } + } + } + requestUpdate(); + }); +} + +void EpubReaderMenuActivity::toggleCurrentItem() { + if (selectedIndex < 0 || selectedIndex >= static_cast(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&&) { diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index d5fc72c9..a380e99b 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -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"}; diff --git a/src/activities/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp new file mode 100644 index 00000000..9773a950 --- /dev/null +++ b/src/activities/reader/MdReaderActivity.cpp @@ -0,0 +1,895 @@ +#include "MdReaderActivity.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#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((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(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(headings.size()) - 1; + } else { + headingIndex += next ? 1 : -1; + } + + if (headingIndex < 0) { + headingIndex = 0; + } else if (headingIndex >= static_cast(headings.size())) { + headingIndex = static_cast(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(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(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(renderer, mappedInput, headings, currentHeadingIndex), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + currentPage = std::get(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(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& 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& 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 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(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& 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(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(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(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 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(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 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(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(data[0]) | (static_cast(data[1]) << 8) | + (static_cast(data[2]) << 16) | (static_cast(data[3]) << 24); + if (totalPages == 0) { + currentPage = 0; + } else if (loadedPage >= static_cast(totalPages)) { + currentPage = totalPages - 1; + } else { + currentPage = static_cast(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(txt->getFileSize())); + serialization::writePod(f, static_cast(viewportWidth)); + serialization::writePod(f, static_cast(linesPerPage)); + serialization::writePod(f, static_cast(cachedFontId)); + serialization::writePod(f, static_cast(cachedScreenMargin)); + serialization::writePod(f, cachedParagraphAlignment); + serialization::writePod(f, static_cast(pageOffsets.size())); + + for (size_t i = 0; i < pageOffsets.size(); i++) { + serialization::writePod(f, static_cast(pageOffsets[i])); + serialization::writePod(f, pageCodeBlockState[i]); + } + + LOG_DBG("MDR", "Saved page index cache: %d pages", totalPages); +} \ No newline at end of file diff --git a/src/activities/reader/MdReaderActivity.h b/src/activities/reader/MdReaderActivity.h new file mode 100644 index 00000000..604fc532 --- /dev/null +++ b/src/activities/reader/MdReaderActivity.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include + +#include + +#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; + + int currentPage = 0; + int totalPages = 1; + int pagesUntilFullRefresh = 0; + + // A single rendered line on screen (after word-wrapping) + struct RenderedLine { + std::vector spans; + int indent = 0; // left indent in pixels + bool isHR = false; // draw as horizontal rule + bool isCodeBlock = false; + }; + + // Streaming reader state + std::vector pageOffsets; + std::vector pageCodeBlockState; // 1 if page starts inside a code block + std::vector currentPageLines; + std::vector pageBuffer; + + std::vector 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& 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& outLines, + int maxLines, bool isCodeBlock = false); + + // Measure total pixel width of a span list + int measureSpans(const std::vector& spans) const; + + public: + explicit MdReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr 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; } +}; \ No newline at end of file diff --git a/src/activities/reader/MdReaderTocSelectionActivity.cpp b/src/activities/reader/MdReaderTocSelectionActivity.cpp new file mode 100644 index 00000000..89054c7d --- /dev/null +++ b/src/activities/reader/MdReaderTocSelectionActivity.cpp @@ -0,0 +1,112 @@ +#include "MdReaderTocSelectionActivity.h" + +#include +#include + +#include + +#include "components/UITheme.h" +#include "fontIds.h" + +int MdReaderTocSelectionActivity::getTotalItems() const { return static_cast(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(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(); +} diff --git a/src/activities/reader/MdReaderTocSelectionActivity.h b/src/activities/reader/MdReaderTocSelectionActivity.h new file mode 100644 index 00000000..0d8bc66c --- /dev/null +++ b/src/activities/reader/MdReaderTocSelectionActivity.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +#include "../Activity.h" +#include "MdReaderActivity.h" +#include "util/ButtonNavigator.h" + +class MdReaderTocSelectionActivity final : public Activity { + std::vector headings; + ButtonNavigator buttonNavigator; + int selectorIndex = 0; + + int getPageItems() const; + int getTotalItems() const; + + public: + explicit MdReaderTocSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + std::vector headings, int currentHeadingIndex) + : Activity("MdReaderTocSelection", renderer, mappedInput), headings(std::move(headings)), selectorIndex(0) { + if (currentHeadingIndex >= 0 && currentHeadingIndex < static_cast(headings.size())) { + selectorIndex = currentHeadingIndex; + } + } + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; +}; diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 69f572a0..06a5c05a 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -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) { activityManager.replaceActivity(std::make_unique(renderer, mappedInput, std::move(txt))); } +void ReaderActivity::onGoToMdReader(std::unique_ptr txt) { + const auto txtPath = txt->getPath(); + currentBookPath = txtPath; + activityManager.replaceActivity(std::make_unique(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) { diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index efdfd201..0292901f 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -16,6 +16,7 @@ class ReaderActivity final : public Activity { static std::unique_ptr 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); void onGoToXtcReader(std::unique_ptr xtc); void onGoToTxtReader(std::unique_ptr txt); + void onGoToMdReader(std::unique_ptr txt); void onGoToBmpViewer(const std::string& path); void onGoBack(); diff --git a/src/activities/settings/CalibreSettingsActivity.cpp b/src/activities/settings/CalibreSettingsActivity.cpp deleted file mode 100644 index 24fe2b83..00000000 --- a/src/activities/settings/CalibreSettingsActivity.cpp +++ /dev/null @@ -1,128 +0,0 @@ -#include "CalibreSettingsActivity.h" - -#include -#include - -#include - -#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(renderer, mappedInput, tr(STR_CALIBRE_WEB_URL), - SETTINGS.opdsServerUrl, 127, InputType::Url), - [this](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& kb = std::get(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(renderer, mappedInput, tr(STR_USERNAME), - SETTINGS.opdsUsername, 63, InputType::Text), - [this](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& kb = std::get(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(renderer, mappedInput, tr(STR_PASSWORD), - SETTINGS.opdsPassword, 63, InputType::Password), - [this](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& kb = std::get(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(MENU_ITEMS), - static_cast(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(); -} diff --git a/src/activities/settings/CalibreSettingsActivity.h b/src/activities/settings/CalibreSettingsActivity.h deleted file mode 100644 index c7990d9a..00000000 --- a/src/activities/settings/CalibreSettingsActivity.h +++ /dev/null @@ -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(); -}; diff --git a/src/activities/settings/OpdsServerListActivity.cpp b/src/activities/settings/OpdsServerListActivity.cpp new file mode 100644 index 00000000..d6964107 --- /dev/null +++ b/src/activities/settings/OpdsServerListActivity.cpp @@ -0,0 +1,136 @@ +#include "OpdsServerListActivity.h" + +#include +#include + +#include + +#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(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(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(selectedIndex)); + if (server) { + activityManager.replaceActivity(std::make_unique(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(renderer, mappedInput, selectedIndex), resultHandler); + } else { + startActivityForResult(std::make_unique(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(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(); +} diff --git a/src/activities/settings/OpdsServerListActivity.h b/src/activities/settings/OpdsServerListActivity.h new file mode 100644 index 00000000..b03c3a92 --- /dev/null +++ b/src/activities/settings/OpdsServerListActivity.h @@ -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(); +}; diff --git a/src/activities/settings/OpdsSettingsActivity.cpp b/src/activities/settings/OpdsSettingsActivity.cpp new file mode 100644 index 00000000..c3907cea --- /dev/null +++ b/src/activities/settings/OpdsSettingsActivity.cpp @@ -0,0 +1,241 @@ +#include "OpdsSettingsActivity.h" + +#include +#include +#include + +#include + +#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(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(*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(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(result.data); + editServer.name = kb.text; + saveServer(); + requestUpdate(); + } + }; + startActivityForResult( + std::make_unique(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(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(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(result.data); + editServer.username = kb.text; + saveServer(); + requestUpdate(); + } + }; + startActivityForResult( + std::make_unique(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(result.data); + editServer.password = kb.text; + saveServer(); + requestUpdate(); + } + }; + startActivityForResult( + std::make_unique(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(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(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(); +} diff --git a/src/activities/settings/OpdsSettingsActivity.h b/src/activities/settings/OpdsSettingsActivity.h new file mode 100644 index 00000000..2e818edc --- /dev/null +++ b/src/activities/settings/OpdsSettingsActivity.h @@ -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(); +}; diff --git a/src/activities/settings/SettingActionDispatch.cpp b/src/activities/settings/SettingActionDispatch.cpp index 45780107..599f85e7 100644 --- a/src/activities/settings/SettingActionDispatch.cpp +++ b/src/activities/settings/SettingActionDispatch.cpp @@ -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 createActivityForAction(SettingAction action, GfxRende case SettingAction::KOReaderSync: return std::make_unique(renderer, mappedInput); case SettingAction::OPDSBrowser: - return std::make_unique(renderer, mappedInput); + return std::make_unique(renderer, mappedInput); case SettingAction::Network: return std::make_unique(renderer, mappedInput, false); case SettingAction::ClearCache: diff --git a/src/activities/settings/SettingInfo.h b/src/activities/settings/SettingInfo.h index d0e684c0..77abbe13 100644 --- a/src/activities/settings/SettingInfo.h +++ b/src/activities/settings/SettingInfo.h @@ -1,7 +1,9 @@ #pragma once #include +#include #include +#include #include #include @@ -88,6 +90,13 @@ struct SettingInfo { stringSetter(accessorCtx, v); } + struct SubmenuData { + StrId id = StrId::STR_NONE_OPT; + std::vector items; + }; + + static void prepareSubmenus(std::vector& items, std::vector& 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& items, + std::vector& submenuData) { + if (items.empty()) return; + + std::vector preparedItems; + std::vector 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())); + } + } +} diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 789b38e2..01020f65 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -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& vec, StrId& lastSub, - const SettingInfo& s) -> std::vector* { - 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& 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& vec, StrId& lastSub, const SettingInfo& s) { - locateTarget(vec, lastSub, s)->push_back(s); - }; - auto addToMoved = [&locateTarget](std::vector& vec, StrId& lastSub, SettingInfo&& s) { - auto* target = locateTarget(vec, lastSub, s); - target->push_back(std::move(s)); + auto addToMoved = [](std::vector& 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(&result.data); + if (menuResult && menuResult->action != -1) { + auto activity = createActivityForAction(static_cast(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(renderer, mappedInput, setting.nameId, it->items), resultHandler); diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 60cf75b6..a84114b9 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -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 items; - }; - std::vector submenuData; + std::vector submenuData; void enterCategory(int categoryIndex); void toggleCurrentSetting(); diff --git a/src/activities/settings/SettingsSubmenuActivity.cpp b/src/activities/settings/SettingsSubmenuActivity.cpp index 5ac7ffff..0fc68ff0 100644 --- a/src/activities/settings/SettingsSubmenuActivity.cpp +++ b/src/activities/settings/SettingsSubmenuActivity.cpp @@ -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(setting.action); + } else { + menuResult.nameId = static_cast(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(); } diff --git a/src/activities/settings/SettingsSubmenuActivity.h b/src/activities/settings/SettingsSubmenuActivity.h index 6fff3691..32618941 100644 --- a/src/activities/settings/SettingsSubmenuActivity.h +++ b/src/activities/settings/SettingsSubmenuActivity.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #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 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 items) - : MenuListActivity("SettingsSubmenu", renderer, mappedInput), titleId(titleId) { + std::vector items, + std::function itemValueStringOverride = {}) + : MenuListActivity("SettingsSubmenu", renderer, mappedInput), + titleId(titleId), + itemValueStringOverride(std::move(itemValueStringOverride)) { menuItems = std::move(items); } diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index d51b633e..f65a6c1e 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -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); diff --git a/src/main.cpp b/src/main.cpp index f1914ee5..e79a2bf0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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(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); } } -} \ No newline at end of file +} diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 80261482..784aedb1 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -11,6 +11,7 @@ #include #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() || doc["password"].is(); + 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 idx = doc["index"].as(); + if (idx < 0 || idx >= static_cast(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(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(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()) { + server->send(400, "text/plain", "Missing index"); + return; + } + + int idx = doc["index"].as(); + if (idx < 0 || idx >= static_cast(OPDS_STORE.getCount())) { + server->send(400, "text/plain", "Invalid server index"); + return; + } + + if (!OPDS_STORE.removeServer(static_cast(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) { diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 14a71ad8..511da928 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -109,4 +109,9 @@ class CrossPointWebServer { void handleSettingsPage() const; void handleGetSettings() const; void handlePostSettings(); + + // OPDS server handlers + void handleGetOpdsServers() const; + void handlePostOpdsServer(); + void handleDeleteOpdsServer(); }; diff --git a/src/network/HttpDownloader.cpp b/src/network/HttpDownloader.cpp index 0fa1612a..ab6806a9 100644 --- a/src/network/HttpDownloader.cpp +++ b/src/network/HttpDownloader.cpp @@ -11,7 +11,6 @@ #include #include -#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 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 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); } diff --git a/src/network/HttpDownloader.h b/src/network/HttpDownloader.h index a8f1cd25..216840a5 100644 --- a/src/network/HttpDownloader.h +++ b/src/network/HttpDownloader.h @@ -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 = ""); }; diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 256a86d0..83b5da0b 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -4,12 +4,13 @@ - CrossPoint Reader - Files + Files - CrossPoint Reader