diff --git a/lib/OpdsParser/OpdsParser.cpp b/lib/OpdsParser/OpdsParser.cpp index f4ce69602..db56bff84 100644 --- a/lib/OpdsParser/OpdsParser.cpp +++ b/lib/OpdsParser/OpdsParser.cpp @@ -33,7 +33,6 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) { 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; @@ -78,6 +77,7 @@ bool OpdsParser::error() const { return errorOccured; } void OpdsParser::clear() { entries.clear(); + searchTemplate.clear(); // Reset search template on clear currentEntry = OpdsEntry{}; currentText.clear(); inEntry = false; @@ -109,7 +109,36 @@ 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) + // Handle feed-level or entry-level links + 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) { + // 1. Search Template: Look for search relation + if (rel && strcmp(rel, "search") == 0) { + self->searchTemplate = href; + } + + if (self->inEntry) { + // 2. Book: acquisition link with epub type + 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; + } + // 3. Navigation: atom+xml type + 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,64 +147,34 @@ 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) { self->inAuthor = true; return; } - // Check for author name element 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) { 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); } @@ -186,7 +185,6 @@ void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) { 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; @@ -195,13 +193,11 @@ void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) { 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) { self->currentEntry.author = self->currentText; @@ -210,7 +206,6 @@ void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) { return; } - // Check for id end if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { if (self->inId) { self->currentEntry.id = self->currentText; @@ -222,8 +217,6 @@ void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) { 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..45b36796c 100644 --- a/lib/OpdsParser/OpdsParser.h +++ b/lib/OpdsParser/OpdsParser.h @@ -49,6 +49,7 @@ class OpdsParser final : public Print { ~OpdsParser(); // Disable copy + const std::string& getSearchTemplate() const { return searchTemplate; } OpdsParser(const OpdsParser&) = delete; OpdsParser& operator=(const OpdsParser&) = delete; @@ -85,6 +86,7 @@ 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; // 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..0cc4c7e32 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" @@ -26,8 +27,10 @@ void OpdsBookBrowserActivity::onEnter() { state = BrowserState::CHECK_WIFI; entries.clear(); navigationHistory.clear(); + searchTemplate = ""; currentPath = ""; // Root path - user provides full URL in settings selectorIndex = 0; + consumeConfirm = false; errorMessage.clear(); statusMessage = tr(STR_CHECKING_WIFI); requestUpdate(); @@ -47,9 +50,9 @@ void OpdsBookBrowserActivity::onExit() { } void OpdsBookBrowserActivity::loop() { - // Handle WiFi selection subactivity - if (state == BrowserState::WIFI_SELECTION) { - // Should already handled by the WifiSelectionActivity + // Handle WiFi selection / search input subactivities + if (state == BrowserState::WIFI_SELECTION || state == BrowserState::SEARCH_INPUT) { + // Already handled by the child activity return; } @@ -99,7 +102,9 @@ void OpdsBookBrowserActivity::loop() { // Handle browsing state if (state == BrowserState::BROWSING) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - if (!entries.empty()) { + if (consumeConfirm) { + consumeConfirm = false; + } else if (!entries.empty()) { const auto& entry = entries[selectorIndex]; if (entry.type == OpdsEntryType::BOOK) { downloadBook(entry); @@ -109,6 +114,10 @@ void OpdsBookBrowserActivity::loop() { } } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { navigateBack(); + } else if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { + if (!searchTemplate.empty()) { + launchSearch(); + } } // Handle navigation @@ -192,7 +201,8 @@ void OpdsBookBrowserActivity::render(RenderLock&&) { 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* searchLabel = searchTemplate.empty() ? "" : tr(STR_CUSTOM); + 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()) { @@ -236,7 +246,7 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { return; } - std::string url = UrlUtils::buildUrl(serverUrl, path); + std::string url = (path.find("http") == 0) ? path : UrlUtils::buildUrl(serverUrl, path); LOG_DBG("OPDS", "Fetching: %s", url.c_str()); OpdsParser parser; @@ -258,6 +268,8 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { return; } + searchTemplate = parser.getSearchTemplate(); + entries = std::move(parser).getEntries(); LOG_DBG("OPDS", "Found %d entries", entries.size()); selectorIndex = 0; @@ -349,6 +361,60 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { } } +void OpdsBookBrowserActivity::launchSearch() { + state = BrowserState::SEARCH_INPUT; + requestUpdate(); + + auto keyboard = std::make_unique(renderer, mappedInput, tr(STR_CUSTOM)); + + startActivityForResult(std::move(keyboard), [this](const ActivityResult& result) { + if (!result.isCancelled) { + consumeConfirm = true; + performSearch(std::get(result.data).text); + } else { + state = BrowserState::BROWSING; + requestUpdate(); + } + }); +} + +void OpdsBookBrowserActivity::performSearch(const std::string& query) { + if (query.empty() || searchTemplate.empty()) { + state = BrowserState::BROWSING; + requestUpdate(); + return; + } + + // StringUtils has no url_encode — encode inline + 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)); + } + + LOG_DBG("OPDS", "Search URL: %s", url.c_str()); + 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)) { diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h index fa716cbd2..7c5f760ca 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.h +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -21,7 +21,8 @@ class OpdsBookBrowserActivity final : public Activity { LOADING, // Fetching OPDS feed BROWSING, // Displaying entries (navigation or books) DOWNLOADING, // Downloading selected EPUB - ERROR // Error state with message + ERROR, // Error state with message + SEARCH_INPUT // Keyboard entry subactivity is active }; explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) @@ -38,6 +39,8 @@ class OpdsBookBrowserActivity final : public Activity { std::vector entries; std::vector navigationHistory; // Stack of previous feed paths for back navigation std::string currentPath; // Current feed path being displayed + std::string searchTemplate; // OpenSearch template URL, empty if server has no search + bool consumeConfirm = false; // Swallows the Confirm release that closed the keyboard int selectorIndex = 0; std::string errorMessage; std::string statusMessage; @@ -51,5 +54,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; } };