feat: add OPDS search support & next/prev page navigation (#1462)

## 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
`<link rel="search">` 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 `<link
rel="search" type="application/opensearchdescription+xml">`
- 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 <rammah@tuta.io>
Co-authored-by: Justin Mitchell <justin@jmitch.com>
This commit is contained in:
rxmmah
2026-04-12 12:24:27 -04:00
committed by GitHub
co-authored by kira Justin Mitchell
parent 5c12f2f01e
commit fa2a3d2539
25 changed files with 208 additions and 277 deletions
+116 -142
View File
@@ -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<size_t>(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<size_t>(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<size_t>(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<size_t>(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<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_SEARCH));
startActivityForResult(std::move(keyboard), [this](const ActivityResult& result) {
state = BrowserState::BROWSING;
if (!result.isCancelled) {
performSearch(std::get<KeyboardResult>(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<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));
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;
@@ -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<OpdsEntry> entries;
std::vector<std::string> navigationHistory; // Stack of previous feed paths for back navigation
std::string currentPath; // Current feed path being displayed
std::vector<std::string> 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; }
};