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