4 Commits

Author SHA1 Message Date
cottongin
3628d8eb37 feat: port upstream KOReader sync PRs (#1185, #1217, #1090)
Port three unmerged upstream PRs with adaptations for the fork's
callback-based ActivityWithSubactivity architecture:

- PR #1185: Cache KOReader document hash using mtime fingerprint +
  file size validation to avoid repeated MD5 computation on sync.
- PR #1217: Proper KOReader XPath synchronisation via new
  ChapterXPathIndexer (Expat-based on-demand XHTML parsing) with
  XPath-first mapping and percentage fallback in ProgressMapper.
- PR #1090: Push Progress & Sleep menu option with PUSH_ONLY sync
  mode. Adapted to fork's callback pattern with deferFinish() for
  thread-safe completion. Modified to sleep silently on any failure
  (hash, upload, no credentials) rather than returning to reader.

Made-with: Cursor
2026-03-02 05:19:14 -05:00
cottongin
42011d5977 feat: add directory picker for OPDS downloads with per-server default path
When downloading a book via OPDS, a directory picker now lets the user
choose the save location instead of always saving to the SD root. Each
OPDS server has a configurable default download path (persisted in
opds.json) that the picker opens to. Falls back to "/" if the saved
path no longer exists on disk.

- Add DirectoryPickerActivity (browse-only directory view with "Save Here")
- Add PICKING_DIRECTORY state to OpdsBookBrowserActivity
- Add downloadPath field to OpdsServer with JSON serialization
- Add Download Path setting to OPDS server edit screen
- Extract sortFileList() to StringUtils for shared use
- Add i18n strings: STR_SAVE_HERE, STR_SELECT_FOLDER, STR_DOWNLOAD_PATH

Made-with: Cursor
2026-03-02 04:28:57 -05:00
cottongin
2aa13ea2de feat: port upstream OPDS improvements (PRs #1207, #1209)
Port two upstream PRs:

- PR #1207: Replace manual chunked download loop with
  HTTPClient::writeToStream via a FileWriteStream adapter, improving
  reliability for OPDS file downloads including chunked transfers.

- PR #1209: Add support for multiple OPDS servers with a new
  OpdsServerStore (JSON persistence with MAC-based password obfuscation),
  OpdsServerListActivity and OpdsSettingsActivity UIs, per-server
  credentials passed to HttpDownloader, web UI management endpoints,
  and migration from legacy single-server settings.

Made-with: Cursor
2026-02-26 19:14:59 -05:00
cottongin
19b6ad047b feat: add silent NTP time sync on boot via saved WiFi credentials
New "Auto Sync on Boot" toggle in Clock Settings. When enabled, a
background FreeRTOS task scans for saved WiFi networks at boot,
connects, syncs time via NTP, then tears down WiFi — all without
blocking boot or requiring user interaction. If no saved network is
found after two scan attempts (with a 3-second retry gap), it bails
silently.

Conflict guards (BootNtpSync::cancel()) added to all WiFi-using
activities so the background task cleans up before any user-initiated
WiFi flow. Also fixes clock not appearing in the header until a button
press by detecting the invalid→valid time transition after NTP sync.

Made-with: Cursor
2026-02-26 18:21:13 -05:00
56 changed files with 2736 additions and 392 deletions

View File

@@ -0,0 +1,99 @@
# KOReader Sync XPath Mapping
This note documents how CrossPoint maps reading positions to and from KOReader sync payloads.
## Problem
CrossPoint internally stores position as:
- `spineIndex` (chapter index, 0-based)
- `pageNumber` + `totalPages`
KOReader sync payload stores:
- `progress` (XPath-like location)
- `percentage` (overall progress)
A direct 1:1 mapping is not guaranteed because page layout differs between engines/devices.
## DocFragment Index Convention
KOReader uses **1-based** XPath predicates throughout, following standard XPath conventions.
The first EPUB spine item is `DocFragment[1]`, the second is `DocFragment[2]`, and so on.
CrossPoint stores spine items as 0-based indices internally. The conversion is:
- **Generating XPath (to KOReader):** `DocFragment[spineIndex + 1]`
- **Parsing XPath (from KOReader):** `spineIndex = DocFragment[N] - 1`
Reference: [koreader/koreader#11585](https://github.com/koreader/koreader/issues/11585) confirms this
via a KOReader contributor mapping spine items to DocFragment numbers.
## Current Strategy
### CrossPoint -> KOReader
Implemented in `ProgressMapper::toKOReader`.
1. Compute overall `percentage` from chapter/page.
2. Attempt to compute a real element-level XPath via `ChapterXPathIndexer::findXPathForProgress`.
3. If XPath extraction fails, fallback to synthetic chapter path:
- `/body/DocFragment[spineIndex + 1]/body`
### KOReader -> CrossPoint
Implemented in `ProgressMapper::toCrossPoint`.
1. Attempt to parse `DocFragment[N]` from incoming XPath; convert N to 0-based `spineIndex = N - 1`.
2. If valid, attempt XPath-to-offset mapping via `ChapterXPathIndexer::findProgressForXPath`.
3. Convert resolved intra-spine progress to page estimate.
4. If XPath path is invalid/unresolvable, fallback to percentage-based chapter/page estimation.
## ChapterXPathIndexer Design
The module reparses **one spine XHTML** on demand using Expat and builds temporary anchors:
Source-of-truth note: XPath anchors are built from the original EPUB spine XHTML bytes (zip item contents), not from CrossPoint's distilled section render cache. This is intentional to preserve KOReader XPath compatibility.
- anchor: `<xpath, textOffset>`
- `textOffset` counts non-whitespace bytes
- When multiple anchors exist for the same path, the one with the **smallest** textOffset is used
(start of element), not the latest periodic anchor.
Forward lookup (CrossPoint → XPath): uses `upper_bound` to find the last anchor at or before the
target text offset, ensuring the returned XPath corresponds to the element the user is currently
inside rather than the next element.
Matching for reverse lookup:
1. exact path match — reported as `exact=yes`
2. index-insensitive path match (`div[2]` vs `div[3]` tolerated) — reported as `exact=no`
3. ancestor fallback — reported as `exact=no`
If no match is found, caller must fallback to percentage.
## Memory / Safety Constraints (ESP32-C3)
The implementation intentionally avoids full DOM storage.
- Parse one chapter only.
- Keep anchors in transient vectors only for duration of call.
- Free XML parser and chapter byte buffer on all success/failure paths.
- No persistent cache structures are introduced by this module.
## Known Limitations
- Page number on reverse mapping is still an estimate (renderer differences).
- XPath mapping intentionally uses original spine XHTML while pagination comes from distilled renderer output, so minor roundtrip page drift is expected.
- Image-only/low-text chapters may yield coarse anchors.
- Extremely malformed XHTML can force fallback behavior.
## Operational Logging
`ProgressMapper` logs mapping source in reverse direction:
- `xpath` when XPath mapping path was used
- `percentage` when fallback path was used
It also logs exactness (`exact=yes/no`) for XPath matches. Note that `exact=yes` is only set for
a full path match with correct indices; index-insensitive and ancestor matches always log `exact=no`.

View File

@@ -321,6 +321,7 @@ enum class StrId : uint16_t {
STR_GO_TO_PERCENT, STR_GO_TO_PERCENT,
STR_GO_HOME_BUTTON, STR_GO_HOME_BUTTON,
STR_SYNC_PROGRESS, STR_SYNC_PROGRESS,
STR_PUSH_AND_SLEEP,
STR_DELETE_CACHE, STR_DELETE_CACHE,
STR_CHAPTER_PREFIX, STR_CHAPTER_PREFIX,
STR_PAGES_SEPARATOR, STR_PAGES_SEPARATOR,
@@ -400,6 +401,7 @@ enum class StrId : uint16_t {
STR_INDEXING_STATUS_ICON, STR_INDEXING_STATUS_ICON,
STR_SYNC_CLOCK, STR_SYNC_CLOCK,
STR_TIME_SYNCED, STR_TIME_SYNCED,
STR_AUTO_NTP_SYNC,
STR_MANAGE_BOOK, STR_MANAGE_BOOK,
STR_ARCHIVE_BOOK, STR_ARCHIVE_BOOK,
STR_UNARCHIVE_BOOK, STR_UNARCHIVE_BOOK,
@@ -415,6 +417,15 @@ enum class StrId : uint16_t {
STR_ACTION_FAILED, STR_ACTION_FAILED,
STR_BACK_TO_BEGINNING, STR_BACK_TO_BEGINNING,
STR_CLOSE_MENU, STR_CLOSE_MENU,
STR_ADD_SERVER,
STR_SERVER_NAME,
STR_NO_SERVERS,
STR_DELETE_SERVER,
STR_DELETE_CONFIRM,
STR_OPDS_SERVERS,
STR_SAVE_HERE,
STR_SELECT_FOLDER,
STR_DOWNLOAD_PATH,
// Sentinel - must be last // Sentinel - must be last
_COUNT _COUNT
}; };

View File

@@ -343,3 +343,13 @@ STR_INDEXING_STATUS_TEXT: "Text stavového řádku"
STR_INDEXING_STATUS_ICON: "Ikona stavového řádku" STR_INDEXING_STATUS_ICON: "Ikona stavového řádku"
STR_SYNC_CLOCK: "Sync Clock" STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!" STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
STR_ADD_SERVER: "Přidat server"
STR_SERVER_NAME: "Název serveru"
STR_NO_SERVERS: "Žádné OPDS servery nejsou nakonfigurovány"
STR_DELETE_SERVER: "Smazat server"
STR_DELETE_CONFIRM: "Smazat tento server?"
STR_OPDS_SERVERS: "OPDS servery"
STR_SAVE_HERE: "Uložit zde"
STR_SELECT_FOLDER: "Vybrat složku"
STR_DOWNLOAD_PATH: "Cesta ke stažení"

View File

@@ -285,6 +285,7 @@ STR_HW_RIGHT_LABEL: "Right (4th button)"
STR_GO_TO_PERCENT: "Go to %" STR_GO_TO_PERCENT: "Go to %"
STR_GO_HOME_BUTTON: "Go Home" STR_GO_HOME_BUTTON: "Go Home"
STR_SYNC_PROGRESS: "Sync Reading Progress" STR_SYNC_PROGRESS: "Sync Reading Progress"
STR_PUSH_AND_SLEEP: "Push Progress & Sleep"
STR_DELETE_CACHE: "Delete Book Cache" STR_DELETE_CACHE: "Delete Book Cache"
STR_CHAPTER_PREFIX: "Chapter: " STR_CHAPTER_PREFIX: "Chapter: "
STR_PAGES_SEPARATOR: " pages | " STR_PAGES_SEPARATOR: " pages | "
@@ -364,6 +365,7 @@ STR_INDEXING_STATUS_TEXT: "Status Bar Text"
STR_INDEXING_STATUS_ICON: "Status Bar Icon" STR_INDEXING_STATUS_ICON: "Status Bar Icon"
STR_SYNC_CLOCK: "Sync Clock" STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!" STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
STR_MANAGE_BOOK: "Manage Book" STR_MANAGE_BOOK: "Manage Book"
STR_ARCHIVE_BOOK: "Archive Book" STR_ARCHIVE_BOOK: "Archive Book"
STR_UNARCHIVE_BOOK: "Unarchive Book" STR_UNARCHIVE_BOOK: "Unarchive Book"
@@ -379,3 +381,12 @@ STR_BOOK_REINDEXED: "Book reindexed"
STR_ACTION_FAILED: "Action failed" STR_ACTION_FAILED: "Action failed"
STR_BACK_TO_BEGINNING: "Back to Beginning" STR_BACK_TO_BEGINNING: "Back to Beginning"
STR_CLOSE_MENU: "Close Menu" STR_CLOSE_MENU: "Close Menu"
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_SAVE_HERE: "Save Here"
STR_SELECT_FOLDER: "Select Folder"
STR_DOWNLOAD_PATH: "Download Path"

View File

@@ -343,3 +343,13 @@ STR_INDEXING_STATUS_TEXT: "Texte barre d'état"
STR_INDEXING_STATUS_ICON: "Icône barre d'état" STR_INDEXING_STATUS_ICON: "Icône barre d'état"
STR_SYNC_CLOCK: "Sync Clock" STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!" STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
STR_ADD_SERVER: "Ajouter un serveur"
STR_SERVER_NAME: "Nom du serveur"
STR_NO_SERVERS: "Aucun serveur OPDS configuré"
STR_DELETE_SERVER: "Supprimer le serveur"
STR_DELETE_CONFIRM: "Supprimer ce serveur ?"
STR_OPDS_SERVERS: "Serveurs OPDS"
STR_SAVE_HERE: "Enregistrer ici"
STR_SELECT_FOLDER: "Sélectionner un dossier"
STR_DOWNLOAD_PATH: "Chemin de téléchargement"

View File

@@ -343,3 +343,13 @@ STR_INDEXING_STATUS_TEXT: "Statusleistentext"
STR_INDEXING_STATUS_ICON: "Statusleistensymbol" STR_INDEXING_STATUS_ICON: "Statusleistensymbol"
STR_SYNC_CLOCK: "Sync Clock" STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!" STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
STR_ADD_SERVER: "Server hinzufügen"
STR_SERVER_NAME: "Servername"
STR_NO_SERVERS: "Keine OPDS-Server konfiguriert"
STR_DELETE_SERVER: "Server löschen"
STR_DELETE_CONFIRM: "Diesen Server löschen?"
STR_OPDS_SERVERS: "OPDS-Server"
STR_SAVE_HERE: "Hier speichern"
STR_SELECT_FOLDER: "Ordner auswählen"
STR_DOWNLOAD_PATH: "Download-Pfad"

View File

@@ -343,3 +343,13 @@ STR_INDEXING_STATUS_TEXT: "Texto da barra"
STR_INDEXING_STATUS_ICON: "Ícone da barra" STR_INDEXING_STATUS_ICON: "Ícone da barra"
STR_SYNC_CLOCK: "Sync Clock" STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!" STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
STR_ADD_SERVER: "Adicionar servidor"
STR_SERVER_NAME: "Nome do servidor"
STR_NO_SERVERS: "Nenhum servidor OPDS configurado"
STR_DELETE_SERVER: "Excluir servidor"
STR_DELETE_CONFIRM: "Excluir este servidor?"
STR_OPDS_SERVERS: "Servidores OPDS"
STR_SAVE_HERE: "Salvar aqui"
STR_SELECT_FOLDER: "Selecionar pasta"
STR_DOWNLOAD_PATH: "Caminho de download"

View File

@@ -318,3 +318,13 @@ STR_EMBEDDED_STYLE: "Stil încorporat"
STR_OPDS_SERVER_URL: "URL server OPDS" STR_OPDS_SERVER_URL: "URL server OPDS"
STR_SYNC_CLOCK: "Sync Clock" STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!" STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
STR_ADD_SERVER: "Adaugă server"
STR_SERVER_NAME: "Numele serverului"
STR_NO_SERVERS: "Niciun server OPDS configurat"
STR_DELETE_SERVER: "Șterge serverul"
STR_DELETE_CONFIRM: "Ștergi acest server?"
STR_OPDS_SERVERS: "Servere OPDS"
STR_SAVE_HERE: "Salvează aici"
STR_SELECT_FOLDER: "Selectează dosar"
STR_DOWNLOAD_PATH: "Cale descărcare"

View File

@@ -343,3 +343,13 @@ STR_INDEXING_STATUS_TEXT: "Текст в строке"
STR_INDEXING_STATUS_ICON: "Иконка в строке" STR_INDEXING_STATUS_ICON: "Иконка в строке"
STR_SYNC_CLOCK: "Sync Clock" STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!" STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
STR_ADD_SERVER: "Добавить сервер"
STR_SERVER_NAME: "Имя сервера"
STR_NO_SERVERS: "Нет настроенных серверов OPDS"
STR_DELETE_SERVER: "Удалить сервер"
STR_DELETE_CONFIRM: "Удалить этот сервер?"
STR_OPDS_SERVERS: "Серверы OPDS"
STR_SAVE_HERE: "Сохранить здесь"
STR_SELECT_FOLDER: "Выбрать папку"
STR_DOWNLOAD_PATH: "Путь загрузки"

View File

@@ -343,3 +343,13 @@ STR_INDEXING_STATUS_TEXT: "Texto barra estado"
STR_INDEXING_STATUS_ICON: "Icono barra estado" STR_INDEXING_STATUS_ICON: "Icono barra estado"
STR_SYNC_CLOCK: "Sync Clock" STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!" STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
STR_ADD_SERVER: "Añadir servidor"
STR_SERVER_NAME: "Nombre del servidor"
STR_NO_SERVERS: "No hay servidores OPDS configurados"
STR_DELETE_SERVER: "Eliminar servidor"
STR_DELETE_CONFIRM: "¿Eliminar este servidor?"
STR_OPDS_SERVERS: "Servidores OPDS"
STR_SAVE_HERE: "Guardar aquí"
STR_SELECT_FOLDER: "Seleccionar carpeta"
STR_DOWNLOAD_PATH: "Ruta de descarga"

View File

@@ -343,3 +343,13 @@ STR_INDEXING_STATUS_TEXT: "Statusfältstext"
STR_INDEXING_STATUS_ICON: "Statusfältsikon" STR_INDEXING_STATUS_ICON: "Statusfältsikon"
STR_SYNC_CLOCK: "Sync Clock" STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!" STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
STR_ADD_SERVER: "Lägg till server"
STR_SERVER_NAME: "Servernamn"
STR_NO_SERVERS: "Inga OPDS-servrar konfigurerade"
STR_DELETE_SERVER: "Ta bort server"
STR_DELETE_CONFIRM: "Ta bort denna server?"
STR_OPDS_SERVERS: "OPDS-servrar"
STR_SAVE_HERE: "Spara här"
STR_SELECT_FOLDER: "Välj mapp"
STR_DOWNLOAD_PATH: "Nedladdningssökväg"

View File

@@ -0,0 +1,497 @@
#include "ChapterXPathIndexer.h"
#include <Logging.h>
#include <expat.h>
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <limits>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
namespace {
// Anchor used for both mapping directions.
// textOffset is counted as visible (non-whitespace) bytes from chapter start.
// xpath points to the nearest element path at/near that offset.
struct XPathAnchor {
size_t textOffset = 0;
std::string xpath;
std::string xpathNoIndex; // precomputed removeIndices(xpath)
};
struct StackNode {
std::string tag;
int index = 1;
bool hasTextAnchor = false;
};
// ParserState is intentionally ephemeral and created per lookup call.
// It holds only one spine parse worth of data to avoid retaining structures
// that would increase long-lived heap usage on the ESP32-C3.
struct ParserState {
explicit ParserState(const int spineIndex) : spineIndex(spineIndex) { siblingCounters.emplace_back(); }
int spineIndex = 0;
int skipDepth = -1;
size_t totalTextBytes = 0;
std::vector<StackNode> stack;
std::vector<std::unordered_map<std::string, int>> siblingCounters;
std::vector<XPathAnchor> anchors;
std::string baseXPath() const { return "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body"; }
// Canonicalize incoming KOReader XPath before matching:
// - remove all whitespace
// - lowercase tags
// - strip optional trailing /text()
// - strip trailing slash
static std::string normalizeXPath(const std::string& input) {
if (input.empty()) {
return "";
}
std::string out;
out.reserve(input.size());
for (char c : input) {
const unsigned char uc = static_cast<unsigned char>(c);
if (std::isspace(uc)) {
continue;
}
out.push_back(static_cast<char>(std::tolower(uc)));
}
const std::string textSuffix = "/text()";
const size_t textPos = out.rfind(textSuffix);
if (textPos != std::string::npos && textPos + textSuffix.size() == out.size()) {
out.erase(textPos);
}
while (!out.empty() && out.back() == '/') {
out.pop_back();
}
return out;
}
// Remove bracketed numeric predicates so paths can be compared even when
// index counters differ between parser implementations.
static std::string removeIndices(const std::string& xpath) {
std::string out;
out.reserve(xpath.size());
bool inBracket = false;
for (char c : xpath) {
if (c == '[') {
inBracket = true;
continue;
}
if (c == ']') {
inBracket = false;
continue;
}
if (!inBracket) {
out.push_back(c);
}
}
return out;
}
static int pathDepth(const std::string& xpath) {
int depth = 0;
for (char c : xpath) {
if (c == '/') {
depth++;
}
}
return depth;
}
// Resolve a path to the best anchor offset.
// If exact node path is not found, progressively trim trailing segments and
// match ancestors to obtain a stable approximate location.
bool pickBestAnchorByPath(const std::string& targetPath, const bool ignoreIndices, size_t& outTextOffset,
bool& outExact) const {
if (targetPath.empty() || anchors.empty()) {
return false;
}
const std::string normalizedTarget = ignoreIndices ? removeIndices(targetPath) : targetPath;
std::string probe = normalizedTarget;
bool exactProbe = true;
while (!probe.empty()) {
int bestDepth = -1;
size_t bestOffset = 0;
bool found = false;
for (const auto& anchor : anchors) {
const std::string& anchorPath = ignoreIndices ? anchor.xpathNoIndex : anchor.xpath;
if (anchorPath == probe) {
const int depth = pathDepth(anchorPath);
if (!found || depth > bestDepth || (depth == bestDepth && anchor.textOffset < bestOffset)) {
found = true;
bestDepth = depth;
bestOffset = anchor.textOffset;
}
}
}
if (found) {
outTextOffset = bestOffset;
outExact = exactProbe;
return true;
}
const size_t lastSlash = probe.find_last_of('/');
if (lastSlash == std::string::npos || lastSlash == 0) {
break;
}
probe.erase(lastSlash);
exactProbe = false;
}
return false;
}
static std::string toLower(std::string value) {
for (char& c : value) {
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
return value;
}
// Elements that should not contribute text position anchors.
static bool isSkippableTag(const std::string& tag) { return tag == "head" || tag == "script" || tag == "style"; }
static bool isWhitespaceOnly(const XML_Char* text, const int len) {
for (int i = 0; i < len; i++) {
if (!std::isspace(static_cast<unsigned char>(text[i]))) {
return false;
}
}
return true;
}
// Count non-whitespace bytes to keep offsets stable against formatting-only
// differences and indentation in source XHTML.
static size_t countVisibleBytes(const XML_Char* text, const int len) {
size_t count = 0;
for (int i = 0; i < len; i++) {
if (!std::isspace(static_cast<unsigned char>(text[i]))) {
count++;
}
}
return count;
}
int bodyDepth() const {
for (int i = static_cast<int>(stack.size()) - 1; i >= 0; i--) {
if (stack[i].tag == "body") {
return i;
}
}
return -1;
}
bool insideBody() const { return bodyDepth() >= 0; }
std::string currentXPath() const {
const int bodyIdx = bodyDepth();
if (bodyIdx < 0) {
return baseXPath();
}
std::string xpath = baseXPath();
for (size_t i = static_cast<size_t>(bodyIdx + 1); i < stack.size(); i++) {
xpath += "/" + stack[i].tag + "[" + std::to_string(stack[i].index) + "]";
}
return xpath;
}
// Adds first anchor for an element when text begins and periodic anchors in
// longer runs so matching has sufficient granularity without exploding memory.
void addAnchorIfNeeded() {
if (!insideBody() || stack.empty()) {
return;
}
if (!stack.back().hasTextAnchor) {
const std::string xpath = currentXPath();
anchors.push_back({totalTextBytes, xpath, removeIndices(xpath)});
stack.back().hasTextAnchor = true;
} else if (anchors.empty() || totalTextBytes - anchors.back().textOffset >= 192) {
const std::string xpath = currentXPath();
if (anchors.empty() || anchors.back().xpath != xpath) {
anchors.push_back({totalTextBytes, xpath, removeIndices(xpath)});
}
}
}
void onStartElement(const XML_Char* rawName) {
std::string name = toLower(rawName ? rawName : "");
const size_t depth = stack.size();
if (siblingCounters.size() <= depth) {
siblingCounters.resize(depth + 1);
}
const int siblingIndex = ++siblingCounters[depth][name];
stack.push_back({name, siblingIndex, false});
siblingCounters.emplace_back();
if (skipDepth < 0 && isSkippableTag(name)) {
skipDepth = static_cast<int>(stack.size()) - 1;
}
}
void onEndElement() {
if (stack.empty()) {
return;
}
if (skipDepth == static_cast<int>(stack.size()) - 1) {
skipDepth = -1;
}
stack.pop_back();
if (!siblingCounters.empty()) {
siblingCounters.pop_back();
}
}
void onCharacterData(const XML_Char* text, const int len) {
if (skipDepth >= 0 || len <= 0 || !insideBody() || isWhitespaceOnly(text, len)) {
return;
}
addAnchorIfNeeded();
totalTextBytes += countVisibleBytes(text, len);
}
std::string chooseXPath(const float intraSpineProgress) const {
if (anchors.empty()) {
return baseXPath();
}
if (totalTextBytes == 0) {
return anchors.front().xpath;
}
const float clampedProgress = std::max(0.0f, std::min(1.0f, intraSpineProgress));
const size_t target = static_cast<size_t>(clampedProgress * static_cast<float>(totalTextBytes));
// upper_bound returns the first anchor strictly after target; step back to get
// the last anchor at-or-before target (the element the user is currently inside).
auto it = std::upper_bound(anchors.begin(), anchors.end(), target,
[](const size_t value, const XPathAnchor& anchor) { return value < anchor.textOffset; });
if (it != anchors.begin()) {
--it;
}
return it->xpath;
}
// Convert path -> progress ratio by matching to nearest available anchor.
bool chooseProgressForXPath(const std::string& xpath, float& outIntraSpineProgress, bool& outExactMatch) const {
if (anchors.empty()) {
return false;
}
const std::string normalized = normalizeXPath(xpath);
if (normalized.empty()) {
return false;
}
size_t matchedOffset = 0;
bool exact = false;
const char* matchTier = nullptr;
bool matched = pickBestAnchorByPath(normalized, false, matchedOffset, exact);
if (matched) {
matchTier = exact ? "exact" : "ancestor";
} else {
bool exactRaw = false;
matched = pickBestAnchorByPath(normalized, true, matchedOffset, exactRaw);
if (matched) {
exact = false;
matchTier = exactRaw ? "index-insensitive" : "index-insensitive-ancestor";
}
}
if (!matched) {
LOG_DBG("KOX", "Reverse: spine=%d no anchor match for '%s' (%zu anchors)", spineIndex, normalized.c_str(),
anchors.size());
return false;
}
outExactMatch = exact;
if (totalTextBytes == 0) {
outIntraSpineProgress = 0.0f;
LOG_DBG("KOX", "Reverse: spine=%d %s match offset=%zu -> progress=0.0 (no text)", spineIndex, matchTier,
matchedOffset);
return true;
}
outIntraSpineProgress = static_cast<float>(matchedOffset) / static_cast<float>(totalTextBytes);
outIntraSpineProgress = std::max(0.0f, std::min(1.0f, outIntraSpineProgress));
LOG_DBG("KOX", "Reverse: spine=%d %s match offset=%zu/%zu -> progress=%.3f", spineIndex, matchTier, matchedOffset,
totalTextBytes, outIntraSpineProgress);
return true;
}
};
void XMLCALL onStartElement(void* userData, const XML_Char* name, const XML_Char**) {
auto* state = static_cast<ParserState*>(userData);
state->onStartElement(name);
}
void XMLCALL onEndElement(void* userData, const XML_Char*) {
auto* state = static_cast<ParserState*>(userData);
state->onEndElement();
}
void XMLCALL onCharacterData(void* userData, const XML_Char* text, const int len) {
auto* state = static_cast<ParserState*>(userData);
state->onCharacterData(text, len);
}
void XMLCALL onDefaultHandlerExpand(void* userData, const XML_Char* text, const int len) {
// The default handler fires for comments, PIs, DOCTYPE, and entity references.
// Only forward entity references (&..;) to avoid skewing text offsets with
// non-visible markup.
if (len < 3 || text[0] != '&' || text[len - 1] != ';') {
return;
}
for (int i = 1; i < len - 1; ++i) {
if (text[i] == '<' || text[i] == '>') {
return;
}
}
auto* state = static_cast<ParserState*>(userData);
state->onCharacterData(text, len);
}
// Parse one spine item and return a fully populated ParserState.
// Returns std::nullopt if validation, I/O, or XML parse fails.
static std::optional<ParserState> parseSpineItem(const std::shared_ptr<Epub>& epub, const int spineIndex) {
if (!epub || spineIndex < 0 || spineIndex >= epub->getSpineItemsCount()) {
return std::nullopt;
}
const auto spineItem = epub->getSpineItem(spineIndex);
if (spineItem.href.empty()) {
return std::nullopt;
}
size_t chapterSize = 0;
uint8_t* chapterBytes = epub->readItemContentsToBytes(spineItem.href, &chapterSize, false);
if (!chapterBytes || chapterSize == 0) {
free(chapterBytes);
return std::nullopt;
}
ParserState state(spineIndex);
XML_Parser parser = XML_ParserCreate(nullptr);
if (!parser) {
free(chapterBytes);
LOG_ERR("KOX", "Failed to allocate XML parser for spine=%d", spineIndex);
return std::nullopt;
}
XML_SetUserData(parser, &state);
XML_SetElementHandler(parser, onStartElement, onEndElement);
XML_SetCharacterDataHandler(parser, onCharacterData);
XML_SetDefaultHandlerExpand(parser, onDefaultHandlerExpand);
const bool parseOk = XML_Parse(parser, reinterpret_cast<const char*>(chapterBytes), static_cast<int>(chapterSize),
XML_TRUE) != XML_STATUS_ERROR;
if (!parseOk) {
LOG_ERR("KOX", "XPath parse failed for spine=%d at line %lu: %s", spineIndex, XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser)));
}
XML_ParserFree(parser);
free(chapterBytes);
if (!parseOk) {
return std::nullopt;
}
return state;
}
} // namespace
std::string ChapterXPathIndexer::findXPathForProgress(const std::shared_ptr<Epub>& epub, const int spineIndex,
const float intraSpineProgress) {
const auto state = parseSpineItem(epub, spineIndex);
if (!state) {
return "";
}
const std::string result = state->chooseXPath(intraSpineProgress);
LOG_DBG("KOX", "Forward: spine=%d progress=%.3f anchors=%zu textBytes=%zu -> %s", spineIndex, intraSpineProgress,
state->anchors.size(), state->totalTextBytes, result.c_str());
return result;
}
bool ChapterXPathIndexer::findProgressForXPath(const std::shared_ptr<Epub>& epub, const int spineIndex,
const std::string& xpath, float& outIntraSpineProgress,
bool& outExactMatch) {
outIntraSpineProgress = 0.0f;
outExactMatch = false;
if (xpath.empty()) {
return false;
}
const auto state = parseSpineItem(epub, spineIndex);
if (!state) {
return false;
}
LOG_DBG("KOX", "Reverse: spine=%d anchors=%zu textBytes=%zu for '%s'", spineIndex, state->anchors.size(),
state->totalTextBytes, xpath.c_str());
return state->chooseProgressForXPath(xpath, outIntraSpineProgress, outExactMatch);
}
bool ChapterXPathIndexer::tryExtractSpineIndexFromXPath(const std::string& xpath, int& outSpineIndex) {
outSpineIndex = -1;
if (xpath.empty()) {
return false;
}
const std::string normalized = ParserState::normalizeXPath(xpath);
const std::string key = "/docfragment[";
const size_t pos = normalized.find(key);
if (pos == std::string::npos) {
LOG_DBG("KOX", "No DocFragment in xpath: '%s'", xpath.c_str());
return false;
}
const size_t start = pos + key.size();
size_t end = start;
while (end < normalized.size() && std::isdigit(static_cast<unsigned char>(normalized[end]))) {
end++;
}
if (end == start || end >= normalized.size() || normalized[end] != ']') {
return false;
}
const std::string value = normalized.substr(start, end - start);
const long parsed = std::strtol(value.c_str(), nullptr, 10);
// KOReader uses 1-based DocFragment indices; convert to 0-based spine index.
if (parsed < 1 || parsed > std::numeric_limits<int>::max()) {
return false;
}
outSpineIndex = static_cast<int>(parsed) - 1;
return true;
}

View File

@@ -0,0 +1,67 @@
#pragma once
#include <Epub.h>
#include <memory>
#include <string>
/**
* Lightweight XPath/progress bridge for KOReader sync.
*
* Why this exists:
* - CrossPoint stores reading position as chapter/page.
* - KOReader sync uses XPath + percentage.
*
* This utility reparses exactly one spine XHTML item with Expat and builds
* transient text anchors (<xpath, textOffset>) so we can translate in both
* directions without keeping a full DOM in memory.
*
* Design constraints (ESP32-C3):
* - No persistent full-book structures.
* - Parse-on-demand and free memory immediately.
* - Keep fallback behavior deterministic if parsing/matching fails.
*/
class ChapterXPathIndexer {
public:
/**
* Convert an intra-spine progress ratio to the nearest element-level XPath.
*
* @param epub Loaded EPUB instance
* @param spineIndex Current spine item index
* @param intraSpineProgress Position within the spine item [0.0, 1.0]
* @return Best matching XPath for KOReader, or empty string on failure
*/
static std::string findXPathForProgress(const std::shared_ptr<Epub>& epub, int spineIndex, float intraSpineProgress);
/**
* Resolve a KOReader XPath to an intra-spine progress ratio.
*
* Matching strategy:
* 1) exact anchor path match,
* 2) index-insensitive path match,
* 3) ancestor fallback.
*
* @param epub Loaded EPUB instance
* @param spineIndex Spine item index to parse
* @param xpath Incoming KOReader XPath
* @param outIntraSpineProgress Resolved position within spine [0.0, 1.0]
* @param outExactMatch True only for full exact path match
* @return true if any match was resolved; false means caller should fallback
*/
static bool findProgressForXPath(const std::shared_ptr<Epub>& epub, int spineIndex, const std::string& xpath,
float& outIntraSpineProgress, bool& outExactMatch);
/**
* Parse DocFragment index from KOReader-style path segment:
* /body/DocFragment[N]/body/...
*
* KOReader uses 1-based DocFragment indices; N is converted to the 0-based
* spine index stored in outSpineIndex (i.e. outSpineIndex = N - 1).
*
* @param xpath KOReader XPath
* @param outSpineIndex 0-based spine index derived from DocFragment[N]
* @return true when DocFragment[N] exists and N is a valid integer >= 1
* (converted to 0-based outSpineIndex); false otherwise
*/
static bool tryExtractSpineIndexFromXPath(const std::string& xpath, int& outSpineIndex);
};

View File

@@ -4,6 +4,8 @@
#include <Logging.h> #include <Logging.h>
#include <MD5Builder.h> #include <MD5Builder.h>
#include <functional>
namespace { namespace {
// Extract filename from path (everything after last '/') // Extract filename from path (everything after last '/')
std::string getFilename(const std::string& path) { std::string getFilename(const std::string& path) {
@@ -15,6 +17,130 @@ std::string getFilename(const std::string& path) {
} }
} // namespace } // namespace
std::string KOReaderDocumentId::getCacheFilePath(const std::string& filePath) {
// Mirror the Epub cache directory convention so the hash file shares the
// same per-book folder as other cached data.
return std::string("/.crosspoint/epub_") + std::to_string(std::hash<std::string>{}(filePath)) + "/koreader_docid.txt";
}
std::string KOReaderDocumentId::loadCachedHash(const std::string& cacheFilePath, const size_t fileSize,
const std::string& currentFingerprint) {
if (!Storage.exists(cacheFilePath.c_str())) {
return "";
}
const String content = Storage.readFile(cacheFilePath.c_str());
if (content.isEmpty()) {
return "";
}
// Format: "<filesize>:<fingerprint>\n<32-char-hex-hash>"
const int newlinePos = content.indexOf('\n');
if (newlinePos < 0) {
return "";
}
const String header = content.substring(0, newlinePos);
const int colonPos = header.indexOf(':');
if (colonPos < 0) {
LOG_DBG("KODoc", "Hash cache invalidated: header missing fingerprint");
return "";
}
const String sizeTok = header.substring(0, colonPos);
const String fpTok = header.substring(colonPos + 1);
// Validate the filesize token it must consist of ASCII digits and parse
// correctly to the expected size.
bool digitsOnly = true;
for (size_t i = 0; i < sizeTok.length(); ++i) {
const char ch = sizeTok[i];
if (ch < '0' || ch > '9') {
digitsOnly = false;
break;
}
}
if (!digitsOnly) {
LOG_DBG("KODoc", "Hash cache invalidated: size token not numeric ('%s')", sizeTok.c_str());
return "";
}
const long parsed = sizeTok.toInt();
if (parsed < 0) {
LOG_DBG("KODoc", "Hash cache invalidated: size token parse error ('%s')", sizeTok.c_str());
return "";
}
const size_t cachedSize = static_cast<size_t>(parsed);
if (cachedSize != fileSize) {
LOG_DBG("KODoc", "Hash cache invalidated: file size or fingerprint changed (%zu -> %zu)", cachedSize, fileSize);
return "";
}
// Validate stored fingerprint format (8 hex characters)
if (fpTok.length() != 8) {
LOG_DBG("KODoc", "Hash cache invalidated: bad fingerprint length (%zu)", fpTok.length());
return "";
}
for (size_t i = 0; i < fpTok.length(); ++i) {
char c = fpTok[i];
bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
if (!hex) {
LOG_DBG("KODoc", "Hash cache invalidated: non-hex character '%c' in fingerprint", c);
return "";
}
}
{
String currentFpStr(currentFingerprint.c_str());
if (fpTok != currentFpStr) {
LOG_DBG("KODoc", "Hash cache invalidated: fingerprint changed (%s != %s)", fpTok.c_str(),
currentFingerprint.c_str());
return "";
}
}
std::string hash = content.substring(newlinePos + 1).c_str();
// Trim any trailing whitespace / line endings
while (!hash.empty() && (hash.back() == '\n' || hash.back() == '\r' || hash.back() == ' ')) {
hash.pop_back();
}
// Hash must be exactly 32 hex characters.
if (hash.size() != 32) {
LOG_DBG("KODoc", "Hash cache invalidated: wrong hash length (%zu)", hash.size());
return "";
}
for (char c : hash) {
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) {
LOG_DBG("KODoc", "Hash cache invalidated: non-hex character '%c' in hash", c);
return "";
}
}
LOG_DBG("KODoc", "Hash cache hit: %s", hash.c_str());
return hash;
}
void KOReaderDocumentId::saveCachedHash(const std::string& cacheFilePath, const size_t fileSize,
const std::string& fingerprint, const std::string& hash) {
// Ensure the book's cache directory exists before writing
const size_t lastSlash = cacheFilePath.rfind('/');
if (lastSlash != std::string::npos) {
Storage.ensureDirectoryExists(cacheFilePath.substr(0, lastSlash).c_str());
}
// Format: "<filesize>:<fingerprint>\n<hash>"
String content(std::to_string(fileSize).c_str());
content += ':';
content += fingerprint.c_str();
content += '\n';
content += hash.c_str();
if (!Storage.writeFile(cacheFilePath.c_str(), content)) {
LOG_DBG("KODoc", "Failed to write hash cache to %s", cacheFilePath.c_str());
}
}
std::string KOReaderDocumentId::calculateFromFilename(const std::string& filePath) { std::string KOReaderDocumentId::calculateFromFilename(const std::string& filePath) {
const std::string filename = getFilename(filePath); const std::string filename = getFilename(filePath);
if (filename.empty()) { if (filename.empty()) {
@@ -49,6 +175,30 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) {
} }
const size_t fileSize = file.fileSize(); const size_t fileSize = file.fileSize();
// Compute a lightweight fingerprint from the file's modification time.
// The underlying FsFile API provides getModifyDateTime which returns two
// packed 16-bit values (date and time). Concatenate these as eight hex
// digits to produce the token stored in the cache header.
uint16_t date = 0, time = 0;
if (!file.getModifyDateTime(&date, &time)) {
// If timestamp isn't available for some reason, fall back to a sentinel.
date = 0;
time = 0;
}
char fpBuf[9];
// two 16-bit numbers => 4 hex digits each
sprintf(fpBuf, "%04x%04x", date, time);
const std::string fingerprintTok(fpBuf);
// Return persisted hash if the file size and fingerprint haven't changed.
const std::string cacheFilePath = getCacheFilePath(filePath);
const std::string cached = loadCachedHash(cacheFilePath, fileSize, fingerprintTok);
if (!cached.empty()) {
file.close();
return cached;
}
LOG_DBG("KODoc", "Calculating hash for file: %s (size: %zu)", filePath.c_str(), fileSize); LOG_DBG("KODoc", "Calculating hash for file: %s (size: %zu)", filePath.c_str(), fileSize);
// Initialize MD5 builder // Initialize MD5 builder
@@ -92,5 +242,7 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) {
LOG_DBG("KODoc", "Hash calculated: %s (from %zu bytes)", result.c_str(), totalBytesRead); LOG_DBG("KODoc", "Hash calculated: %s (from %zu bytes)", result.c_str(), totalBytesRead);
saveCachedHash(cacheFilePath, fileSize, fingerprintTok, result);
return result; return result;
} }

View File

@@ -42,4 +42,31 @@ class KOReaderDocumentId {
// Calculate offset for index i: 1024 << (2*i) // Calculate offset for index i: 1024 << (2*i)
static size_t getOffset(int i); static size_t getOffset(int i);
// Hash cache helpers
// Returns the path to the per-book cache file that stores the precomputed hash.
// Uses the same directory convention as the Epub cache (/.crosspoint/epub_<hash>/).
static std::string getCacheFilePath(const std::string& filePath);
// Returns the cached hash if the file size and fingerprint match, or empty
// string on miss/invalidation.
//
// The fingerprint is derived from the file's modification timestamp. We
// call `FsFile::getModifyDateTime` to retrieve two 16bit packed values
// supplied by the filesystem: one for the date and one for the time. These
// are concatenated and represented as eight hexadecimal digits in the form
// <date><time> (high 16 bits = packed date, low 16 bits = packed time).
//
// The resulting string serves as a lightweight change signal; any modification
// to the file's mtime will alter the packed date/time combo and invalidate
// the cache entry. Since the full document hash is expensive to compute,
// using the packed timestamp gives us a quick way to detect modifications
// without reading file contents.
static std::string loadCachedHash(const std::string& cacheFilePath, size_t fileSize,
const std::string& currentFingerprint);
// Persists the computed hash alongside the file size and fingerprint (the
// modification-timestamp token) used to generate it.
static void saveCachedHash(const std::string& cacheFilePath, size_t fileSize, const std::string& fingerprint,
const std::string& hash);
}; };

View File

@@ -2,8 +2,11 @@
#include <Logging.h> #include <Logging.h>
#include <algorithm>
#include <cmath> #include <cmath>
#include "ChapterXPathIndexer.h"
KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos) { KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos) {
KOReaderPosition result; KOReaderPosition result;
@@ -16,8 +19,13 @@ 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 with estimated paragraph position based on page // Generate the best available XPath for the current chapter position.
result.xpath = generateXPath(pos.spineIndex, pos.pageNumber, pos.totalPages); // Prefer element-level XPaths from a lightweight XHTML reparse; fall back
// to a synthetic chapter-level path if parsing fails.
result.xpath = ChapterXPathIndexer::findXPathForProgress(epub, pos.spineIndex, intraSpineProgress);
if (result.xpath.empty()) {
result.xpath = generateXPath(pos.spineIndex);
}
// Get chapter info for logging // Get chapter info for logging
const int tocIndex = epub->getTocIndexForSpineIndex(pos.spineIndex); const int tocIndex = epub->getTocIndexForSpineIndex(pos.spineIndex);
@@ -36,17 +44,41 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
result.pageNumber = 0; result.pageNumber = 0;
result.totalPages = 0; result.totalPages = 0;
if (!epub || epub->getSpineItemsCount() <= 0) {
return result;
}
const int spineCount = epub->getSpineItemsCount();
float resolvedIntraSpineProgress = -1.0f;
bool xpathExactMatch = false;
bool usedXPathMapping = false;
int xpathSpineIndex = -1;
if (ChapterXPathIndexer::tryExtractSpineIndexFromXPath(koPos.xpath, xpathSpineIndex) && xpathSpineIndex >= 0 &&
xpathSpineIndex < spineCount) {
float intraFromXPath = 0.0f;
if (ChapterXPathIndexer::findProgressForXPath(epub, xpathSpineIndex, koPos.xpath, intraFromXPath,
xpathExactMatch)) {
result.spineIndex = xpathSpineIndex;
resolvedIntraSpineProgress = intraFromXPath;
usedXPathMapping = true;
}
}
if (!usedXPathMapping) {
const size_t bookSize = epub->getBookSize(); const size_t bookSize = epub->getBookSize();
if (bookSize == 0) { if (bookSize == 0) {
return result; return result;
} }
// Use percentage-based lookup for both spine and page positioning if (!std::isfinite(koPos.percentage)) {
// XPath parsing is unreliable since CrossPoint doesn't preserve detailed HTML structure return result;
const size_t targetBytes = static_cast<size_t>(bookSize * koPos.percentage); }
const float sanitizedPercentage = std::clamp(koPos.percentage, 0.0f, 1.0f);
const size_t targetBytes = static_cast<size_t>(bookSize * sanitizedPercentage);
// Find the spine item that contains this byte position
const int spineCount = epub->getSpineItemsCount();
bool spineFound = false; bool spineFound = false;
for (int i = 0; i < spineCount; i++) { for (int i = 0; i < spineCount; i++) {
const size_t cumulativeSize = epub->getCumulativeSpineItemSize(i); const size_t cumulativeSize = epub->getCumulativeSpineItemSize(i);
@@ -57,13 +89,24 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
} }
} }
// If no spine item was found (e.g., targetBytes beyond last cumulative size),
// default to the last spine item so we map to the end of the book instead of the beginning.
if (!spineFound && spineCount > 0) { if (!spineFound && spineCount > 0) {
result.spineIndex = spineCount - 1; result.spineIndex = spineCount - 1;
} }
// Estimate page number within the spine item using percentage if (result.spineIndex < epub->getSpineItemsCount()) {
const size_t prevCumSize = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0;
const size_t currentCumSize = epub->getCumulativeSpineItemSize(result.spineIndex);
const size_t spineSize = currentCumSize - prevCumSize;
if (spineSize > 0) {
const size_t bytesIntoSpine = (targetBytes > prevCumSize) ? (targetBytes - prevCumSize) : 0;
resolvedIntraSpineProgress = static_cast<float>(bytesIntoSpine) / static_cast<float>(spineSize);
resolvedIntraSpineProgress = std::max(0.0f, std::min(1.0f, resolvedIntraSpineProgress));
}
}
}
// Estimate page number within the selected spine item
if (result.spineIndex < epub->getSpineItemsCount()) { if (result.spineIndex < epub->getSpineItemsCount()) {
const size_t prevCumSize = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0; const size_t prevCumSize = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0;
const size_t currentCumSize = epub->getCumulativeSpineItemSize(result.spineIndex); const size_t currentCumSize = epub->getCumulativeSpineItemSize(result.spineIndex);
@@ -91,24 +134,24 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
result.totalPages = estimatedTotalPages; result.totalPages = estimatedTotalPages;
if (spineSize > 0 && estimatedTotalPages > 0) { if (estimatedTotalPages > 0 && resolvedIntraSpineProgress >= 0.0f) {
const size_t bytesIntoSpine = (targetBytes > prevCumSize) ? (targetBytes - prevCumSize) : 0; const float clampedProgress = std::max(0.0f, std::min(1.0f, resolvedIntraSpineProgress));
const float intraSpineProgress = static_cast<float>(bytesIntoSpine) / static_cast<float>(spineSize); result.pageNumber = static_cast<int>(clampedProgress * static_cast<float>(estimatedTotalPages));
const float clampedProgress = std::max(0.0f, std::min(1.0f, intraSpineProgress));
result.pageNumber = static_cast<int>(clampedProgress * estimatedTotalPages);
result.pageNumber = std::max(0, std::min(result.pageNumber, estimatedTotalPages - 1)); result.pageNumber = std::max(0, std::min(result.pageNumber, estimatedTotalPages - 1));
} else if (spineSize > 0 && estimatedTotalPages > 0) {
result.pageNumber = 0;
} }
} }
LOG_DBG("ProgressMapper", "KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d", koPos.percentage * 100, LOG_DBG("ProgressMapper", "KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d (%s, exact=%s)",
koPos.xpath.c_str(), result.spineIndex, result.pageNumber); koPos.percentage * 100, koPos.xpath.c_str(), result.spineIndex, result.pageNumber,
usedXPathMapping ? "xpath" : "percentage", xpathExactMatch ? "yes" : "no");
return result; return result;
} }
std::string ProgressMapper::generateXPath(int spineIndex, int pageNumber, int totalPages) { std::string ProgressMapper::generateXPath(int spineIndex) {
// Use 0-based DocFragment indices for KOReader // Fallback path when element-level XPath extraction is unavailable.
// Use a simple xpath pointing to the DocFragment - KOReader will use the percentage for fine positioning within it // KOReader uses 1-based XPath predicates; spineIndex is 0-based internally.
// Avoid specifying paragraph numbers as they may not exist in the target document return "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body";
return "/body/DocFragment[" + std::to_string(spineIndex) + "]/body";
} }

View File

@@ -27,9 +27,16 @@ struct KOReaderPosition {
* CrossPoint tracks position as (spineIndex, pageNumber). * CrossPoint tracks position as (spineIndex, pageNumber).
* KOReader uses XPath-like strings + percentage. * KOReader uses XPath-like strings + percentage.
* *
* Since CrossPoint discards HTML structure during parsing, we generate * Forward mapping (CrossPoint -> KOReader):
* synthetic XPath strings based on spine index, using percentage as the * - Prefer element-level XPath extracted from current spine XHTML.
* primary sync mechanism. * - Fallback to synthetic chapter XPath if extraction fails.
*
* Reverse mapping (KOReader -> CrossPoint):
* - Prefer incoming XPath (DocFragment + element path) when resolvable.
* - Fallback to percentage-based approximation when XPath is missing/invalid.
*
* This keeps behavior stable on low-memory devices while improving round-trip
* sync precision when KOReader provides detailed paths.
*/ */
class ProgressMapper { class ProgressMapper {
public: public:
@@ -45,8 +52,9 @@ class ProgressMapper {
/** /**
* Convert KOReader position to CrossPoint format. * Convert KOReader position to CrossPoint format.
* *
* Note: The returned pageNumber may be approximate since different * Uses XPath-first resolution when possible and percentage fallback otherwise.
* rendering settings produce different page counts. * Returned pageNumber can still be approximate because page counts differ
* across renderer/font/layout settings.
* *
* @param epub The EPUB book * @param epub The EPUB book
* @param koPos KOReader position * @param koPos KOReader position
@@ -60,8 +68,7 @@ class ProgressMapper {
private: private:
/** /**
* Generate XPath for KOReader compatibility. * Generate XPath for KOReader compatibility.
* Format: /body/DocFragment[spineIndex+1]/body * Fallback format: /body/DocFragment[spineIndex + 1]/body
* Since CrossPoint doesn't preserve HTML structure, we rely on percentage for positioning.
*/ */
static std::string generateXPath(int spineIndex, int pageNumber, int totalPages); static std::string generateXPath(int spineIndex);
}; };

View File

@@ -144,6 +144,7 @@ uint8_t CrossPointSettings::writeSettings(FsFile& file, bool count_only) const {
writer.writeItem(file, timezone); writer.writeItem(file, timezone);
writer.writeItem(file, timezoneOffsetHours); writer.writeItem(file, timezoneOffsetHours);
writer.writeItem(file, indexingDisplay); writer.writeItem(file, indexingDisplay);
writer.writeItem(file, autoNtpSync);
return writer.item_count; return writer.item_count;
} }
@@ -288,6 +289,8 @@ bool CrossPointSettings::loadFromFile() {
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
readAndValidate(inputFile, indexingDisplay, INDEXING_DISPLAY_COUNT); readAndValidate(inputFile, indexingDisplay, INDEXING_DISPLAY_COUNT);
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, autoNtpSync);
if (++settingsRead >= fileSettingsCount) break;
} while (false); } while (false);
if (frontButtonMappingRead) { if (frontButtonMappingRead) {

View File

@@ -227,6 +227,9 @@ class CrossPointSettings {
// Custom timezone offset in hours from UTC (-12 to +14) // Custom timezone offset in hours from UTC (-12 to +14)
int8_t timezoneOffsetHours = 0; int8_t timezoneOffsetHours = 0;
// Automatically sync time via NTP on boot using saved WiFi credentials
uint8_t autoNtpSync = 0;
~CrossPointSettings() = default; ~CrossPointSettings() = default;
// Get singleton instance // Get singleton instance

203
src/OpdsServerStore.cpp Normal file
View File

@@ -0,0 +1,203 @@
#include "OpdsServerStore.h"
#include <ArduinoJson.h>
#include <HalStorage.h>
#include <Logging.h>
#include <base64.h>
#include <esp_mac.h>
#include <mbedtls/base64.h>
#include <cstring>
#include "CrossPointSettings.h"
OpdsServerStore OpdsServerStore::instance;
namespace {
constexpr char OPDS_FILE_JSON[] = "/.crosspoint/opds.json";
constexpr size_t HW_KEY_LEN = 6;
const uint8_t* getHwKey() {
static uint8_t key[HW_KEY_LEN] = {};
static bool initialized = false;
if (!initialized) {
esp_efuse_mac_get_default(key);
initialized = true;
}
return key;
}
void xorTransform(std::string& data) {
const uint8_t* key = getHwKey();
for (size_t i = 0; i < data.size(); i++) {
data[i] ^= key[i % HW_KEY_LEN];
}
}
String obfuscateToBase64(const std::string& plaintext) {
if (plaintext.empty()) return "";
std::string temp = plaintext;
xorTransform(temp);
return base64::encode(reinterpret_cast<const uint8_t*>(temp.data()), temp.size());
}
std::string deobfuscateFromBase64(const char* encoded, bool* ok) {
if (encoded == nullptr || encoded[0] == '\0') {
if (ok) *ok = false;
return "";
}
if (ok) *ok = true;
size_t encodedLen = strlen(encoded);
size_t decodedLen = 0;
int ret = mbedtls_base64_decode(nullptr, 0, &decodedLen, reinterpret_cast<const unsigned char*>(encoded), encodedLen);
if (ret != 0 && ret != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) {
LOG_ERR("OPS", "Base64 decode size query failed (ret=%d)", ret);
if (ok) *ok = false;
return "";
}
std::string result(decodedLen, '\0');
ret = mbedtls_base64_decode(reinterpret_cast<unsigned char*>(&result[0]), decodedLen, &decodedLen,
reinterpret_cast<const unsigned char*>(encoded), encodedLen);
if (ret != 0) {
LOG_ERR("OPS", "Base64 decode failed (ret=%d)", ret);
if (ok) *ok = false;
return "";
}
result.resize(decodedLen);
xorTransform(result);
return result;
}
} // namespace
bool OpdsServerStore::saveToFile() const {
Storage.mkdir("/.crosspoint");
JsonDocument doc;
JsonArray arr = doc["servers"].to<JsonArray>();
for (const auto& server : servers) {
JsonObject obj = arr.add<JsonObject>();
obj["name"] = server.name;
obj["url"] = server.url;
obj["username"] = server.username;
obj["password_obf"] = obfuscateToBase64(server.password);
obj["download_path"] = server.downloadPath;
}
String json;
serializeJson(doc, json);
return Storage.writeFile(OPDS_FILE_JSON, json);
}
bool OpdsServerStore::loadFromFile() {
if (Storage.exists(OPDS_FILE_JSON)) {
String json = Storage.readFile(OPDS_FILE_JSON);
if (!json.isEmpty()) {
JsonDocument doc;
auto error = deserializeJson(doc, json.c_str());
if (error) {
LOG_ERR("OPS", "JSON parse error: %s", error.c_str());
return false;
}
servers.clear();
bool needsResave = false;
JsonArray arr = doc["servers"].as<JsonArray>();
for (JsonObject obj : arr) {
if (servers.size() >= MAX_SERVERS) break;
OpdsServer server;
server.name = obj["name"] | std::string("");
server.url = obj["url"] | std::string("");
server.username = obj["username"] | std::string("");
bool ok = false;
server.password = deobfuscateFromBase64(obj["password_obf"] | "", &ok);
if (!ok || server.password.empty()) {
server.password = obj["password"] | std::string("");
if (!server.password.empty()) needsResave = true;
}
server.downloadPath = obj["download_path"] | std::string("/");
servers.push_back(std::move(server));
}
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", servers.size());
if (needsResave) {
LOG_DBG("OPS", "Resaving JSON with obfuscated passwords");
saveToFile();
}
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()) {
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;
}
servers.clear();
return false;
}
bool OpdsServerStore::addServer(const OpdsServer& server) {
if (servers.size() >= MAX_SERVERS) {
LOG_DBG("OPS", "Cannot add more servers, limit of %zu reached", MAX_SERVERS);
return false;
}
servers.push_back(server);
LOG_DBG("OPS", "Added server: %s", server.name.c_str());
return saveToFile();
}
bool OpdsServerStore::updateServer(size_t index, const OpdsServer& server) {
if (index >= servers.size()) {
return false;
}
servers[index] = server;
LOG_DBG("OPS", "Updated server: %s", server.name.c_str());
return saveToFile();
}
bool OpdsServerStore::removeServer(size_t index) {
if (index >= servers.size()) {
return false;
}
LOG_DBG("OPS", "Removed server: %s", servers[index].name.c_str());
servers.erase(servers.begin() + static_cast<ptrdiff_t>(index));
return saveToFile();
}
const OpdsServer* OpdsServerStore::getServer(size_t index) const {
if (index >= servers.size()) {
return nullptr;
}
return &servers[index];
}

52
src/OpdsServerStore.h Normal file
View File

@@ -0,0 +1,52 @@
#pragma once
#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
std::string downloadPath = "/";
};
/**
* 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;
static constexpr size_t MAX_SERVERS = 8;
OpdsServerStore() = default;
public:
OpdsServerStore(const OpdsServerStore&) = delete;
OpdsServerStore& operator=(const OpdsServerStore&) = delete;
static OpdsServerStore& getInstance() { return instance; }
bool saveToFile() const;
bool loadFromFile();
bool 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()

View File

@@ -91,6 +91,8 @@ inline std::vector<SettingInfo> getSettingsList() {
{StrId::STR_TZ_UTC, StrId::STR_TZ_EASTERN, StrId::STR_TZ_CENTRAL, StrId::STR_TZ_MOUNTAIN, {StrId::STR_TZ_UTC, StrId::STR_TZ_EASTERN, StrId::STR_TZ_CENTRAL, StrId::STR_TZ_MOUNTAIN,
StrId::STR_TZ_PACIFIC, StrId::STR_TZ_ALASKA, StrId::STR_TZ_HAWAII, StrId::STR_TZ_CUSTOM}, StrId::STR_TZ_PACIFIC, StrId::STR_TZ_ALASKA, StrId::STR_TZ_HAWAII, StrId::STR_TZ_CUSTOM},
"timezone", StrId::STR_CAT_CLOCK), "timezone", StrId::STR_CAT_CLOCK),
SettingInfo::Toggle(StrId::STR_AUTO_NTP_SYNC, &CrossPointSettings::autoNtpSync, "autoNtpSync",
StrId::STR_CAT_CLOCK),
// --- Reader --- // --- Reader ---
SettingInfo::DynamicEnum( SettingInfo::DynamicEnum(
@@ -189,13 +191,5 @@ inline std::vector<SettingInfo> getSettingsList() {
KOREADER_STORE.saveToFile(); KOREADER_STORE.saveToFile();
}, },
"koMatchMethod", StrId::STR_KOREADER_SYNC), "koMatchMethod", StrId::STR_KOREADER_SYNC),
// --- OPDS Browser (web-only, uses CrossPointSettings char arrays) ---
SettingInfo::String(StrId::STR_OPDS_SERVER_URL, SETTINGS.opdsServerUrl, sizeof(SETTINGS.opdsServerUrl),
"opdsServerUrl", StrId::STR_OPDS_BROWSER),
SettingInfo::String(StrId::STR_USERNAME, SETTINGS.opdsUsername, sizeof(SETTINGS.opdsUsername), "opdsUsername",
StrId::STR_OPDS_BROWSER),
SettingInfo::String(StrId::STR_PASSWORD, SETTINGS.opdsPassword, sizeof(SETTINGS.opdsPassword), "opdsPassword",
StrId::STR_OPDS_BROWSER),
}; };
} }

View File

@@ -7,9 +7,9 @@
#include <OpdsStream.h> #include <OpdsStream.h>
#include <WiFi.h> #include <WiFi.h>
#include "CrossPointSettings.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "activities/network/WifiSelectionActivity.h" #include "activities/network/WifiSelectionActivity.h"
#include "activities/util/DirectoryPickerActivity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "network/HttpDownloader.h" #include "network/HttpDownloader.h"
@@ -53,6 +53,12 @@ void OpdsBookBrowserActivity::loop() {
return; return;
} }
// Handle directory picker subactivity
if (state == BrowserState::PICKING_DIRECTORY) {
ActivityWithSubactivity::loop();
return;
}
// Handle error state - Confirm retries, Back goes back or home // Handle error state - Confirm retries, Back goes back or home
if (state == BrowserState::ERROR) { if (state == BrowserState::ERROR) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
@@ -102,7 +108,7 @@ void OpdsBookBrowserActivity::loop() {
if (!entries.empty()) { if (!entries.empty()) {
const auto& entry = entries[selectorIndex]; const auto& entry = entries[selectorIndex];
if (entry.type == OpdsEntryType::BOOK) { if (entry.type == OpdsEntryType::BOOK) {
downloadBook(entry); launchDirectoryPicker(entry);
} else { } else {
navigateToEntry(entry); navigateToEntry(entry);
} }
@@ -142,7 +148,8 @@ void OpdsBookBrowserActivity::render(Activity::RenderLock&&) {
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_OPDS_BROWSER), true, EpdFontFamily::BOLD); 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, pageHeight / 2, statusMessage.c_str()); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
@@ -171,7 +178,9 @@ void OpdsBookBrowserActivity::render(Activity::RenderLock&&) {
if (state == BrowserState::DOWNLOADING) { if (state == BrowserState::DOWNLOADING) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, tr(STR_DOWNLOADING)); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, tr(STR_DOWNLOADING));
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 10, statusMessage.c_str()); const auto maxWidth = pageWidth - 40;
auto title = renderer.truncatedText(UI_10_FONT_ID, statusMessage.c_str(), maxWidth);
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 10, title.c_str());
if (downloadTotal > 0) { if (downloadTotal > 0) {
const int barWidth = pageWidth - 100; const int barWidth = pageWidth - 100;
constexpr int barHeight = 20; constexpr int barHeight = 20;
@@ -225,22 +234,21 @@ void OpdsBookBrowserActivity::render(Activity::RenderLock&&) {
} }
void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
const char* serverUrl = SETTINGS.opdsServerUrl; if (server.url.empty()) {
if (strlen(serverUrl) == 0) {
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 = UrlUtils::buildUrl(serverUrl, path); std::string url = 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();
@@ -303,36 +311,60 @@ void OpdsBookBrowserActivity::navigateBack() {
} }
} }
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { void OpdsBookBrowserActivity::launchDirectoryPicker(const OpdsEntry& book) {
pendingBook = book;
state = BrowserState::PICKING_DIRECTORY;
requestUpdate();
enterNewActivity(new DirectoryPickerActivity(
renderer, mappedInput, [this](const std::string& dir) { onDirectorySelected(dir); },
[this] { onDirectoryPickerCancelled(); }, server.downloadPath));
}
void OpdsBookBrowserActivity::onDirectorySelected(const std::string& directory) {
// Copy before exitActivity() destroys the subactivity (and the referenced string)
std::string dir = directory;
exitActivity();
downloadBook(pendingBook, dir);
}
void OpdsBookBrowserActivity::onDirectoryPickerCancelled() {
exitActivity();
state = BrowserState::BROWSING;
requestUpdate();
}
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book, const std::string& directory) {
state = BrowserState::DOWNLOADING; state = BrowserState::DOWNLOADING;
statusMessage = book.title; statusMessage = book.title;
downloadProgress = 0; downloadProgress = 0;
downloadTotal = 0; downloadTotal = 0;
requestUpdate(); requestUpdate();
// Build full download URL std::string downloadUrl = UrlUtils::buildUrl(server.url, book.href);
std::string downloadUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href);
// Create sanitized filename: "Title - Author.epub" or just "Title.epub" if no author
std::string baseName = book.title; std::string baseName = book.title;
if (!book.author.empty()) { if (!book.author.empty()) {
baseName += " - " + book.author; baseName += " - " + book.author;
} }
std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + ".epub"; std::string dir = directory;
if (dir.back() != '/') dir += '/';
std::string filename = dir + StringUtils::sanitizeFilename(baseName) + ".epub";
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(); requestUpdate();
}); },
server.username, server.password);
if (result == HttpDownloader::OK) { if (result == HttpDownloader::OK) {
LOG_DBG("OPDS", "Download complete: %s", filename.c_str()); LOG_DBG("OPDS", "Download complete: %s", filename.c_str());
// Invalidate any existing cache for this file to prevent stale metadata issues
Epub epub(filename, "/.crosspoint"); Epub epub(filename, "/.crosspoint");
epub.clearCache(); epub.clearCache();
LOG_DBG("OPDS", "Cleared cache for: %s", filename.c_str()); LOG_DBG("OPDS", "Cleared cache for: %s", filename.c_str());

View File

@@ -6,6 +6,7 @@
#include <vector> #include <vector>
#include "../ActivityWithSubactivity.h" #include "../ActivityWithSubactivity.h"
#include "OpdsServerStore.h"
#include "util/ButtonNavigator.h" #include "util/ButtonNavigator.h"
/** /**
@@ -20,13 +21,14 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
WIFI_SELECTION, // WiFi selection subactivity is active WIFI_SELECTION, // WiFi selection subactivity is active
LOADING, // Fetching OPDS feed LOADING, // Fetching OPDS feed
BROWSING, // Displaying entries (navigation or books) BROWSING, // Displaying entries (navigation or books)
PICKING_DIRECTORY, // Directory picker subactivity is active
DOWNLOADING, // Downloading selected EPUB DOWNLOADING, // Downloading selected EPUB
ERROR // Error state with message ERROR // Error state with message
}; };
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onGoHome) const std::function<void()>& onGoHome, const OpdsServer& server)
: ActivityWithSubactivity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome) {} : ActivityWithSubactivity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome), server(server) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
@@ -46,6 +48,7 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
size_t downloadTotal = 0; size_t downloadTotal = 0;
const std::function<void()> onGoHome; const std::function<void()> onGoHome;
OpdsServer server; // Copied at construction — safe even if the store changes during browsing
void checkAndConnectWifi(); void checkAndConnectWifi();
void launchWifiSelection(); void launchWifiSelection();
@@ -53,6 +56,11 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
void fetchFeed(const std::string& path); void fetchFeed(const std::string& path);
void navigateToEntry(const OpdsEntry& entry); void navigateToEntry(const OpdsEntry& entry);
void navigateBack(); void navigateBack();
void downloadBook(const OpdsEntry& book); void launchDirectoryPicker(const OpdsEntry& book);
void onDirectorySelected(const std::string& directory);
void onDirectoryPickerCancelled();
void downloadBook(const OpdsEntry& book, const std::string& directory);
bool preventAutoSleep() override { return true; } bool preventAutoSleep() override { return true; }
OpdsEntry pendingBook;
}; };

View File

@@ -17,6 +17,7 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.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"
@@ -28,7 +29,7 @@ int HomeActivity::getMenuItemCount() const {
if (!recentBooks.empty()) { if (!recentBooks.empty()) {
count += recentBooks.size(); count += recentBooks.size();
} }
if (hasOpdsUrl) { if (hasOpdsServers) {
count++; count++;
} }
return count; return count;
@@ -128,8 +129,7 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
void HomeActivity::onEnter() { void HomeActivity::onEnter() {
ActivityWithSubactivity::onEnter(); ActivityWithSubactivity::onEnter();
// Check if OPDS browser URL is configured hasOpdsServers = OPDS_STORE.hasServers();
hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0;
selectorIndex = 0; selectorIndex = 0;
@@ -238,7 +238,7 @@ void HomeActivity::loop() {
int menuSelectedIndex = selectorIndex - static_cast<int>(recentBooks.size()); int menuSelectedIndex = selectorIndex - static_cast<int>(recentBooks.size());
const int myLibraryIdx = idx++; const int myLibraryIdx = idx++;
const int recentsIdx = idx++; const int recentsIdx = idx++;
const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; const int opdsLibraryIdx = hasOpdsServers ? idx++ : -1;
const int fileTransferIdx = idx++; const int fileTransferIdx = idx++;
const int settingsIdx = idx; const int settingsIdx = idx;
@@ -277,7 +277,7 @@ void HomeActivity::render(Activity::RenderLock&&) {
tr(STR_SETTINGS_TITLE)}; tr(STR_SETTINGS_TITLE)};
std::vector<UIIcon> menuIcons = {Folder, Recent, Transfer, Settings}; std::vector<UIIcon> menuIcons = {Folder, Recent, Transfer, Settings};
if (hasOpdsUrl) { if (hasOpdsServers) {
// Insert OPDS Browser after My Library // Insert OPDS Browser after My Library
menuItems.insert(menuItems.begin() + 2, tr(STR_OPDS_BROWSER)); menuItems.insert(menuItems.begin() + 2, tr(STR_OPDS_BROWSER));
menuIcons.insert(menuIcons.begin() + 2, Library); menuIcons.insert(menuIcons.begin() + 2, Library);

View File

@@ -15,7 +15,7 @@ class HomeActivity final : public ActivityWithSubactivity {
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
uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image

View File

@@ -4,8 +4,6 @@
#include <HalStorage.h> #include <HalStorage.h>
#include <I18n.h> #include <I18n.h>
#include <algorithm>
#include "BookManageMenuActivity.h" #include "BookManageMenuActivity.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "components/UITheme.h" #include "components/UITheme.h"
@@ -17,58 +15,6 @@ namespace {
constexpr unsigned long GO_HOME_MS = 1000; constexpr unsigned long GO_HOME_MS = 1000;
} // namespace } // namespace
void sortFileList(std::vector<std::string>& strs) {
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
// Directories first
bool isDir1 = str1.back() == '/';
bool isDir2 = str2.back() == '/';
if (isDir1 != isDir2) return isDir1;
// Start naive natural sort
const char* s1 = str1.c_str();
const char* s2 = str2.c_str();
// Iterate while both strings have characters
while (*s1 && *s2) {
// Check if both are at the start of a number
if (isdigit(*s1) && isdigit(*s2)) {
// Skip leading zeros and track them
const char* start1 = s1;
const char* start2 = s2;
while (*s1 == '0') s1++;
while (*s2 == '0') s2++;
// Count digits to compare lengths first
int len1 = 0, len2 = 0;
while (isdigit(s1[len1])) len1++;
while (isdigit(s2[len2])) len2++;
// Different length so return smaller integer value
if (len1 != len2) return len1 < len2;
// Same length so compare digit by digit
for (int i = 0; i < len1; i++) {
if (s1[i] != s2[i]) return s1[i] < s2[i];
}
// Numbers equal so advance pointers
s1 += len1;
s2 += len2;
} else {
// Regular case-insensitive character comparison
char c1 = tolower(*s1);
char c2 = tolower(*s2);
if (c1 != c2) return c1 < c2;
s1++;
s2++;
}
}
// One string is prefix of other
return *s1 == '\0' && *s2 != '\0';
});
}
void MyLibraryActivity::loadFiles() { void MyLibraryActivity::loadFiles() {
files.clear(); files.clear();
@@ -101,7 +47,7 @@ void MyLibraryActivity::loadFiles() {
file.close(); file.close();
} }
root.close(); root.close();
sortFileList(files); StringUtils::sortFileList(files);
} }
void MyLibraryActivity::onEnter() { void MyLibraryActivity::onEnter() {

View File

@@ -13,6 +13,7 @@
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "NetworkModeSelectionActivity.h" #include "NetworkModeSelectionActivity.h"
#include "WifiSelectionActivity.h" #include "WifiSelectionActivity.h"
#include "util/BootNtpSync.h"
#include "activities/network/CalibreConnectActivity.h" #include "activities/network/CalibreConnectActivity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
@@ -35,6 +36,8 @@ constexpr uint16_t DNS_PORT = 53;
void CrossPointWebServerActivity::onEnter() { void CrossPointWebServerActivity::onEnter() {
ActivityWithSubactivity::onEnter(); ActivityWithSubactivity::onEnter();
BootNtpSync::cancel();
LOG_DBG("WEBACT", "Free heap at onEnter: %d bytes", ESP.getFreeHeap()); LOG_DBG("WEBACT", "Free heap at onEnter: %d bytes", ESP.getFreeHeap());
// Reset state // Reset state

View File

@@ -12,11 +12,14 @@
#include "activities/util/KeyboardEntryActivity.h" #include "activities/util/KeyboardEntryActivity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/BootNtpSync.h"
#include "util/TimeSync.h" #include "util/TimeSync.h"
void WifiSelectionActivity::onEnter() { void WifiSelectionActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
BootNtpSync::cancel();
// Load saved WiFi credentials - SD card operations need lock as we use SPI // Load saved WiFi credentials - SD card operations need lock as we use SPI
// for both // for both
{ {

View File

@@ -24,6 +24,8 @@
#include "util/BookmarkStore.h" #include "util/BookmarkStore.h"
#include "util/Dictionary.h" #include "util/Dictionary.h"
extern void enterDeepSleep();
namespace { namespace {
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency() // pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
constexpr unsigned long skipChapterMs = 700; constexpr unsigned long skipChapterMs = 700;
@@ -210,6 +212,18 @@ void EpubReaderActivity::loop() {
} }
return; // Don't access 'this' after callback return; // Don't access 'this' after callback
} }
if (pendingSleep) {
pendingSleep = false;
exitActivity();
enterDeepSleep();
return;
}
return;
}
if (pendingSleep) {
pendingSleep = false;
enterDeepSleep();
return; return;
} }
@@ -814,6 +828,28 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
} }
break; break;
} }
case EpubReaderMenuActivity::MenuAction::PUSH_AND_SLEEP: {
if (KOREADER_STORE.hasCredentials()) {
const int cp = section ? section->currentPage : 0;
const int tp = section ? section->pageCount : 0;
exitActivity();
enterNewActivity(new KOReaderSyncActivity(
renderer, mappedInput, epub, epub->getPath(), currentSpineIndex, cp, tp,
[this]() {
// Push failed -- sleep anyway (silent failure)
pendingSleep = true;
},
[this](int, int) {
// Push succeeded -- sleep
pendingSleep = true;
},
KOReaderSyncActivity::SyncMode::PUSH_ONLY));
} else {
// No credentials -- just sleep
pendingSleep = true;
}
break;
}
// Handled locally in the menu activity (cycle on Confirm, never dispatched here) // Handled locally in the menu activity (cycle on Confirm, never dispatched here)
case EpubReaderMenuActivity::MenuAction::ROTATE_SCREEN: case EpubReaderMenuActivity::MenuAction::ROTATE_SCREEN:
case EpubReaderMenuActivity::MenuAction::TOGGLE_FONT_SIZE: case EpubReaderMenuActivity::MenuAction::TOGGLE_FONT_SIZE:

View File

@@ -22,6 +22,7 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
float pendingSpineProgress = 0.0f; float pendingSpineProgress = 0.0f;
bool pendingSubactivityExit = false; // Defer subactivity exit to avoid use-after-free bool pendingSubactivityExit = false; // Defer subactivity exit to avoid use-after-free
bool pendingGoHome = false; // Defer go home to avoid race condition with display task bool pendingGoHome = false; // Defer go home to avoid race condition with display task
bool pendingSleep = false; // Defer deep sleep until after push-and-sleep completes
bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit
bool ignoreNextConfirmRelease = false; // Suppress short-press after long-press Confirm bool ignoreNextConfirmRelease = false; // Suppress short-press after long-press Confirm
volatile bool loadingSection = false; // True during the entire !section block (read from main loop) volatile bool loadingSection = false; // True during the entire !section block (read from main loop)

View File

@@ -27,6 +27,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
GO_TO_PERCENT, GO_TO_PERCENT,
GO_HOME, GO_HOME,
SYNC, SYNC,
PUSH_AND_SLEEP,
DELETE_CACHE, DELETE_CACHE,
MANAGE_BOOK, MANAGE_BOOK,
ARCHIVE_BOOK, ARCHIVE_BOOK,
@@ -140,6 +141,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT}); items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT});
items.push_back({MenuAction::GO_HOME, StrId::STR_CLOSE_BOOK}); items.push_back({MenuAction::GO_HOME, StrId::STR_CLOSE_BOOK});
items.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS}); items.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS});
items.push_back({MenuAction::PUSH_AND_SLEEP, StrId::STR_PUSH_AND_SLEEP});
items.push_back({MenuAction::MANAGE_BOOK, StrId::STR_MANAGE_BOOK}); items.push_back({MenuAction::MANAGE_BOOK, StrId::STR_MANAGE_BOOK});
return items; return items;
} }

View File

@@ -11,6 +11,7 @@
#include "activities/network/WifiSelectionActivity.h" #include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/BootNtpSync.h"
#include "util/TimeSync.h" #include "util/TimeSync.h"
void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) { void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
@@ -43,6 +44,12 @@ void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
performSync(); performSync();
} }
void KOReaderSyncActivity::deferFinish(bool success) {
RenderLock lock(*this);
pendingFinishSuccess = success;
pendingFinish = true;
}
void KOReaderSyncActivity::performSync() { void KOReaderSyncActivity::performSync() {
// Calculate document hash based on user's preferred method // Calculate document hash based on user's preferred method
if (KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME) { if (KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME) {
@@ -51,6 +58,10 @@ void KOReaderSyncActivity::performSync() {
documentHash = KOReaderDocumentId::calculate(epubPath); documentHash = KOReaderDocumentId::calculate(epubPath);
} }
if (documentHash.empty()) { if (documentHash.empty()) {
if (syncMode == SyncMode::PUSH_ONLY) {
deferFinish(false);
return;
}
{ {
RenderLock lock(*this); RenderLock lock(*this);
state = SYNC_FAILED; state = SYNC_FAILED;
@@ -62,6 +73,11 @@ void KOReaderSyncActivity::performSync() {
LOG_DBG("KOSync", "Document hash: %s", documentHash.c_str()); LOG_DBG("KOSync", "Document hash: %s", documentHash.c_str());
if (syncMode == SyncMode::PUSH_ONLY) {
performUpload();
return;
}
{ {
RenderLock lock(*this); RenderLock lock(*this);
statusMessage = tr(STR_FETCH_PROGRESS); statusMessage = tr(STR_FETCH_PROGRESS);
@@ -136,6 +152,10 @@ void KOReaderSyncActivity::performUpload() {
const auto result = KOReaderSyncClient::updateProgress(progress); const auto result = KOReaderSyncClient::updateProgress(progress);
if (result != KOReaderSyncClient::OK) { if (result != KOReaderSyncClient::OK) {
if (syncMode == SyncMode::PUSH_ONLY) {
deferFinish(false);
return;
}
{ {
RenderLock lock(*this); RenderLock lock(*this);
state = SYNC_FAILED; state = SYNC_FAILED;
@@ -145,6 +165,11 @@ void KOReaderSyncActivity::performUpload() {
return; return;
} }
if (syncMode == SyncMode::PUSH_ONLY) {
deferFinish(true);
return;
}
{ {
RenderLock lock(*this); RenderLock lock(*this);
state = UPLOAD_COMPLETE; state = UPLOAD_COMPLETE;
@@ -155,6 +180,8 @@ void KOReaderSyncActivity::performUpload() {
void KOReaderSyncActivity::onEnter() { void KOReaderSyncActivity::onEnter() {
ActivityWithSubactivity::onEnter(); ActivityWithSubactivity::onEnter();
BootNtpSync::cancel();
// Check for credentials first // Check for credentials first
if (!KOREADER_STORE.hasCredentials()) { if (!KOREADER_STORE.hasCredentials()) {
state = NO_CREDENTIALS; state = NO_CREDENTIALS;
@@ -331,6 +358,27 @@ void KOReaderSyncActivity::loop() {
return; return;
} }
if (syncMode == SyncMode::PUSH_ONLY) {
bool ready = false;
bool success = false;
{
RenderLock lock(*this);
if (pendingFinish) {
pendingFinish = false;
ready = true;
success = pendingFinishSuccess;
}
}
if (ready) {
if (success) {
onSyncComplete(currentSpineIndex, currentPage);
} else {
onCancel();
}
return;
}
}
if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE) { if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onCancel(); onCancel();

View File

@@ -15,18 +15,21 @@
* 1. Connect to WiFi (if not connected) * 1. Connect to WiFi (if not connected)
* 2. Calculate document hash * 2. Calculate document hash
* 3. Fetch remote progress * 3. Fetch remote progress
* 4. Show comparison and options (Apply/Upload) * 4. Show comparison and options (Apply/Upload) or skip when SyncMode::PUSH_ONLY
* 5. Apply or upload progress * 5. Apply or upload progress
*/ */
class KOReaderSyncActivity final : public ActivityWithSubactivity { class KOReaderSyncActivity final : public ActivityWithSubactivity {
public: public:
enum class SyncMode { INTERACTIVE, PUSH_ONLY };
using OnCancelCallback = std::function<void()>; using OnCancelCallback = std::function<void()>;
using OnSyncCompleteCallback = std::function<void(int newSpineIndex, int newPageNumber)>; using OnSyncCompleteCallback = std::function<void(int newSpineIndex, int newPageNumber)>;
explicit KOReaderSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit KOReaderSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::shared_ptr<Epub>& epub, const std::string& epubPath, int currentSpineIndex, const std::shared_ptr<Epub>& epub, const std::string& epubPath, int currentSpineIndex,
int currentPage, int totalPagesInSpine, OnCancelCallback onCancel, int currentPage, int totalPagesInSpine, OnCancelCallback onCancel,
OnSyncCompleteCallback onSyncComplete) OnSyncCompleteCallback onSyncComplete,
SyncMode syncMode = SyncMode::INTERACTIVE)
: ActivityWithSubactivity("KOReaderSync", renderer, mappedInput), : ActivityWithSubactivity("KOReaderSync", renderer, mappedInput),
epub(epub), epub(epub),
epubPath(epubPath), epubPath(epubPath),
@@ -37,7 +40,8 @@ class KOReaderSyncActivity final : public ActivityWithSubactivity {
remotePosition{}, remotePosition{},
localProgress{}, localProgress{},
onCancel(std::move(onCancel)), onCancel(std::move(onCancel)),
onSyncComplete(std::move(onSyncComplete)) {} onSyncComplete(std::move(onSyncComplete)),
syncMode(syncMode) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
@@ -82,6 +86,11 @@ class KOReaderSyncActivity final : public ActivityWithSubactivity {
OnCancelCallback onCancel; OnCancelCallback onCancel;
OnSyncCompleteCallback onSyncComplete; OnSyncCompleteCallback onSyncComplete;
SyncMode syncMode;
bool pendingFinish = false;
bool pendingFinishSuccess = false;
void deferFinish(bool success);
void onWifiSelectionComplete(bool success); void onWifiSelectionComplete(bool success);
void performSync(); void performSync();
void performUpload(); void performUpload();

View File

@@ -1,149 +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() {
ActivityWithSubactivity::onEnter();
selectedIndex = 0;
requestUpdate();
}
void CalibreSettingsActivity::onExit() { ActivityWithSubactivity::onExit(); }
void CalibreSettingsActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onBack();
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
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, tr(STR_CALIBRE_WEB_URL), SETTINGS.opdsServerUrl,
127, // maxLength
false, // not password
[this](const std::string& url) {
strncpy(SETTINGS.opdsServerUrl, url.c_str(), sizeof(SETTINGS.opdsServerUrl) - 1);
SETTINGS.opdsServerUrl[sizeof(SETTINGS.opdsServerUrl) - 1] = '\0';
SETTINGS.saveToFile();
exitActivity();
requestUpdate();
},
[this]() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 1) {
// Username
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, tr(STR_USERNAME), SETTINGS.opdsUsername,
63, // maxLength
false, // not password
[this](const std::string& username) {
strncpy(SETTINGS.opdsUsername, username.c_str(), sizeof(SETTINGS.opdsUsername) - 1);
SETTINGS.opdsUsername[sizeof(SETTINGS.opdsUsername) - 1] = '\0';
SETTINGS.saveToFile();
exitActivity();
requestUpdate();
},
[this]() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 2) {
// Password
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, tr(STR_PASSWORD), SETTINGS.opdsPassword,
63, // maxLength
false, // not password mode
[this](const std::string& password) {
strncpy(SETTINGS.opdsPassword, password.c_str(), sizeof(SETTINGS.opdsPassword) - 1);
SETTINGS.opdsPassword[sizeof(SETTINGS.opdsPassword) - 1] = '\0';
SETTINGS.saveToFile();
exitActivity();
requestUpdate();
},
[this]() {
exitActivity();
requestUpdate();
}));
}
}
void CalibreSettingsActivity::render(Activity::RenderLock&&) {
renderer.clearScreen();
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_BROWSER));
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;
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, 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);
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();
}

View File

@@ -1,29 +0,0 @@
#pragma once
#include <functional>
#include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
/**
* Submenu for OPDS Browser settings.
* Shows OPDS Server URL and HTTP authentication options.
*/
class CalibreSettingsActivity final : public ActivityWithSubactivity {
public:
explicit CalibreSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onBack)
: ActivityWithSubactivity("CalibreSettings", renderer, mappedInput), onBack(onBack) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(Activity::RenderLock&&) override;
private:
ButtonNavigator buttonNavigator;
size_t selectedIndex = 0;
const std::function<void()> onBack;
void handleSelection();
};

View File

@@ -8,6 +8,7 @@
#include "KOReaderSyncClient.h" #include "KOReaderSyncClient.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "activities/network/WifiSelectionActivity.h" #include "activities/network/WifiSelectionActivity.h"
#include "util/BootNtpSync.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
@@ -53,6 +54,8 @@ void KOReaderAuthActivity::performAuthentication() {
void KOReaderAuthActivity::onEnter() { void KOReaderAuthActivity::onEnter() {
ActivityWithSubactivity::onEnter(); ActivityWithSubactivity::onEnter();
BootNtpSync::cancel();
// Turn on WiFi // Turn on WiFi
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);

View File

@@ -10,6 +10,7 @@
#include "activities/network/WifiSelectionActivity.h" #include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/BootNtpSync.h"
#include "util/TimeSync.h" #include "util/TimeSync.h"
static constexpr unsigned long AUTO_DISMISS_MS = 5000; static constexpr unsigned long AUTO_DISMISS_MS = 5000;
@@ -52,6 +53,7 @@ void NtpSyncActivity::onWifiSelectionComplete(const bool success) {
void NtpSyncActivity::onEnter() { void NtpSyncActivity::onEnter() {
ActivityWithSubactivity::onEnter(); ActivityWithSubactivity::onEnter();
BootNtpSync::cancel();
LOG_DBG("NTP", "Turning on WiFi..."); LOG_DBG("NTP", "Turning on WiFi...");
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);

View File

@@ -0,0 +1,131 @@
#include "OpdsServerListActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include "MappedInputManager.h"
#include "OpdsServerStore.h"
#include "OpdsSettingsActivity.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 (!isPickerMode()) {
count++;
}
return count;
}
void OpdsServerListActivity::onEnter() {
ActivityWithSubactivity::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() { ActivityWithSubactivity::onExit(); }
void OpdsServerListActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onBack();
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 (isPickerMode()) {
// Picker mode: selecting a server triggers the callback instead of opening the editor
if (selectedIndex < serverCount) {
onServerSelected(static_cast<size_t>(selectedIndex));
}
return;
}
// Settings mode: open editor for selected server, or create a new one
auto onEditDone = [this] {
exitActivity();
selectedIndex = 0;
requestUpdate();
};
if (selectedIndex < serverCount) {
exitActivity();
enterNewActivity(new OpdsSettingsActivity(renderer, mappedInput, onEditDone, selectedIndex));
} else {
exitActivity();
enterNewActivity(new OpdsSettingsActivity(renderer, mappedInput, onEditDone, -1));
}
}
void OpdsServerListActivity::render(Activity::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();
}

View File

@@ -0,0 +1,41 @@
#pragma once
#include <functional>
#include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
/**
* Activity showing the list of configured OPDS servers.
* Allows adding new servers and editing/deleting existing ones.
* Used from Settings and also as a server picker from the home screen.
*/
class OpdsServerListActivity final : public ActivityWithSubactivity {
public:
using OnServerSelected = std::function<void(size_t serverIndex)>;
/**
* @param onBack Called when user presses Back
* @param onServerSelected If set, acts as a picker: selecting a server calls this instead of opening editor.
*/
explicit OpdsServerListActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onBack, OnServerSelected onServerSelected = nullptr)
: ActivityWithSubactivity("OpdsServerList", renderer, mappedInput),
onBack(onBack),
onServerSelected(std::move(onServerSelected)) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(Activity::RenderLock&&) override;
private:
ButtonNavigator buttonNavigator;
int selectedIndex = 0;
const std::function<void()> onBack;
OnServerSelected onServerSelected;
bool isPickerMode() const { return onServerSelected != nullptr; }
int getItemCount() const;
void handleSelection();
};

View File

@@ -0,0 +1,219 @@
#include "OpdsSettingsActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include <cstring>
#include "MappedInputManager.h"
#include "OpdsServerStore.h"
#include "activities/util/DirectoryPickerActivity.h"
#include "activities/util/KeyboardEntryActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
// Editable fields: Name, URL, Username, Password, Download Path.
// Existing servers also show a Delete option (BASE_ITEMS + 1).
constexpr int BASE_ITEMS = 5;
} // namespace
int OpdsSettingsActivity::getMenuItemCount() const {
return isNewServer ? BASE_ITEMS : BASE_ITEMS + 1; // +1 for Delete
}
void OpdsSettingsActivity::onEnter() {
ActivityWithSubactivity::onEnter();
selectedIndex = 0;
isNewServer = (serverIndex < 0);
if (!isNewServer) {
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() { ActivityWithSubactivity::onExit(); }
void OpdsSettingsActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onBack();
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();
});
}
void OpdsSettingsActivity::saveServer() {
if (isNewServer) {
OPDS_STORE.addServer(editServer);
// After the first field is saved, promote to an existing server so
// subsequent field edits update in-place rather than creating duplicates.
isNewServer = false;
serverIndex = static_cast<int>(OPDS_STORE.getCount()) - 1;
} else {
OPDS_STORE.updateServer(static_cast<size_t>(serverIndex), editServer);
}
}
void OpdsSettingsActivity::handleSelection() {
if (selectedIndex == 0) {
// Server Name
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, tr(STR_SERVER_NAME), editServer.name, 63, false,
[this](const std::string& name) {
editServer.name = name;
saveServer();
exitActivity();
requestUpdate();
},
[this]() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 1) {
// Server URL
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, tr(STR_OPDS_SERVER_URL), editServer.url, 127, false,
[this](const std::string& url) {
editServer.url = url;
saveServer();
exitActivity();
requestUpdate();
},
[this]() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 2) {
// Username
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, tr(STR_USERNAME), editServer.username, 63, false,
[this](const std::string& username) {
editServer.username = username;
saveServer();
exitActivity();
requestUpdate();
},
[this]() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 3) {
// Password
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, tr(STR_PASSWORD), editServer.password, 63, false,
[this](const std::string& password) {
editServer.password = password;
saveServer();
exitActivity();
requestUpdate();
},
[this]() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 4) {
// Download Path
exitActivity();
enterNewActivity(new DirectoryPickerActivity(
renderer, mappedInput,
[this](const std::string& path) {
std::string dir = path;
editServer.downloadPath = dir;
saveServer();
exitActivity();
requestUpdate();
},
[this]() {
exitActivity();
requestUpdate();
},
editServer.downloadPath));
} else if (selectedIndex == 5 && !isNewServer) {
// Delete server
OPDS_STORE.removeServer(static_cast<size_t>(serverIndex));
onBack();
}
}
void OpdsSettingsActivity::render(Activity::RenderLock&&) {
renderer.clearScreen();
const auto& metrics = UITheme::getInstance().getMetrics();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
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, StrId::STR_DOWNLOAD_PATH};
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("******");
} else if (index == 4) {
return editServer.downloadPath;
}
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);
renderer.displayBuffer();
}

View File

@@ -0,0 +1,40 @@
#pragma once
#include <functional>
#include "OpdsServerStore.h"
#include "activities/ActivityWithSubactivity.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 ActivityWithSubactivity {
public:
/**
* @param serverIndex Index into OpdsServerStore, or -1 for a new server
*/
explicit OpdsSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onBack, int serverIndex = -1)
: ActivityWithSubactivity("OpdsSettings", renderer, mappedInput), onBack(onBack), serverIndex(serverIndex) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(Activity::RenderLock&&) override;
private:
ButtonNavigator buttonNavigator;
size_t selectedIndex = 0;
const std::function<void()> onBack;
int serverIndex;
OpdsServer editServer;
bool isNewServer = false;
int getMenuItemCount() const;
void handleSelection();
void saveServer();
};

View File

@@ -9,6 +9,7 @@
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "network/OtaUpdater.h" #include "network/OtaUpdater.h"
#include "util/BootNtpSync.h"
void OtaUpdateActivity::onWifiSelectionComplete(const bool success) { void OtaUpdateActivity::onWifiSelectionComplete(const bool success) {
exitActivity(); exitActivity();
@@ -58,6 +59,8 @@ void OtaUpdateActivity::onWifiSelectionComplete(const bool success) {
void OtaUpdateActivity::onEnter() { void OtaUpdateActivity::onEnter() {
ActivityWithSubactivity::onEnter(); ActivityWithSubactivity::onEnter();
BootNtpSync::cancel();
// Turn on WiFi immediately // Turn on WiFi immediately
LOG_DBG("OTA", "Turning on WiFi..."); LOG_DBG("OTA", "Turning on WiFi...");
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);

View File

@@ -7,7 +7,7 @@
#include <cstdlib> #include <cstdlib>
#include "ButtonRemapActivity.h" #include "ButtonRemapActivity.h"
#include "CalibreSettingsActivity.h" #include "OpdsServerListActivity.h"
#include "ClearCacheActivity.h" #include "ClearCacheActivity.h"
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "KOReaderSettingsActivity.h" #include "KOReaderSettingsActivity.h"
@@ -202,7 +202,7 @@ void SettingsActivity::toggleCurrentSetting() {
enterSubActivity(new KOReaderSettingsActivity(renderer, mappedInput, onComplete)); enterSubActivity(new KOReaderSettingsActivity(renderer, mappedInput, onComplete));
break; break;
case SettingAction::OPDSBrowser: case SettingAction::OPDSBrowser:
enterSubActivity(new CalibreSettingsActivity(renderer, mappedInput, onComplete)); enterSubActivity(new OpdsServerListActivity(renderer, mappedInput, onComplete));
break; break;
case SettingAction::Network: case SettingAction::Network:
enterSubActivity(new WifiSelectionActivity(renderer, mappedInput, onCompleteBool, false)); enterSubActivity(new WifiSelectionActivity(renderer, mappedInput, onCompleteBool, false));

View File

@@ -0,0 +1,166 @@
#include "DirectoryPickerActivity.h"
#include <HalStorage.h>
#include <I18n.h>
#include <cstring>
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/StringUtils.h"
void DirectoryPickerActivity::onEnter() {
Activity::onEnter();
basepath = initialPath;
if (basepath.empty()) basepath = "/";
// Validate the initial path exists; fall back to root if not
auto dir = Storage.open(basepath.c_str());
if (!dir || !dir.isDirectory()) {
if (dir) dir.close();
basepath = "/";
} else {
dir.close();
}
selectorIndex = 0;
loadDirectories();
requestUpdate();
}
void DirectoryPickerActivity::onExit() {
directories.clear();
Activity::onExit();
}
void DirectoryPickerActivity::loadDirectories() {
directories.clear();
auto root = Storage.open(basepath.c_str());
if (!root || !root.isDirectory()) {
if (root) root.close();
return;
}
root.rewindDirectory();
char name[256];
for (auto file = root.openNextFile(); file; file = root.openNextFile()) {
file.getName(name, sizeof(name));
if (name[0] == '.' || strcmp(name, "System Volume Information") == 0) {
file.close();
continue;
}
if (file.isDirectory()) {
directories.emplace_back(std::string(name) + "/");
}
file.close();
}
root.close();
StringUtils::sortFileList(directories);
}
void DirectoryPickerActivity::loop() {
// Absorb the Confirm release from the parent activity that launched us
if (waitForConfirmRelease) {
if (!mappedInput.isPressed(MappedInputManager::Button::Confirm)) {
waitForConfirmRelease = false;
}
return;
}
// Index 0 = "Save Here", indices 1..N = directory entries
const int totalItems = 1 + static_cast<int>(directories.size());
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (selectorIndex == 0) {
onSelect(basepath);
} else {
const auto& dirName = directories[selectorIndex - 1];
// Strip trailing '/'
std::string folderName = dirName.substr(0, dirName.length() - 1);
basepath = (basepath.back() == '/' ? basepath : basepath + "/") + folderName;
selectorIndex = 0;
loadDirectories();
requestUpdate();
}
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
if (basepath == "/") {
onCancel();
} else {
auto slash = basepath.find_last_of('/');
basepath = (slash == 0) ? "/" : basepath.substr(0, slash);
selectorIndex = 0;
loadDirectories();
requestUpdate();
}
return;
}
buttonNavigator.onNextRelease([this, totalItems] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
requestUpdate();
});
buttonNavigator.onPreviousRelease([this, totalItems] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
requestUpdate();
});
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false);
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 DirectoryPickerActivity::render(Activity::RenderLock&&) {
renderer.clearScreen();
const auto& metrics = UITheme::getInstance().getMetrics();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
std::string folderName = (basepath == "/") ? tr(STR_SD_CARD) : basepath.substr(basepath.rfind('/') + 1);
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_SELECT_FOLDER));
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
const int totalItems = 1 + static_cast<int>(directories.size());
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, totalItems, selectorIndex,
[this](int index) -> std::string {
if (index == 0) {
std::string label = std::string(tr(STR_SAVE_HERE)) + " (" + basepath + ")";
return label;
}
// Strip trailing '/' for display
const auto& dir = directories[index - 1];
return dir.substr(0, dir.length() - 1);
},
nullptr,
[this](int index) -> UIIcon {
return (index == 0) ? UIIcon::File : UIIcon::Folder;
});
const char* backLabel = (basepath == "/") ? tr(STR_CANCEL) : tr(STR_BACK);
const char* confirmLabel = (selectorIndex == 0) ? tr(STR_SAVE_HERE) : tr(STR_OPEN);
const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@@ -0,0 +1,44 @@
#pragma once
#include <functional>
#include <string>
#include <vector>
#include "../Activity.h"
#include "util/ButtonNavigator.h"
/**
* Directory picker subactivity for selecting a save location on the SD card.
* Shows only directories and a "Save Here" option at index 0.
* Navigating into a subdirectory updates the current path; Back goes up.
* Pressing Back at root calls onCancel.
*/
class DirectoryPickerActivity final : public Activity {
public:
explicit DirectoryPickerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
std::function<void(const std::string& path)> onSelect,
std::function<void()> onCancel,
std::string initialPath = "/")
: Activity("DirectoryPicker", renderer, mappedInput),
initialPath(std::move(initialPath)),
onSelect(std::move(onSelect)),
onCancel(std::move(onCancel)) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
private:
ButtonNavigator buttonNavigator;
std::string initialPath;
std::string basepath = "/";
std::vector<std::string> directories;
int selectorIndex = 0;
bool waitForConfirmRelease = true;
std::function<void(const std::string& path)> onSelect;
std::function<void()> onCancel;
void loadDirectories();
};

View File

@@ -19,6 +19,7 @@
#include "CrossPointState.h" #include "CrossPointState.h"
#include "KOReaderCredentialStore.h" #include "KOReaderCredentialStore.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "OpdsServerStore.h"
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include "activities/boot_sleep/BootActivity.h" #include "activities/boot_sleep/BootActivity.h"
#include "activities/boot_sleep/SleepActivity.h" #include "activities/boot_sleep/SleepActivity.h"
@@ -28,10 +29,12 @@
#include "activities/home/RecentBooksActivity.h" #include "activities/home/RecentBooksActivity.h"
#include "activities/network/CrossPointWebServerActivity.h" #include "activities/network/CrossPointWebServerActivity.h"
#include "activities/reader/ReaderActivity.h" #include "activities/reader/ReaderActivity.h"
#include "activities/settings/OpdsServerListActivity.h"
#include "activities/settings/SettingsActivity.h" #include "activities/settings/SettingsActivity.h"
#include "activities/util/FullScreenMessageActivity.h" #include "activities/util/FullScreenMessageActivity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/BootNtpSync.h"
#include "util/ButtonNavigator.h" #include "util/ButtonNavigator.h"
HalDisplay display; HalDisplay display;
@@ -260,7 +263,18 @@ void onGoToMyLibraryWithPath(const std::string& path, bool initialSkipRelease) {
void onGoToBrowser() { void onGoToBrowser() {
exitActivity(); exitActivity();
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome)); const auto& servers = OPDS_STORE.getServers();
if (servers.size() == 1) {
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome, servers[0]));
} else {
enterNewActivity(new OpdsServerListActivity(renderer, mappedInputManager, onGoHome, [](size_t serverIndex) {
const auto* server = OPDS_STORE.getServer(serverIndex);
if (server) {
exitActivity();
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome, *server));
}
}));
}
} }
void onGoHome() { void onGoHome() {
@@ -342,6 +356,8 @@ void setup() {
I18N.loadSettings(); I18N.loadSettings();
KOREADER_STORE.loadFromFile(); KOREADER_STORE.loadFromFile();
OPDS_STORE.loadFromFile();
BootNtpSync::start();
UITheme::getInstance().reload(); UITheme::getInstance().reload();
ButtonNavigator::setMappedInputManager(mappedInputManager); ButtonNavigator::setMappedInputManager(mappedInputManager);
@@ -459,15 +475,24 @@ void loop() {
// Refresh screen when the displayed minute changes (clock in header) // Refresh screen when the displayed minute changes (clock in header)
if (SETTINGS.clockFormat != CrossPointSettings::CLOCK_OFF && currentActivity) { if (SETTINGS.clockFormat != CrossPointSettings::CLOCK_OFF && currentActivity) {
static int lastRenderedMinute = -1; static int lastRenderedMinute = -1;
static bool sawInvalidTime = false;
time_t now = time(nullptr); time_t now = time(nullptr);
struct tm* t = localtime(&now); struct tm* t = localtime(&now);
if (t != nullptr && t->tm_year > 100) { if (t != nullptr && t->tm_year > 100) {
const int currentMinute = t->tm_hour * 60 + t->tm_min; const int currentMinute = t->tm_hour * 60 + t->tm_min;
if (lastRenderedMinute >= 0 && currentMinute != lastRenderedMinute) { if (lastRenderedMinute < 0) {
lastRenderedMinute = currentMinute;
if (sawInvalidTime) {
// Time just became valid (e.g. background NTP sync completed)
currentActivity->requestUpdate(); currentActivity->requestUpdate();
} }
} else if (currentMinute != lastRenderedMinute) {
currentActivity->requestUpdate();
lastRenderedMinute = currentMinute; lastRenderedMinute = currentMinute;
} }
} else {
sawInvalidTime = true;
}
} }
const unsigned long activityStartTime = millis(); const unsigned long activityStartTime = millis();

View File

@@ -11,6 +11,7 @@
#include <algorithm> #include <algorithm>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "OpdsServerStore.h"
#include "SettingsList.h" #include "SettingsList.h"
#include "html/FilesPageHtml.generated.h" #include "html/FilesPageHtml.generated.h"
#include "html/HomePageHtml.generated.h" #include "html/HomePageHtml.generated.h"
@@ -156,6 +157,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 management 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());
@@ -1157,6 +1163,116 @@ 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 Management API ----
void CrossPointWebServer::handleGetOpdsServers() const {
const auto& servers = OPDS_STORE.getServers();
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
server->send(200, "application/json", "");
server->sendContent("[");
constexpr size_t outputSize = 512;
char output[outputSize];
bool first = true;
for (size_t i = 0; i < servers.size(); i++) {
JsonDocument doc;
doc["name"] = servers[i].name;
doc["url"] = servers[i].url;
doc["username"] = servers[i].username;
doc["hasPassword"] = !servers[i].password.empty();
const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) continue;
if (!first) server->sendContent(",");
server->sendContent(output);
first = false;
}
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;
}
OpdsServer opdsServer;
opdsServer.name = doc["name"] | std::string("");
opdsServer.url = doc["url"] | std::string("");
opdsServer.username = doc["username"] | std::string("");
bool hasPasswordField = doc["password"].is<const char*>() || doc["password"].is<std::string>();
std::string password = doc["password"] | std::string("");
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;
}
if (!hasPasswordField) {
const auto* existing = OPDS_STORE.getServer(static_cast<size_t>(idx));
if (existing) password = existing->password;
}
opdsServer.password = password;
OPDS_STORE.updateServer(static_cast<size_t>(idx), opdsServer);
LOG_DBG("WEB", "Updated OPDS server at index %d", idx);
} else {
opdsServer.password = password;
if (!OPDS_STORE.addServer(opdsServer)) {
server->send(400, "text/plain", "Cannot add server (limit reached)");
return;
}
LOG_DBG("WEB", "Added new OPDS server: %s", 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;
}
OPDS_STORE.removeServer(static_cast<size_t>(idx));
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) {

View File

@@ -105,4 +105,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();
}; };

View File

@@ -9,12 +9,49 @@
#include <cstring> #include <cstring>
#include <memory> #include <memory>
#include <utility>
#include "CrossPointSettings.h"
#include "util/UrlUtils.h" #include "util/UrlUtils.h"
bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) { namespace {
// Use WiFiClientSecure for HTTPS, regular WiFiClient for HTTP class FileWriteStream final : public Stream {
public:
FileWriteStream(FsFile& file, size_t total, HttpDownloader::ProgressCallback progress)
: file_(file), total_(total), progress_(std::move(progress)) {}
size_t write(uint8_t byte) override { return write(&byte, 1); }
size_t write(const uint8_t* buffer, size_t size) override {
const size_t written = file_.write(buffer, size);
if (written != size) {
writeOk_ = false;
}
downloaded_ += written;
if (progress_ && total_ > 0) {
progress_(downloaded_, total_);
}
return written;
}
int available() override { return 0; }
int read() override { return -1; }
int peek() override { return -1; }
void flush() override { file_.flush(); }
size_t downloaded() const { return downloaded_; }
bool ok() const { return writeOk_; }
private:
FsFile& file_;
size_t total_;
size_t downloaded_ = 0;
bool writeOk_ = true;
HttpDownloader::ProgressCallback progress_;
};
} // namespace
bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent, const std::string& username,
const std::string& password) {
std::unique_ptr<WiFiClient> client; std::unique_ptr<WiFiClient> client;
if (UrlUtils::isHttpsUrl(url)) { if (UrlUtils::isHttpsUrl(url)) {
auto* secureClient = new WiFiClientSecure(); auto* secureClient = new WiFiClientSecure();
@@ -31,9 +68,8 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
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);
} }
@@ -53,9 +89,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();
@@ -63,8 +100,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 WiFiClientSecure for HTTPS, regular WiFiClient for HTTP const std::string& password) {
std::unique_ptr<WiFiClient> client; std::unique_ptr<WiFiClient> client;
if (UrlUtils::isHttpsUrl(url)) { if (UrlUtils::isHttpsUrl(url)) {
auto* secureClient = new WiFiClientSecure(); auto* secureClient = new WiFiClientSecure();
@@ -82,9 +119,8 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
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);
} }
@@ -96,8 +132,13 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
return HTTP_ERROR; return HTTP_ERROR;
} }
const size_t contentLength = http.getSize(); const int64_t reportedLength = http.getSize();
const size_t contentLength = reportedLength > 0 ? static_cast<size_t>(reportedLength) : 0;
if (contentLength > 0) {
LOG_DBG("HTTP", "Content-Length: %zu", contentLength); LOG_DBG("HTTP", "Content-Length: %zu", contentLength);
} else {
LOG_DBG("HTTP", "Content-Length: unknown");
}
// Remove existing file if present // Remove existing file if present
if (Storage.exists(destPath.c_str())) { if (Storage.exists(destPath.c_str())) {
@@ -112,55 +153,28 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
return FILE_ERROR; return FILE_ERROR;
} }
// Get the stream for chunked reading // Let HTTPClient handle chunked decoding and stream body bytes into the file.
WiFiClient* stream = http.getStreamPtr();
if (!stream) {
LOG_ERR("HTTP", "Failed to get stream");
file.close();
Storage.remove(destPath.c_str());
http.end();
return HTTP_ERROR;
}
// Download in chunks
uint8_t buffer[DOWNLOAD_CHUNK_SIZE];
size_t downloaded = 0;
const size_t total = contentLength > 0 ? contentLength : 0; const size_t total = contentLength > 0 ? contentLength : 0;
FileWriteStream fileStream(file, total, progress);
http.writeToStream(&fileStream);
while (http.connected() && (contentLength == 0 || downloaded < contentLength)) {
const size_t available = stream->available();
if (available == 0) {
delay(1);
continue;
}
const size_t toRead = available < DOWNLOAD_CHUNK_SIZE ? available : DOWNLOAD_CHUNK_SIZE;
const size_t bytesRead = stream->readBytes(buffer, toRead);
if (bytesRead == 0) {
break;
}
const size_t written = file.write(buffer, bytesRead);
if (written != bytesRead) {
LOG_ERR("HTTP", "Write failed: wrote %zu of %zu bytes", written, bytesRead);
file.close(); file.close();
Storage.remove(destPath.c_str());
http.end(); http.end();
const size_t downloaded = fileStream.downloaded();
LOG_DBG("HTTP", "Downloaded %zu bytes", downloaded);
if (!fileStream.ok()) {
LOG_ERR("HTTP", "Write failed during download");
Storage.remove(destPath.c_str());
return FILE_ERROR; return FILE_ERROR;
} }
downloaded += bytesRead; if (contentLength == 0 && downloaded == 0) {
LOG_ERR("HTTP", "Download failed: no data received");
if (progress && total > 0) { Storage.remove(destPath.c_str());
progress(downloaded, total); return HTTP_ERROR;
} }
}
file.close();
http.end();
LOG_DBG("HTTP", "Downloaded %zu bytes", downloaded);
// Verify download size if known // Verify download size if known
if (contentLength > 0 && downloaded != contentLength) { if (contentLength > 0 && downloaded != contentLength) {

View File

@@ -20,24 +20,20 @@ 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 = "");
private: private:
static constexpr size_t DOWNLOAD_CHUNK_SIZE = 1024; static constexpr size_t DOWNLOAD_CHUNK_SIZE = 1024;

View File

@@ -180,6 +180,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;
@@ -231,6 +273,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
@@ -409,6 +453,116 @@
} }
loadSettings(); loadSettings();
// --- OPDS Server Management ---
let opdsServers = [];
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>';
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;">' +
'<button class="btn-small btn-add" onclick="addOpdsServer()">+ Add Server</button>' +
'</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() {
const container = document.getElementById('opds-container');
const card = container.querySelector('.card');
const addBtn = card.querySelector('.btn-add').parentElement;
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,
};
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>

163
src/util/BootNtpSync.cpp Normal file
View File

@@ -0,0 +1,163 @@
#include "BootNtpSync.h"
#include <Logging.h>
#include <WiFi.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <string>
#include <vector>
#include "CrossPointSettings.h"
#include "WifiCredentialStore.h"
#include "util/TimeSync.h"
namespace BootNtpSync {
static volatile bool running = false;
static TaskHandle_t taskHandle = nullptr;
struct TaskParams {
std::vector<WifiCredential> credentials;
std::string lastConnectedSsid;
};
static bool tryConnectToSavedNetwork(const TaskParams& params) {
WiFi.mode(WIFI_STA);
WiFi.disconnect();
vTaskDelay(100 / portTICK_PERIOD_MS);
LOG_DBG("BNTP", "Scanning WiFi networks...");
int16_t count = WiFi.scanNetworks();
if (count <= 0) {
LOG_DBG("BNTP", "Scan returned %d networks", count);
WiFi.scanDelete();
return false;
}
LOG_DBG("BNTP", "Found %d networks, matching against %zu saved credentials", count, params.credentials.size());
// Find best match: prefer lastConnectedSsid, otherwise first saved match
const WifiCredential* bestMatch = nullptr;
for (int i = 0; i < count; i++) {
std::string ssid = WiFi.SSID(i).c_str();
for (const auto& cred : params.credentials) {
if (cred.ssid == ssid) {
if (!bestMatch || cred.ssid == params.lastConnectedSsid) {
bestMatch = &cred;
}
if (cred.ssid == params.lastConnectedSsid) {
break; // Can't do better than lastConnected
}
}
}
if (bestMatch && bestMatch->ssid == params.lastConnectedSsid) {
break;
}
}
WiFi.scanDelete();
if (!bestMatch) {
LOG_DBG("BNTP", "No saved network found in scan results");
return false;
}
LOG_DBG("BNTP", "Connecting to %s", bestMatch->ssid.c_str());
if (!bestMatch->password.empty()) {
WiFi.begin(bestMatch->ssid.c_str(), bestMatch->password.c_str());
} else {
WiFi.begin(bestMatch->ssid.c_str());
}
const unsigned long start = millis();
constexpr unsigned long CONNECT_TIMEOUT_MS = 10000;
while (WiFi.status() != WL_CONNECTED && millis() - start < CONNECT_TIMEOUT_MS) {
if (!running) return false;
vTaskDelay(100 / portTICK_PERIOD_MS);
wl_status_t status = WiFi.status();
if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL) {
LOG_DBG("BNTP", "Connection failed (status=%d)", status);
return false;
}
}
if (WiFi.status() == WL_CONNECTED) {
LOG_DBG("BNTP", "Connected to %s", bestMatch->ssid.c_str());
return true;
}
LOG_DBG("BNTP", "Connection timed out");
return false;
}
static void taskFunc(void* param) {
auto* params = static_cast<TaskParams*>(param);
bool connected = tryConnectToSavedNetwork(*params);
if (!connected && running) {
LOG_DBG("BNTP", "First scan failed, retrying in 3s...");
vTaskDelay(3000 / portTICK_PERIOD_MS);
if (running) {
connected = tryConnectToSavedNetwork(*params);
}
}
if (connected && running) {
LOG_DBG("BNTP", "Starting NTP sync...");
bool synced = TimeSync::waitForNtpSync(5000);
TimeSync::stopNtpSync();
if (synced) {
LOG_DBG("BNTP", "NTP sync successful");
} else {
LOG_DBG("BNTP", "NTP sync timed out, continuing without time");
}
}
WiFi.disconnect(false);
vTaskDelay(100 / portTICK_PERIOD_MS);
WiFi.mode(WIFI_OFF);
vTaskDelay(100 / portTICK_PERIOD_MS);
delete params;
running = false;
taskHandle = nullptr;
LOG_DBG("BNTP", "Boot NTP task complete");
vTaskDelete(nullptr);
}
void start() {
if (!SETTINGS.autoNtpSync) {
return;
}
WIFI_STORE.loadFromFile();
const auto& creds = WIFI_STORE.getCredentials();
if (creds.empty()) {
LOG_DBG("BNTP", "No saved WiFi credentials, skipping boot NTP sync");
return;
}
auto* params = new TaskParams{creds, WIFI_STORE.getLastConnectedSsid()};
running = true;
xTaskCreate(taskFunc, "BootNTP", 4096, params, 1, &taskHandle);
LOG_DBG("BNTP", "Boot NTP sync task started");
}
void cancel() {
if (!running) return;
LOG_DBG("BNTP", "Cancelling boot NTP sync...");
running = false;
// Wait for the task to notice and clean up (up to 2s)
for (int i = 0; i < 20 && taskHandle != nullptr; i++) {
delay(100);
}
LOG_DBG("BNTP", "Boot NTP sync cancelled");
}
bool isRunning() { return running; }
} // namespace BootNtpSync

16
src/util/BootNtpSync.h Normal file
View File

@@ -0,0 +1,16 @@
#pragma once
namespace BootNtpSync {
// Spawn a background FreeRTOS task that scans for saved WiFi networks,
// connects, syncs NTP, then tears down WiFi. Non-blocking; does nothing
// if autoNtpSync is disabled or no credentials are stored.
void start();
// Signal the background task to abort and wait for it to finish.
// Call before starting any other WiFi operation.
void cancel();
bool isRunning();
} // namespace BootNtpSync

View File

@@ -1,5 +1,6 @@
#include "StringUtils.h" #include "StringUtils.h"
#include <algorithm>
#include <cstring> #include <cstring>
namespace StringUtils { namespace StringUtils {
@@ -61,4 +62,43 @@ bool checkFileExtension(const String& fileName, const char* extension) {
return localFile.endsWith(localExtension); return localFile.endsWith(localExtension);
} }
void sortFileList(std::vector<std::string>& entries) {
std::sort(begin(entries), end(entries), [](const std::string& str1, const std::string& str2) {
bool isDir1 = str1.back() == '/';
bool isDir2 = str2.back() == '/';
if (isDir1 != isDir2) return isDir1;
const char* s1 = str1.c_str();
const char* s2 = str2.c_str();
while (*s1 && *s2) {
if (isdigit(*s1) && isdigit(*s2)) {
while (*s1 == '0') s1++;
while (*s2 == '0') s2++;
int len1 = 0, len2 = 0;
while (isdigit(s1[len1])) len1++;
while (isdigit(s2[len2])) len2++;
if (len1 != len2) return len1 < len2;
for (int i = 0; i < len1; i++) {
if (s1[i] != s2[i]) return s1[i] < s2[i];
}
s1 += len1;
s2 += len2;
} else {
char c1 = tolower(*s1);
char c2 = tolower(*s2);
if (c1 != c2) return c1 < c2;
s1++;
s2++;
}
}
return *s1 == '\0' && *s2 != '\0';
});
}
} // namespace StringUtils } // namespace StringUtils

View File

@@ -3,6 +3,7 @@
#include <WString.h> #include <WString.h>
#include <string> #include <string>
#include <vector>
namespace StringUtils { namespace StringUtils {
@@ -19,4 +20,10 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100);
bool checkFileExtension(const std::string& fileName, const char* extension); bool checkFileExtension(const std::string& fileName, const char* extension);
bool checkFileExtension(const String& fileName, const char* extension); bool checkFileExtension(const String& fileName, const char* extension);
/**
* Sort a file/directory list with directories first, using case-insensitive natural sort.
* Directory entries are identified by a trailing '/'.
*/
void sortFileList(std::vector<std::string>& entries);
} // namespace StringUtils } // namespace StringUtils