diff --git a/lib/OpdsParser/OpdsParser.cpp b/lib/OpdsParser/OpdsParser.cpp index 21117d3d..3c54ea0e 100644 --- a/lib/OpdsParser/OpdsParser.cpp +++ b/lib/OpdsParser/OpdsParser.cpp @@ -60,13 +60,13 @@ bool OpdsParser::parse(const char* xmlData, const size_t length) { XML_ParserFree(parser); parser = nullptr; - Serial.printf("[%lu] [OPDS] Parsed %zu books\n", millis(), books.size()); + Serial.printf("[%lu] [OPDS] Parsed %zu entries\n", millis(), entries.size()); return true; } void OpdsParser::clear() { - books.clear(); - currentBook = OpdsBook{}; + entries.clear(); + currentEntry = OpdsEntry{}; currentText.clear(); inEntry = false; inTitle = false; @@ -75,6 +75,16 @@ void OpdsParser::clear() { inId = false; } +std::vector OpdsParser::getBooks() const { + std::vector books; + for (const auto& entry : entries) { + 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) { @@ -90,7 +100,7 @@ void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, cons // Check for entry element (with or without namespace prefix) if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) { self->inEntry = true; - self->currentBook = OpdsBook{}; + self->currentEntry = OpdsEntry{}; return; } @@ -123,16 +133,26 @@ void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, cons return; } - // Check for link element with acquisition rel and epub type + // 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"); - // Look for acquisition link with epub type - if (rel && type && href) { - if (strstr(rel, "opds-spec.org/acquisition") != nullptr && strcmp(type, "application/epub+zip") == 0) { - self->currentBook.epubUrl = 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; + } } } } @@ -143,12 +163,12 @@ void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) { // Check for entry end if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) { - // Only add book if it has required fields - if (!self->currentBook.title.empty() && !self->currentBook.epubUrl.empty()) { - self->books.push_back(self->currentBook); + // 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->currentBook = OpdsBook{}; + self->currentEntry = OpdsEntry{}; return; } @@ -157,7 +177,7 @@ void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) { // Check for title end if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) { if (self->inTitle) { - self->currentBook.title = self->currentText; + self->currentEntry.title = self->currentText; } self->inTitle = false; return; @@ -172,7 +192,7 @@ void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) { // Check for author name end if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) { if (self->inAuthorName) { - self->currentBook.author = self->currentText; + self->currentEntry.author = self->currentText; } self->inAuthorName = false; return; @@ -181,7 +201,7 @@ void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) { // Check for id end if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { if (self->inId) { - self->currentBook.id = self->currentText; + self->currentEntry.id = self->currentText; } self->inId = false; return; diff --git a/lib/OpdsParser/OpdsParser.h b/lib/OpdsParser/OpdsParser.h index 55bd4274..fbaf3f0f 100644 --- a/lib/OpdsParser/OpdsParser.h +++ b/lib/OpdsParser/OpdsParser.h @@ -5,15 +5,27 @@ #include /** - * Represents a book entry from an OPDS feed. + * Type of OPDS entry. */ -struct OpdsBook { +enum class OpdsEntryType { + NAVIGATION, // Link to another catalog + BOOK // Downloadable book +}; + +/** + * Represents an entry from an OPDS feed (either a navigation link or a book). + */ +struct OpdsEntry { + OpdsEntryType type = OpdsEntryType::NAVIGATION; std::string title; - std::string author; - std::string epubUrl; // Relative URL like "/books/get/epub/3/Calibre_Library" + std::string author; // Only for books + std::string href; // Navigation URL or epub download URL std::string id; }; +// Legacy alias for backward compatibility +using OpdsBook = OpdsEntry; + /** * Parser for OPDS (Open Publication Distribution System) Atom feeds. * Uses the Expat XML parser to parse OPDS catalog entries. @@ -21,8 +33,12 @@ struct OpdsBook { * Usage: * OpdsParser parser; * if (parser.parse(xmlData, xmlLength)) { - * for (const auto& book : parser.getBooks()) { - * // Process book entries + * for (const auto& entry : parser.getEntries()) { + * if (entry.type == OpdsEntryType::BOOK) { + * // Downloadable book + * } else { + * // Navigation link to another catalog + * } * } * } */ @@ -44,13 +60,19 @@ class OpdsParser { bool parse(const char* xmlData, size_t length); /** - * Get the parsed books. - * @return Vector of OpdsBook entries + * Get the parsed entries (both navigation and book entries). + * @return Vector of OpdsEntry entries */ - const std::vector& getBooks() const { return books; } + const std::vector& getEntries() const { return entries; } /** - * Clear all parsed books. + * Get only book entries (legacy compatibility). + * @return Vector of book entries + */ + std::vector getBooks() const; + + /** + * Clear all parsed entries. */ void clear(); @@ -64,8 +86,8 @@ class OpdsParser { static const char* findAttribute(const XML_Char** atts, const char* name); XML_Parser parser = nullptr; - std::vector books; - OpdsBook currentBook; + std::vector entries; + OpdsEntry currentEntry; std::string currentText; // Parser state diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index 60a122ad..8a6cfaab 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -11,7 +11,7 @@ namespace { constexpr int PAGE_ITEMS = 23; constexpr int SKIP_PAGE_MS = 700; -constexpr char OPDS_FEED_PATH[] = "/opds"; +constexpr char OPDS_ROOT_PATH[] = "opds"; // No leading slash - relative to server URL // Prepend http:// if no protocol specified (server will redirect to https if needed) std::string ensureProtocol(const std::string& url) { @@ -20,6 +20,39 @@ std::string ensureProtocol(const std::string& url) { } return url; } + +// Extract host with protocol from URL (e.g., "http://example.com" from "http://example.com/path") +std::string extractHost(const std::string& url) { + const size_t protocolEnd = url.find("://"); + if (protocolEnd == std::string::npos) { + // No protocol, find first slash + const size_t firstSlash = url.find('/'); + return firstSlash == std::string::npos ? url : url.substr(0, firstSlash); + } + // Find the first slash after the protocol + const size_t hostStart = protocolEnd + 3; + const size_t pathStart = url.find('/', hostStart); + return pathStart == std::string::npos ? url : url.substr(0, pathStart); +} + +// Build full URL from server URL and path +// If path starts with /, it's an absolute path from the host root +// Otherwise, it's relative to the server URL +std::string buildUrl(const std::string& serverUrl, const std::string& path) { + const std::string urlWithProtocol = ensureProtocol(serverUrl); + if (path.empty()) { + return urlWithProtocol; + } + if (path[0] == '/') { + // Absolute path - use just the host + return extractHost(urlWithProtocol) + path; + } + // Relative path - append to server URL + if (urlWithProtocol.back() == '/') { + return urlWithProtocol + path; + } + return urlWithProtocol + "/" + path; +} } // namespace void OpdsBookBrowserActivity::taskTrampoline(void* param) { @@ -32,7 +65,9 @@ void OpdsBookBrowserActivity::onEnter() { renderingMutex = xSemaphoreCreateMutex(); state = BrowserState::LOADING; - books.clear(); + entries.clear(); + navigationHistory.clear(); + currentPath = OPDS_ROOT_PATH; selectorIndex = 0; errorMessage.clear(); statusMessage = "Loading..."; @@ -45,8 +80,8 @@ void OpdsBookBrowserActivity::onEnter() { &displayTaskHandle // Task handle ); - // Fetch books after setting up the display task - fetchBooks(); + // Fetch feed after setting up the display task + fetchFeed(currentPath); } void OpdsBookBrowserActivity::onExit() { @@ -59,27 +94,28 @@ void OpdsBookBrowserActivity::onExit() { } vSemaphoreDelete(renderingMutex); renderingMutex = nullptr; - books.clear(); + entries.clear(); + navigationHistory.clear(); } void OpdsBookBrowserActivity::loop() { - // Handle error state - Confirm retries, Back goes home + // Handle error state - Confirm retries, Back goes back or home if (state == BrowserState::ERROR) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { state = BrowserState::LOADING; statusMessage = "Loading..."; updateRequired = true; - fetchBooks(); + fetchFeed(currentPath); } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - onGoHome(); + navigateBack(); } return; } - // Handle loading state - only Back goes home + // Handle loading state - only Back works if (state == BrowserState::LOADING) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - onGoHome(); + navigateBack(); } return; } @@ -89,8 +125,8 @@ void OpdsBookBrowserActivity::loop() { return; } - // Handle book list state - if (state == BrowserState::BOOK_LIST) { + // Handle browsing state + if (state == BrowserState::BROWSING) { const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) || mappedInput.wasReleased(MappedInputManager::Button::Left); const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) || @@ -98,23 +134,28 @@ void OpdsBookBrowserActivity::loop() { const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - if (!books.empty()) { - downloadBook(books[selectorIndex]); + if (!entries.empty()) { + const auto& entry = entries[selectorIndex]; + if (entry.type == OpdsEntryType::BOOK) { + downloadBook(entry); + } else { + navigateToEntry(entry); + } } } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - onGoHome(); - } else if (prevReleased && !books.empty()) { + navigateBack(); + } else if (prevReleased && !entries.empty()) { if (skipPage) { - selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + books.size()) % books.size(); + selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + entries.size()) % entries.size(); } else { - selectorIndex = (selectorIndex + books.size() - 1) % books.size(); + selectorIndex = (selectorIndex + entries.size() - 1) % entries.size(); } updateRequired = true; - } else if (nextReleased && !books.empty()) { + } else if (nextReleased && !entries.empty()) { if (skipPage) { - selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % books.size(); + selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % entries.size(); } else { - selectorIndex = (selectorIndex + 1) % books.size(); + selectorIndex = (selectorIndex + 1) % entries.size(); } updateRequired = true; } @@ -171,12 +212,17 @@ void OpdsBookBrowserActivity::render() const { return; } - // Book list state - const auto labels = mappedInput.mapLabels("« Back", "Download", "", ""); + // Browsing state + // Show appropriate button hint based on selected entry type + const char* confirmLabel = "Open"; + if (!entries.empty() && entries[selectorIndex].type == OpdsEntryType::BOOK) { + confirmLabel = "Download"; + } + const auto labels = mappedInput.mapLabels("« Back", confirmLabel, "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - if (books.empty()) { - renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "No books found"); + if (entries.empty()) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "No entries found"); renderer.displayBuffer(); return; } @@ -184,11 +230,19 @@ void OpdsBookBrowserActivity::render() const { 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 < books.size() && i < static_cast(pageStartIndex + PAGE_ITEMS); i++) { - // Format: "Title - Author" or just "Title" if no author - std::string displayText = books[i].title; - if (!books[i].author.empty()) { - displayText += " - " + books[i].author; + 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; + } } auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), renderer.getScreenWidth() - 40); @@ -198,7 +252,7 @@ void OpdsBookBrowserActivity::render() const { renderer.displayBuffer(); } -void OpdsBookBrowserActivity::fetchBooks() { +void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { const char* serverUrl = SETTINGS.opdsServerUrl; if (strlen(serverUrl) == 0) { state = BrowserState::ERROR; @@ -207,7 +261,7 @@ void OpdsBookBrowserActivity::fetchBooks() { return; } - std::string url = ensureProtocol(serverUrl) + OPDS_FEED_PATH; + std::string url = buildUrl(serverUrl, path); Serial.printf("[%lu] [OPDS] Fetching: %s\n", millis(), url.c_str()); std::string content; @@ -226,21 +280,54 @@ void OpdsBookBrowserActivity::fetchBooks() { return; } - books = parser.getBooks(); + entries = parser.getEntries(); selectorIndex = 0; - if (books.empty()) { + if (entries.empty()) { state = BrowserState::ERROR; - errorMessage = "No books found"; + errorMessage = "No entries found"; updateRequired = true; return; } - state = BrowserState::BOOK_LIST; + state = BrowserState::BROWSING; updateRequired = true; } -void OpdsBookBrowserActivity::downloadBook(const OpdsBook& book) { +void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) { + // Push current path to history before navigating + navigationHistory.push_back(currentPath); + currentPath = entry.href; + + state = BrowserState::LOADING; + statusMessage = "Loading..."; + entries.clear(); + selectorIndex = 0; + updateRequired = 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 = "Loading..."; + entries.clear(); + selectorIndex = 0; + updateRequired = true; + + fetchFeed(currentPath); + } +} + +void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { state = BrowserState::DOWNLOADING; statusMessage = book.title; downloadProgress = 0; @@ -248,7 +335,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsBook& book) { updateRequired = true; // Build full download URL - std::string downloadUrl = ensureProtocol(SETTINGS.opdsServerUrl) + book.epubUrl; + std::string downloadUrl = buildUrl(SETTINGS.opdsServerUrl, book.href); // Create sanitized filename std::string filename = "/" + sanitizeFilename(book.title) + ".epub"; @@ -264,7 +351,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsBook& book) { if (result == HttpDownloader::OK) { Serial.printf("[%lu] [OPDS] Download complete: %s\n", millis(), filename.c_str()); - state = BrowserState::BOOK_LIST; + state = BrowserState::BROWSING; updateRequired = true; } else { state = BrowserState::ERROR; diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h index aee43ae4..197ff5e4 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.h +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -12,13 +12,13 @@ /** * Activity for browsing and downloading books from an OPDS server. - * Displays a list of available books and allows downloading EPUBs. + * Supports navigation through catalog hierarchy and downloading EPUBs. */ class OpdsBookBrowserActivity final : public Activity { public: enum class BrowserState { LOADING, // Fetching OPDS feed - BOOK_LIST, // Displaying books + BROWSING, // Displaying entries (navigation or books) DOWNLOADING, // Downloading selected EPUB ERROR // Error state with message }; @@ -37,7 +37,9 @@ class OpdsBookBrowserActivity final : public Activity { bool updateRequired = false; BrowserState state = BrowserState::LOADING; - std::vector books; + std::vector entries; + std::vector navigationHistory; // Stack of previous feed paths for back navigation + std::string currentPath; // Current feed path being displayed int selectorIndex = 0; std::string errorMessage; std::string statusMessage; @@ -50,7 +52,9 @@ class OpdsBookBrowserActivity final : public Activity { [[noreturn]] void displayTaskLoop(); void render() const; - void fetchBooks(); - void downloadBook(const OpdsBook& book); + void fetchFeed(const std::string& path); + void navigateToEntry(const OpdsEntry& entry); + void navigateBack(); + void downloadBook(const OpdsEntry& book); std::string sanitizeFilename(const std::string& title) const; };