Compare commits
4 Commits
2eae521b6a
...
3628d8eb37
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3628d8eb37
|
||
|
|
42011d5977
|
||
|
|
2aa13ea2de
|
||
|
|
19b6ad047b
|
99
docs/contributing/koreader-sync-xpath-mapping.md
Normal file
99
docs/contributing/koreader-sync-xpath-mapping.md
Normal 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`.
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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í"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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: "Путь загрузки"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
497
lib/KOReaderSync/ChapterXPathIndexer.cpp
Normal file
497
lib/KOReaderSync/ChapterXPathIndexer.cpp
Normal 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;
|
||||||
|
}
|
||||||
67
lib/KOReaderSync/ChapterXPathIndexer.h
Normal file
67
lib/KOReaderSync/ChapterXPathIndexer.h
Normal 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);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 16‑bit 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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
203
src/OpdsServerStore.cpp
Normal 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
52
src/OpdsServerStore.h
Normal 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()
|
||||||
@@ -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),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
};
|
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
131
src/activities/settings/OpdsServerListActivity.cpp
Normal file
131
src/activities/settings/OpdsServerListActivity.cpp
Normal 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();
|
||||||
|
}
|
||||||
41
src/activities/settings/OpdsServerListActivity.h
Normal file
41
src/activities/settings/OpdsServerListActivity.h
Normal 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();
|
||||||
|
};
|
||||||
219
src/activities/settings/OpdsSettingsActivity.cpp
Normal file
219
src/activities/settings/OpdsSettingsActivity.cpp
Normal 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();
|
||||||
|
}
|
||||||
40
src/activities/settings/OpdsSettingsActivity.h
Normal file
40
src/activities/settings/OpdsSettingsActivity.h
Normal 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();
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
166
src/activities/util/DirectoryPickerActivity.cpp
Normal file
166
src/activities/util/DirectoryPickerActivity.cpp
Normal 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();
|
||||||
|
}
|
||||||
44
src/activities/util/DirectoryPickerActivity.h
Normal file
44
src/activities/util/DirectoryPickerActivity.h
Normal 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();
|
||||||
|
};
|
||||||
29
src/main.cpp
29
src/main.cpp
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
163
src/util/BootNtpSync.cpp
Normal 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
16
src/util/BootNtpSync.h
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user