From fa2a3d25393414a75620d22abc1105dd5703240e Mon Sep 17 00:00:00 2001 From: rxmmah <115156210+rxmmah@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:24:27 +0200 Subject: [PATCH] feat: add OPDS search support & next/prev page navigation (#1462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary **What is the goal of this PR?** Adds OPDS search support, allowing users to search a catalog directly from the book browser when the server exposes an OpenSearch template. **What changes are included?** - `OpdsParser`: parses the OpenSearch template URL from feed-level `` elements and exposes it via `getSearchTemplate()` - `OpdsBookBrowserActivity`: fetches and stores the search template after each feed load; shows a Search hint on the Left button when a template is available; launches the existing `KeyboardEntryActivity` for query input; URL-encodes the query and fetches the result feed - Absolute search result URLs are handled correctly in `fetchFeed` (skips prepending the server base URL) - A `consumeConfirm` guard prevents the Confirm release that submits the keyboard from immediately triggering a book download on the first browsing frame after search results load ## Additional Context - Search is silently unavailable if the server does not advertise an OpenSearch template — no UI change in that case - Tested against a Calibre-Web OPDS endpoint which exposes `` - The inline URL encoder in `performSearch` was necessary as `StringUtils` has no such utility; worth considering extracting to `StringUtils` in a follow-up - No new dependencies introduced --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --------- Co-authored-by: kira Co-authored-by: Justin Mitchell --- 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 | 5 +- lib/I18n/translations/finnish.yaml | 2 +- lib/I18n/translations/french.yaml | 2 +- lib/I18n/translations/german.yaml | 2 +- lib/I18n/translations/hungarian.yaml | 2 +- lib/I18n/translations/italian.yaml | 2 +- lib/I18n/translations/kazakh.yaml | 2 +- lib/I18n/translations/lithuanian.yaml | 2 +- lib/I18n/translations/polish.yaml | 2 +- lib/I18n/translations/portuguese.yaml | 2 +- lib/I18n/translations/romanian.yaml | 2 +- lib/I18n/translations/russian.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 | 258 ++++++++---------- .../browser/OpdsBookBrowserActivity.h | 21 +- 25 files changed, 208 insertions(+), 277 deletions(-) diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index e17f3bad2..cf1d2de74 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 a585019f1..614821a21 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 2add9e737..06261a26c 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 3e4704c91..807d98312 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 73e44d61a..38b88d77c 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 df91c1a1c..b741a85d0 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Open this URL in your browser" STR_OR_HTTP_PREFIX: "or http://" STR_SCAN_QR_HINT: "or scan QR code with your phone:" STR_CALIBRE_WIRELESS: "Calibre Wireless" -STR_CALIBRE_WEB_URL: "Calibre Web URL" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Encrypted | + = Saved" STR_MAC_ADDRESS: "MAC address:" STR_CHECKING_WIFI: "Checking WiFi..." @@ -175,6 +175,8 @@ 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_NETWORK_PREFIX: "Network: " STR_IP_ADDRESS_PREFIX: "IP Address: " STR_ERROR_GENERAL_FAILURE: "Error: General failure" @@ -229,6 +231,7 @@ STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix" STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons" STR_OPDS_BROWSER: "OPDS Browser" +STR_SEARCH: "Search" STR_COVER_CUSTOM: "Cover + Custom" STR_MENU_RECENT_BOOKS: "Recent Books" STR_NO_RECENT_BOOKS: "No recent books" diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index 35e581dd5..e584d7a26 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/french.yaml b/lib/I18n/translations/french.yaml index f91e46f42..b908fa84d 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Ouvrez cette URL dans un navigateur" STR_OR_HTTP_PREFIX: "ou http://" STR_SCAN_QR_HINT: "ou scannez le QR code :" STR_CALIBRE_WIRELESS: "Connexion Calibre sans fil" -STR_CALIBRE_WEB_URL: "URL Web Calibre" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Sécurisé | + = Sauvegardé" STR_MAC_ADDRESS: "Adresse MAC :" STR_CHECKING_WIFI: "Vérification du WiFi…" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index b53ff4944..881a2b012 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Diese URL im Browser öffnen" STR_OR_HTTP_PREFIX: "oder http://" STR_SCAN_QR_HINT: "oder QR-Code mit dem Handy scannen:" STR_CALIBRE_WIRELESS: "Calibre Wireless" -STR_CALIBRE_WEB_URL: "Calibre-Web-URL" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Verschlüsselt | + = Gespeichert" STR_MAC_ADDRESS: "MAC-Adresse:" STR_CHECKING_WIFI: "WLAN prüfen…" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index e28524ce3..38c1bc12b 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/italian.yaml b/lib/I18n/translations/italian.yaml index b18bea1f2..f7679ccf6 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Apri questo URL nel tuo browser" STR_OR_HTTP_PREFIX: "o http://" STR_SCAN_QR_HINT: "o scansiona il codice QR con il tuo telefono:" STR_CALIBRE_WIRELESS: "Calibre Wireless" -STR_CALIBRE_WEB_URL: "URL Web Calibre" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Criptata | + = Salvata" STR_MAC_ADDRESS: "Indirizzo MAC:" STR_CHECKING_WIFI: "Controllo WiFi in corso..." diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index 4b30e9328..66c07712b 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 91eb7c6ec..67e424abc 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 c5d9fed1f..a4020fbb4 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/portuguese.yaml b/lib/I18n/translations/portuguese.yaml index 962863ad9..989806493 100644 --- a/lib/I18n/translations/portuguese.yaml +++ b/lib/I18n/translations/portuguese.yaml @@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Abra este URL seu navegador" STR_OR_HTTP_PREFIX: "ou http://" STR_SCAN_QR_HINT: "ou escaneie o QR code com seu celular:" STR_CALIBRE_WIRELESS: "Calibre sem fio" -STR_CALIBRE_WEB_URL: "URL do Calibre Web" +STR_CALIBRE_WEB_URL: "OPDS URL" STR_NETWORK_LEGEND: "* = Criptografada | + = Salva" STR_MAC_ADDRESS: "Endereço MAC:" STR_CHECKING_WIFI: "Verificando Wi‑Fi..." diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 27ec6e4a1..b41caa60f 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/russian.yaml b/lib/I18n/translations/russian.yaml index b1354ab67..708a0b4ba 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.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: "Web-адрес 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/spanish.yaml b/lib/I18n/translations/spanish.yaml index d4b2ad149..be4ab7a7e 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 c5e577e3a..34c013249 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 0c6aa1a88..667c90494 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 c6a03112a..d76cad9db 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 f4ce69602..84feef74f 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 570ac4cce..9c93b89ac 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 f6a58385d..e4c6f9fd9 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -10,6 +10,7 @@ #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" @@ -18,7 +19,7 @@ namespace { constexpr int PAGE_ITEMS = 23; -} // namespace +} void OpdsBookBrowserActivity::onEnter() { Activity::onEnter(); @@ -26,47 +27,47 @@ void OpdsBookBrowserActivity::onEnter() { state = BrowserState::CHECK_WIFI; entries.clear(); navigationHistory.clear(); - currentPath = ""; // Root path - user provides full URL in settings + searchTemplate = ""; + currentPath = ""; selectorIndex = 0; + consumeConfirm = false; + consumeBack = false; errorMessage.clear(); statusMessage = tr(STR_CHECKING_WIFI); requestUpdate(); - // Check WiFi and connect if needed, then fetch feed checkAndConnectWifi(); } void OpdsBookBrowserActivity::onExit() { Activity::onExit(); - - // Turn off WiFi when exiting WiFi.mode(WIFI_OFF); - entries.clear(); navigationHistory.clear(); } 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 +76,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(); @@ -138,21 +120,12 @@ void OpdsBookBrowserActivity::loop() { void OpdsBookBrowserActivity::render(RenderLock&&) { renderer.clearScreen(); - const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_OPDS_BROWSER), true, EpdFontFamily::BOLD); - if (state == BrowserState::CHECK_WIFI) { - renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); - const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); - GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - renderer.displayBuffer(); - return; - } - - if (state == BrowserState::LOADING) { + if (state == BrowserState::CHECK_WIFI || state == BrowserState::LOADING) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); @@ -171,76 +144,50 @@ void OpdsBookBrowserActivity::render(RenderLock&&) { if (state == BrowserState::DOWNLOADING) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, tr(STR_DOWNLOADING)); - const auto maxWidth = pageWidth - 40; - // Trim long titles to keep them within the screen bounds. - auto title = renderer.truncatedText(UI_10_FONT_ID, statusMessage.c_str(), maxWidth); + auto title = renderer.truncatedText(UI_10_FONT_ID, statusMessage.c_str(), pageWidth - 40); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 10, title.c_str()); if (downloadTotal > 0) { - const int barWidth = pageWidth - 100; - constexpr int barHeight = 20; - constexpr int barX = 50; - const int barY = pageHeight / 2 + 20; - GUI.drawProgressBar(renderer, Rect{barX, barY, barWidth, barHeight}, downloadProgress, downloadTotal); + GUI.drawProgressBar(renderer, Rect{50, pageHeight / 2 + 20, pageWidth - 100, 20}, downloadProgress, + downloadTotal); } renderer.displayBuffer(); return; } - // 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()) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_NO_ENTRIES)); - renderer.displayBuffer(); - return; - } + } else { + const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; + renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30); - const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; - renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30); - - for (size_t i = pageStartIndex; i < entries.size() && i < static_cast(pageStartIndex + PAGE_ITEMS); i++) { - const auto& entry = entries[i]; - - // Format display text with type indicator - std::string displayText; - if (entry.type == OpdsEntryType::NAVIGATION) { - displayText = "> " + entry.title; // Folder/navigation indicator - } else { - // Book: "Title - Author" or just "Title" - displayText = entry.title; - if (!entry.author.empty()) { - displayText += " - " + entry.author; - } + for (size_t i = pageStartIndex; i < entries.size() && i < static_cast(pageStartIndex + PAGE_ITEMS); i++) { + const auto& entry = entries[i]; + std::string displayText = (entry.type == OpdsEntryType::NAVIGATION) ? "> " + entry.title : entry.title; + if (entry.type == OpdsEntryType::BOOK && !entry.author.empty()) displayText += " - " + entry.author; + auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), pageWidth - 40); + renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), + i != static_cast(selectorIndex)); } - - auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), renderer.getScreenWidth() - 40); - renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), - i != static_cast(selectorIndex)); } - renderer.displayBuffer(); } 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); - LOG_DBG("OPDS", "Fetching: %s", url.c_str()); - + std::string url = (path.find("http") == 0) ? path : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, path); OpdsParser parser; - { OpdsParserStream stream{parser}; if (!HttpDownloader::fetchUrl(url, stream)) { @@ -258,50 +205,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); } } @@ -309,48 +252,84 @@ void OpdsBookBrowserActivity::navigateBack() { void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { state = BrowserState::DOWNLOADING; statusMessage = book.title; - downloadProgress = 0; - downloadTotal = 0; + downloadProgress = downloadTotal = 0; requestUpdate(true); - // Build full download URL - 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; - if (!book.author.empty()) { - baseName += " - " + book.author; - } - std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + ".epub"; - - LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str()); + std::string downloadUrl = + (book.href.find("http") == 0) ? book.href : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href); + std::string filename = + "/" + StringUtils::sanitizeFilename(book.title + (book.author.empty() ? "" : " - " + book.author)) + ".epub"; const auto result = 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) { - 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 { state = BrowserState::ERROR; errorMessage = tr(STR_DOWNLOAD_FAILED); - requestUpdate(); } + requestUpdate(); +} + +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); // <-- add this + currentPath = url; // <-- add this + + 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)) { state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); @@ -358,12 +337,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(); @@ -373,15 +351,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 fa716cbd2..8b55343a3 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; } };