mirror of
https://github.com/crosspoint-reader/crosspoint-reader.git
synced 2026-04-29 10:26:52 -07:00
feat: add OPDS search support
- Parse OpenSearch template URL from OPDS feed - Launch keyboard entry on Left button when search is available - URL-encode search query and fetch results feed - Guard against stale Confirm press auto-downloading after search - Handle absolute search result URLs in fetchFeed
This commit is contained in:
@@ -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<const char*>(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<OpdsParser*>(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<OpdsParser*>(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<OpdsParser*>(userData);
|
||||
|
||||
// Only accumulate text when in a text element
|
||||
if (self->inTitle || self->inAuthorName || self->inId) {
|
||||
self->currentText.append(s, len);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_CUSTOM));
|
||||
|
||||
startActivityForResult(std::move(keyboard), [this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
consumeConfirm = true;
|
||||
performSearch(std::get<KeyboardResult>(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<char>(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)) {
|
||||
|
||||
@@ -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<OpdsEntry> entries;
|
||||
std::vector<std::string> 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; }
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user