From cf3ab19c7743ba770c01e864aee3eef2d35f1017 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sun, 12 Apr 2026 22:49:33 +0200 Subject: [PATCH] add OPDS search support & next/prev page navigation (upstream #1462) --- lib/I18n/translations/belarusian.yaml | 2 +- lib/I18n/translations/catalan.yaml | 2 +- lib/I18n/translations/czech.yaml | 2 +- lib/I18n/translations/danish.yaml | 2 +- lib/I18n/translations/dutch.yaml | 2 +- lib/I18n/translations/english.yaml | 3 + lib/I18n/translations/finnish.yaml | 2 +- lib/I18n/translations/hungarian.yaml | 2 +- lib/I18n/translations/kazakh.yaml | 2 +- lib/I18n/translations/lithuanian.yaml | 2 +- lib/I18n/translations/polish.yaml | 2 +- lib/I18n/translations/romanian.yaml | 2 +- lib/I18n/translations/spanish.yaml | 2 +- lib/I18n/translations/swedish.yaml | 2 +- lib/I18n/translations/turkish.yaml | 2 +- lib/I18n/translations/ukrainian.yaml | 2 +- lib/OpdsParser/OpdsParser.cpp | 155 +++++---------- lib/OpdsParser/OpdsParser.h | 6 + .../browser/OpdsBookBrowserActivity.cpp | 182 ++++++++++-------- .../browser/OpdsBookBrowserActivity.h | 21 +- 20 files changed, 186 insertions(+), 211 deletions(-) diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index 70b5c562..e4318605 100644 --- a/lib/I18n/translations/belarusian.yaml +++ b/lib/I18n/translations/belarusian.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Адкрыйце гэты адрас у браўзеры" STR_OR_HTTP_PREFIX: "або http://" STR_SCAN_QR_HINT: "або адсканіруйце QR-код:" STR_CALIBRE_WIRELESS: "Calibre па Wi-Fi" -STR_CALIBRE_WEB_URL: "Вэб-адрас Calibre" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Абаронена | + = Захавана" STR_MAC_ADDRESS: "MAC-адрас:" STR_CHECKING_WIFI: "Праверка Wi-Fi..." diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index a585019f..614821a2 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Obriu aquest URL al navegador" STR_OR_HTTP_PREFIX: "o http://" STR_SCAN_QR_HINT: "o escanegeu el codi QR amb el telèfon:" STR_CALIBRE_WIRELESS: "Calibre sense fils" -STR_CALIBRE_WEB_URL: "URL web del Calibre" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Encriptat | + = Desat" STR_MAC_ADDRESS: "Adreça MAC:" STR_CHECKING_WIFI: "S'està comprovant el WiFi..." diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index b32ef03b..b655a34f 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Otevřete tuto URL ve svém prohlížeči" STR_OR_HTTP_PREFIX: "nebo http://" STR_SCAN_QR_HINT: "nebo naskenujte QR kód telefonem:" STR_CALIBRE_WIRELESS: "Calibre Wireless" -STR_CALIBRE_WEB_URL: "URL webu Calibre" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Šifrováno | + = Uloženo" STR_MAC_ADDRESS: "MAC adresa:" STR_CHECKING_WIFI: "Kontrola WiFi..." diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index 3e4704c9..807d9831 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Åbn denne URL i din browser" STR_OR_HTTP_PREFIX: "eller http://" STR_SCAN_QR_HINT: "eller scan QR-kode med din telefon:" STR_CALIBRE_WIRELESS: "Calibre Wireless" -STR_CALIBRE_WEB_URL: "Calibre Web URL" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Krypteret | + = Gemt" STR_MAC_ADDRESS: "MAC-adresse:" STR_CHECKING_WIFI: "Tjekker WiFi..." diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index 73e44d61..38b88d77 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Open deze URL in je browser" STR_OR_HTTP_PREFIX: "of http://" STR_SCAN_QR_HINT: "of scan de QR-code met je telefoon:" STR_CALIBRE_WIRELESS: "Calibre Wireless" -STR_CALIBRE_WEB_URL: "Calibre Web URL" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Beveiligd | + = Opgeslagen" STR_MAC_ADDRESS: "MAC-adres:" STR_CHECKING_WIFI: "Wifi controleren..." diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 23253403..3a74f0bf 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -232,6 +232,9 @@ STR_UNNAMED: "Unnamed" STR_NO_SERVER_URL: "No server URL configured" STR_FETCH_FEED_FAILED: "Failed to fetch feed" STR_PARSE_FEED_FAILED: "Failed to parse feed" +STR_NEXT_PAGE: "Next Page »" +STR_PREV_PAGE: "« Previous Page" +STR_SEARCH: "Search" STR_NETWORK_PREFIX: "Network: " STR_IP_ADDRESS_PREFIX: "IP Address: " STR_ERROR_GENERAL_FAILURE: "Error: General failure" diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index 35e581dd..e584d7a2 100644 --- a/lib/I18n/translations/finnish.yaml +++ b/lib/I18n/translations/finnish.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Avaa tämä osoite selaimessasi" STR_OR_HTTP_PREFIX: "tai http://" STR_SCAN_QR_HINT: "tai skannaa QR-koodi puhelimellasi:" STR_CALIBRE_WIRELESS: "Calibre langaton" -STR_CALIBRE_WEB_URL: "Calibre-verkko-osoite" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Salattu | + = Tallennettu" STR_MAC_ADDRESS: "MAC-osoite:" STR_CHECKING_WIFI: "Tarkistetaan WiFi..." diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index 811614e9..70b0d8bf 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Nyisd meg ezt az URL-t a böngésződben" STR_OR_HTTP_PREFIX: "vagy http://" STR_SCAN_QR_HINT: "vagy olvasd be a QR-kódot a telefonoddal:" STR_CALIBRE_WIRELESS: "Calibre Wireless" -STR_CALIBRE_WEB_URL: "Calibre Web URL" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Titkosított | + = Mentett" STR_MAC_ADDRESS: "MAC-cím:" STR_CHECKING_WIFI: "WiFi ellenőrzése..." diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index 4b30e932..66c07712 100644 --- a/lib/I18n/translations/kazakh.yaml +++ b/lib/I18n/translations/kazakh.yaml @@ -44,7 +44,7 @@ STR_OPEN_URL_HINT: "Браузерде осы URL мекенжайын ашың STR_OR_HTTP_PREFIX: "немесе http://" STR_SCAN_QR_HINT: "немесе телефонмен QR кодын сканерлеңіз:" STR_CALIBRE_WIRELESS: "Calibre сымсыз" -STR_CALIBRE_WEB_URL: "Calibre Web URL" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Шифрланған | + = Сақталған" STR_MAC_ADDRESS: "MAC мекенжайы:" STR_CHECKING_WIFI: "WiFi тексерілуде..." diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index 2de50f55..bb26d143 100644 --- a/lib/I18n/translations/lithuanian.yaml +++ b/lib/I18n/translations/lithuanian.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Atidarykite šį adresą naršyklėje" STR_OR_HTTP_PREFIX: "arba http://" STR_SCAN_QR_HINT: "arba nuskaitykite QR kodą:" STR_CALIBRE_WIRELESS: "Calibre belaidis" -STR_CALIBRE_WEB_URL: "Calibre Web URL" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Užšifruota | + = Išsaugota" STR_MAC_ADDRESS: "MAC adresas:" STR_CHECKING_WIFI: "Tikrinamas WiFi..." diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index c5d9fed1..a4020fbb 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Otwórz ten URL w przeglądarce" STR_OR_HTTP_PREFIX: "albo http://" STR_SCAN_QR_HINT: "albo zeskanuj kod QR telefonem:" STR_CALIBRE_WIRELESS: "Bezprzewodowe połączenie z Calibre" -STR_CALIBRE_WEB_URL: "Calibre Web URL" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Zaszyfrowane | + = Zapisane" STR_MAC_ADDRESS: "Adres MAC:" STR_CHECKING_WIFI: "Sprawdzanie WiFi..." diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 27ec6e4a..b41caa60 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Deschideţi acest URL în browserul dvs." STR_OR_HTTP_PREFIX: "sau http://" STR_SCAN_QR_HINT: "sau scanaţi codul QR cu telefonul dvs.:" STR_CALIBRE_WIRELESS: "Calibre Wireless" -STR_CALIBRE_WEB_URL: "Calibre URL" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Criptat | + = Salvat" STR_MAC_ADDRESS: "Adresă MAC:" STR_CHECKING_WIFI: "Verificare WiFi..." diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index cb03c08b..58cead73 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Abra esta dirección en su navegador" STR_OR_HTTP_PREFIX: "o http://" STR_SCAN_QR_HINT: "o escanee el código QR con su móvil:" STR_CALIBRE_WIRELESS: "Calibre inalámbrico" -STR_CALIBRE_WEB_URL: "URL del sitio web de Calibre" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* (Cifrado) | + (Guardado)" STR_MAC_ADDRESS: "MAC Address:" STR_CHECKING_WIFI: "Verificando Wi-Fi..." diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 6a5c7986..c079ab07 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Öppna denna adress i din browser" STR_OR_HTTP_PREFIX: "eller http://" STR_SCAN_QR_HINT: "eller skanna QR-kod med din telefon:" STR_CALIBRE_WIRELESS: "Calibre Trådlöst" -STR_CALIBRE_WEB_URL: "Calibre webbadress" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Krypterad | + = Sparad" STR_MAC_ADDRESS: "MAC-adress:" STR_CHECKING_WIFI: "Kontrollerar trådlöst nätverk…" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index 0c6aa1a8..667c9049 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -44,7 +44,7 @@ STR_OPEN_URL_HINT: "Tarayıcınızda bu adresi açın" STR_OR_HTTP_PREFIX: "veya http://" STR_SCAN_QR_HINT: "veya telefonunuzla QR kodu tarayın:" STR_CALIBRE_WIRELESS: "Calibre Kablosuz" -STR_CALIBRE_WEB_URL: "Calibre Web Adresi" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Şifreli | + = Kayıtlı" STR_MAC_ADDRESS: "MAC adresi:" STR_CHECKING_WIFI: "WiFi kontrol ediliyor..." diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index 28f9b59b..52d76958 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Відкрийте цю URL-адресу у вашому б STR_OR_HTTP_PREFIX: "або http://" STR_SCAN_QR_HINT: "або відскануйте QR-код телефоном:" STR_CALIBRE_WIRELESS: "Calibre бездротовий" -STR_CALIBRE_WEB_URL: "URL Calibre Web" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Зашифровано | + = Збережено" STR_MAC_ADDRESS: "MAC адреса:" STR_CHECKING_WIFI: "Перевірка WiFi..." diff --git a/lib/OpdsParser/OpdsParser.cpp b/lib/OpdsParser/OpdsParser.cpp index f4ce6960..84feef74 100644 --- a/lib/OpdsParser/OpdsParser.cpp +++ b/lib/OpdsParser/OpdsParser.cpp @@ -25,15 +25,12 @@ OpdsParser::~OpdsParser() { size_t OpdsParser::write(uint8_t c) { return write(&c, 1); } size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) { - if (errorOccured) { - return length; - } + if (errorOccured) return length; XML_SetUserData(parser, this); XML_SetElementHandler(parser, startElement, endElement); XML_SetCharacterDataHandler(parser, characterData); - // Parse in chunks to avoid large buffer allocations const char* currentPos = reinterpret_cast(xmlData); size_t remaining = length; constexpr size_t chunkSize = 1024; @@ -42,9 +39,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) { void* const buf = XML_GetBuffer(parser, chunkSize); if (!buf) { errorOccured = true; - LOG_DBG("OPDS", "Couldn't allocate memory for buffer"); XML_ParserFree(parser); - parser = nullptr; return length; } @@ -53,13 +48,9 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) { if (XML_ParseBuffer(parser, static_cast(toRead), 0) == XML_STATUS_ERROR) { errorOccured = true; - LOG_DBG("OPDS", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser), - XML_ErrorString(XML_GetErrorCode(parser))); XML_ParserFree(parser); - parser = nullptr; return length; } - currentPos += toRead; remaining -= toRead; } @@ -78,30 +69,25 @@ bool OpdsParser::error() const { return errorOccured; } void OpdsParser::clear() { entries.clear(); + searchTemplate.clear(); + nextPageUrl.clear(); + prevPageUrl.clear(); currentEntry = OpdsEntry{}; currentText.clear(); - inEntry = false; - inTitle = false; - inAuthor = false; - inAuthorName = false; - inId = false; + inEntry = inTitle = inAuthor = inAuthorName = inId = false; } std::vector OpdsParser::getBooks() const { std::vector books; for (const auto& entry : entries) { - if (entry.type == OpdsEntryType::BOOK) { - books.push_back(entry); - } + if (entry.type == OpdsEntryType::BOOK) books.push_back(entry); } return books; } const char* OpdsParser::findAttribute(const XML_Char** atts, const char* name) { for (int i = 0; atts[i]; i += 2) { - if (strcmp(atts[i], name) == 0) { - return atts[i + 1]; - } + if (strcmp(atts[i], name) == 0) return atts[i + 1]; } return nullptr; } @@ -109,7 +95,38 @@ const char* OpdsParser::findAttribute(const XML_Char** atts, const char* name) { void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) { auto* self = static_cast(userData); - // Check for entry element (with or without namespace prefix) + if (strcmp(name, "link") == 0 || strstr(name, ":link") != nullptr) { + const char* href = findAttribute(atts, "href"); + if (href) { + const char* rel = findAttribute(atts, "rel"); + const char* type = findAttribute(atts, "type"); + + if (rel && strcmp(rel, "search") == 0) { + std::string sHref(href); + if (sHref.find("{searchTerms}") != std::string::npos) { + self->searchTemplate = sHref; + } + } else if (rel && strcmp(rel, "next") == 0 && !self->inEntry) { + self->nextPageUrl = href; + } else if (rel && strcmp(rel, "previous") == 0 && !self->inEntry) { + self->prevPageUrl = href; + } + + if (self->inEntry) { + if (rel && type && strstr(rel, "opds-spec.org/acquisition") != nullptr && + strcmp(type, "application/epub+zip") == 0) { + self->currentEntry.type = OpdsEntryType::BOOK; + self->currentEntry.href = href; + } else if (type && strstr(type, "application/atom+xml") != nullptr) { + if (self->currentEntry.type != OpdsEntryType::BOOK) { + self->currentEntry.type = OpdsEntryType::NAVIGATION; + self->currentEntry.href = href; + } + } + } + } + } + if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) { self->inEntry = true; self->currentEntry = OpdsEntry{}; @@ -118,112 +135,46 @@ void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, cons if (!self->inEntry) return; - // Check for title element if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) { self->inTitle = true; self->currentText.clear(); - return; - } - - // Check for author element - if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) { + } else if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) { self->inAuthor = true; - return; - } - - // Check for author name element - if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) { + } else if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) { self->inAuthorName = true; self->currentText.clear(); - return; - } - - // Check for id element - if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { + } else if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { self->inId = true; self->currentText.clear(); - return; - } - - // Check for link element - if (strcmp(name, "link") == 0 || strstr(name, ":link") != nullptr) { - const char* rel = findAttribute(atts, "rel"); - const char* type = findAttribute(atts, "type"); - const char* href = findAttribute(atts, "href"); - - if (href) { - // Check for acquisition link with epub type (this is a downloadable book) - if (rel && type && strstr(rel, "opds-spec.org/acquisition") != nullptr && - strcmp(type, "application/epub+zip") == 0) { - self->currentEntry.type = OpdsEntryType::BOOK; - self->currentEntry.href = href; - } - // Check for navigation link (subsection or no rel specified with atom+xml type) - else if (type && strstr(type, "application/atom+xml") != nullptr) { - // Only set navigation link if we don't already have an epub link - if (self->currentEntry.type != OpdsEntryType::BOOK) { - self->currentEntry.type = OpdsEntryType::NAVIGATION; - self->currentEntry.href = href; - } - } - } } } void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) { auto* self = static_cast(userData); - // Check for entry end if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) { - // Only add entry if it has required fields (title and href) if (!self->currentEntry.title.empty() && !self->currentEntry.href.empty()) { self->entries.push_back(self->currentEntry); } self->inEntry = false; - self->currentEntry = OpdsEntry{}; - return; - } - - if (!self->inEntry) return; - - // Check for title end - if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) { - if (self->inTitle) { - self->currentEntry.title = self->currentText; - } - self->inTitle = false; - return; - } - - // Check for author end - if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) { - self->inAuthor = false; - return; - } - - // Check for author name end - if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) { - if (self->inAuthorName) { + } else if (self->inEntry) { + if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) { + if (self->inTitle) self->currentEntry.title = self->currentText; + self->inTitle = false; + } else if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) { + self->inAuthor = false; + } else if (self->inAuthorName && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) { self->currentEntry.author = self->currentText; + self->inAuthorName = false; + } else if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { + if (self->inId) self->currentEntry.id = self->currentText; + self->inId = false; } - self->inAuthorName = false; - return; - } - - // Check for id end - if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { - if (self->inId) { - self->currentEntry.id = self->currentText; - } - self->inId = false; - return; } } void XMLCALL OpdsParser::characterData(void* userData, const XML_Char* s, const int len) { auto* self = static_cast(userData); - - // Only accumulate text when in a text element if (self->inTitle || self->inAuthorName || self->inId) { self->currentText.append(s, len); } diff --git a/lib/OpdsParser/OpdsParser.h b/lib/OpdsParser/OpdsParser.h index 570ac4cc..9c93b89a 100644 --- a/lib/OpdsParser/OpdsParser.h +++ b/lib/OpdsParser/OpdsParser.h @@ -49,6 +49,9 @@ class OpdsParser final : public Print { ~OpdsParser(); // Disable copy + const std::string& getSearchTemplate() const { return searchTemplate; } + const std::string& getNextPageUrl() const { return nextPageUrl; } + const std::string& getPrevPageUrl() const { return prevPageUrl; } OpdsParser(const OpdsParser&) = delete; OpdsParser& operator=(const OpdsParser&) = delete; @@ -85,6 +88,9 @@ class OpdsParser final : public Print { static void XMLCALL endElement(void* userData, const XML_Char* name); static void XMLCALL characterData(void* userData, const XML_Char* s, int len); + std::string searchTemplate; + std::string nextPageUrl; + std::string prevPageUrl; // Helper to find attribute value static const char* findAttribute(const XML_Char** atts, const char* name); diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index da7f2845..100a4a4c 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -8,9 +8,14 @@ #include #include +#include +#include +#include + #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "activities/network/WifiSelectionActivity.h" +#include "activities/util/KeyboardEntryActivity.h" #include "components/UITheme.h" #include "fontIds.h" #include "network/HttpDownloader.h" @@ -19,7 +24,7 @@ namespace { constexpr int PAGE_ITEMS = 23; -} // namespace +} void OpdsBookBrowserActivity::onEnter() { Activity::onEnter(); @@ -28,12 +33,14 @@ void OpdsBookBrowserActivity::onEnter() { entries.clear(); navigationHistory.clear(); currentPath = ""; // Root path - user provides full URL in settings + searchTemplate.clear(); selectorIndex = 0; + consumeConfirm = false; + consumeBack = false; errorMessage.clear(); statusMessage = tr(STR_CHECKING_WIFI); requestUpdate(); - // Check WiFi and connect if needed, then fetch feed checkAndConnectWifi(); } @@ -47,26 +54,27 @@ void OpdsBookBrowserActivity::onExit() { } void OpdsBookBrowserActivity::loop() { - // Handle WiFi selection subactivity - if (state == BrowserState::WIFI_SELECTION) { - // Should already handled by the WifiSelectionActivity + if (state == BrowserState::WIFI_SELECTION || state == BrowserState::SEARCH_INPUT) { + return; + } + + if (consumeConfirm && mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + consumeConfirm = false; + return; + } + if (consumeBack && mappedInput.wasReleased(MappedInputManager::Button::Back)) { + consumeBack = false; return; } - // Handle error state - Confirm retries, Back goes back or home if (state == BrowserState::ERROR) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - // Check if WiFi is still connected if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { - // WiFi connected - just retry fetching the feed - LOG_DBG("OPDS", "Retry: WiFi connected, retrying fetch"); state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); requestUpdate(); fetchFeed(currentPath); } else { - // WiFi not connected - launch WiFi selection - LOG_DBG("OPDS", "Retry: WiFi not connected, launching selection"); launchWifiSelection(); } } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { @@ -75,59 +83,40 @@ void OpdsBookBrowserActivity::loop() { return; } - // Handle WiFi check state - only Back works - if (state == BrowserState::CHECK_WIFI) { + if (state == BrowserState::CHECK_WIFI || state == BrowserState::LOADING) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - onGoHome(); + state == BrowserState::CHECK_WIFI ? onGoHome() : navigateBack(); } return; } - // Handle loading state - only Back works - if (state == BrowserState::LOADING) { - if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - navigateBack(); - } - return; - } + if (state == BrowserState::DOWNLOADING) return; - // Handle downloading state - no input allowed - if (state == BrowserState::DOWNLOADING) { - return; - } - - // Handle browsing state if (state == BrowserState::BROWSING) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (!entries.empty()) { const auto& entry = entries[selectorIndex]; - if (entry.type == OpdsEntryType::BOOK) { - downloadBook(entry); - } else { - navigateToEntry(entry); - } + entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry); } } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { navigateBack(); + } else if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { + if (!searchTemplate.empty() && selectorIndex == 0) launchSearch(); } - // Handle navigation if (!entries.empty()) { buttonNavigator.onNextRelease([this] { selectorIndex = ButtonNavigator::nextIndex(selectorIndex, entries.size()); requestUpdate(); }); - buttonNavigator.onPreviousRelease([this] { selectorIndex = ButtonNavigator::previousIndex(selectorIndex, entries.size()); requestUpdate(); }); - buttonNavigator.onNextContinuous([this] { selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, entries.size(), PAGE_ITEMS); requestUpdate(); }); - buttonNavigator.onPreviousContinuous([this] { selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, entries.size(), PAGE_ITEMS); requestUpdate(); @@ -187,11 +176,9 @@ void OpdsBookBrowserActivity::render(RenderLock&&) { // Browsing state // Show appropriate button hint based on selected entry type - const char* confirmLabel = tr(STR_OPEN); - if (!entries.empty() && entries[selectorIndex].type == OpdsEntryType::BOOK) { - confirmLabel = tr(STR_DOWNLOAD); - } - const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + const char* confirmLabel = (!entries.empty() && entries[selectorIndex].type == OpdsEntryType::BOOK) ? tr(STR_DOWNLOAD) : tr(STR_OPEN); + const char* searchLabel = (!searchTemplate.empty() && selectorIndex == 0) ? tr(STR_SEARCH) : tr(STR_DIR_UP); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, searchLabel, tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); if (entries.empty()) { @@ -227,15 +214,14 @@ void OpdsBookBrowserActivity::render(RenderLock&&) { } void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { - const char* serverUrl = SETTINGS.opdsServerUrl; - if (strlen(serverUrl) == 0) { + if (strlen(SETTINGS.opdsServerUrl) == 0) { state = BrowserState::ERROR; errorMessage = tr(STR_NO_SERVER_URL); requestUpdate(); return; } - std::string url = UrlUtils::buildUrl(serverUrl, path); + std::string url = (path.rfind("http", 0) == 0) ? path : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, path); LOG_DBG("OPDS", "Fetching: %s", url.c_str()); OpdsParser parser; @@ -257,50 +243,46 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { return; } + searchTemplate = parser.getSearchTemplate(); + const auto& nextUrl = parser.getNextPageUrl(); + const auto& prevUrl = parser.getPrevPageUrl(); entries = std::move(parser).getEntries(); - LOG_DBG("OPDS", "Found %d entries", entries.size()); - selectorIndex = 0; - if (entries.empty()) { - state = BrowserState::ERROR; - errorMessage = tr(STR_NO_ENTRIES); - requestUpdate(); - return; + if (!prevUrl.empty()) { + entries.insert(entries.begin(), OpdsEntry{OpdsEntryType::NAVIGATION, tr(STR_PREV_PAGE), "", prevUrl, ""}); + } + if (!nextUrl.empty()) { + entries.push_back(OpdsEntry{OpdsEntryType::NAVIGATION, tr(STR_NEXT_PAGE), "", nextUrl, ""}); } - state = BrowserState::BROWSING; + selectorIndex = 0; + state = entries.empty() ? BrowserState::ERROR : BrowserState::BROWSING; + if (entries.empty()) errorMessage = tr(STR_NO_ENTRIES); requestUpdate(); } void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) { - // Push current path to history before navigating navigationHistory.push_back(currentPath); currentPath = entry.href; - state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); entries.clear(); selectorIndex = 0; - requestUpdate(true); // Force update to show loading state immediately before fetch - + requestUpdate(true); fetchFeed(currentPath); } void OpdsBookBrowserActivity::navigateBack() { if (navigationHistory.empty()) { - // At root, go home onGoHome(); } else { - // Go back to previous catalog currentPath = navigationHistory.back(); navigationHistory.pop_back(); - state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); entries.clear(); selectorIndex = 0; requestUpdate(); - fetchFeed(currentPath); } } @@ -312,15 +294,8 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { downloadTotal = 0; requestUpdate(true); - // Build full download URL - std::string downloadUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href); - - // Create sanitized filename: "Author - Title.epub" or just "Title.epub" if no author - std::string baseName = book.title; - if (!book.author.empty()) { - baseName = book.author + " - " + baseName; - } - std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + ".epub"; + std::string downloadUrl = (book.href.rfind("http", 0) == 0) ? book.href : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href); + std::string filename = "/" + StringUtils::sanitizeFilename(book.title + (book.author.empty() ? "" : " - " + book.author)) + ".epub"; LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str()); @@ -328,7 +303,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) { downloadProgress = downloaded; downloadTotal = total; - requestUpdate(true); // Force update to refresh progress bar + requestUpdate(true); }); if (result == HttpDownloader::OK) { @@ -348,11 +323,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { 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.clearCache(); - LOG_DBG("OPDS", "Cleared cache for: %s", filename.c_str()); - + Epub(filename, "/.crosspoint").clearCache(); state = BrowserState::BROWSING; requestUpdate(); } else { @@ -362,6 +333,58 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { } } +void OpdsBookBrowserActivity::launchSearch() { + consumeConfirm = true; + state = BrowserState::SEARCH_INPUT; + requestUpdate(); + + auto keyboard = std::make_unique(renderer, mappedInput, tr(STR_SEARCH)); + startActivityForResult(std::move(keyboard), [this](const ActivityResult& result) { + state = BrowserState::BROWSING; + if (!result.isCancelled) { + performSearch(std::get(result.data).text); + } else { + requestUpdate(); + } + }); +} + +void OpdsBookBrowserActivity::performSearch(const std::string& query) { + if (query.empty() || searchTemplate.empty()) { + state = BrowserState::BROWSING; + requestUpdate(); + return; + } + + auto urlEncode = [](const std::string& s) { + std::string out; + out.reserve(s.size() * 3); + for (unsigned char c : s) { + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') + out += static_cast(c); + else { + char buf[4]; + snprintf(buf, sizeof(buf), "%%%02X", c); + out += buf; + } + } + return out; + }; + + std::string url = searchTemplate; + const std::string placeholder = "{searchTerms}"; + const size_t pos = url.find(placeholder); + if (pos != std::string::npos) url.replace(pos, placeholder.length(), urlEncode(query)); + + navigationHistory.push_back(currentPath); + currentPath = url; + + state = BrowserState::LOADING; + statusMessage = tr(STR_LOADING); + requestUpdate(true); + fetchFeed(url); +} + void OpdsBookBrowserActivity::checkAndConnectWifi() { // Already connected? Verify connection is valid by checking IP if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { @@ -371,12 +394,11 @@ void OpdsBookBrowserActivity::checkAndConnectWifi() { fetchFeed(currentPath); return; } - - // Not connected - launch WiFi selection screen directly launchWifiSelection(); } void OpdsBookBrowserActivity::launchWifiSelection() { + consumeBack = consumeConfirm = true; state = BrowserState::WIFI_SELECTION; requestUpdate(); @@ -386,15 +408,11 @@ void OpdsBookBrowserActivity::launchWifiSelection() { void OpdsBookBrowserActivity::onWifiSelectionComplete(const bool connected) { if (connected) { - LOG_DBG("OPDS", "WiFi connected via selection, fetching feed"); state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); - requestUpdate(true); // Force update to show loading state immediately before fetch + requestUpdate(true); fetchFeed(currentPath); } else { - LOG_DBG("OPDS", "WiFi selection cancelled/failed"); - // Force disconnect to ensure clean state for next retry - // This prevents stale connection status from interfering WiFi.disconnect(); WiFi.mode(WIFI_OFF); state = BrowserState::ERROR; diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h index fa716cbd..8b55343a 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.h +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -11,21 +11,13 @@ /** * Activity for browsing and downloading books from an OPDS server. * Supports navigation through catalog hierarchy and downloading EPUBs. - * When WiFi connection fails, launches WiFi selection to let user connect. */ class OpdsBookBrowserActivity final : public Activity { public: - enum class BrowserState { - CHECK_WIFI, // Checking WiFi connection - WIFI_SELECTION, // WiFi selection subactivity is active - LOADING, // Fetching OPDS feed - BROWSING, // Displaying entries (navigation or books) - DOWNLOADING, // Downloading selected EPUB - ERROR // Error state with message - }; + enum class BrowserState { CHECK_WIFI, WIFI_SELECTION, LOADING, BROWSING, DOWNLOADING, ERROR, SEARCH_INPUT }; explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) - : Activity("OpdsBookBrowser", renderer, mappedInput) {} + : Activity("OpdsBookBrowser", renderer, mappedInput), buttonNavigator() {} void onEnter() override; void onExit() override; @@ -36,8 +28,11 @@ class OpdsBookBrowserActivity final : public Activity { ButtonNavigator buttonNavigator; BrowserState state = BrowserState::LOADING; std::vector entries; - std::vector navigationHistory; // Stack of previous feed paths for back navigation - std::string currentPath; // Current feed path being displayed + std::vector navigationHistory; + std::string currentPath; + std::string searchTemplate; + bool consumeConfirm = false; + bool consumeBack = false; // Added missing member int selectorIndex = 0; std::string errorMessage; std::string statusMessage; @@ -51,5 +46,7 @@ class OpdsBookBrowserActivity final : public Activity { void navigateToEntry(const OpdsEntry& entry); void navigateBack(); void downloadBook(const OpdsEntry& book); + void launchSearch(); + void performSearch(const std::string& query); bool preventAutoSleep() override { return true; } };