diff --git a/lib/OpdsParser/OpdsParser.cpp b/lib/OpdsParser/OpdsParser.cpp index 45d92eaa..4b40b77f 100644 --- a/lib/OpdsParser/OpdsParser.cpp +++ b/lib/OpdsParser/OpdsParser.cpp @@ -1,9 +1,81 @@ #include "OpdsParser.h" +#include #include #include +namespace { +// Returns the length of href after trimming trailing slashes. +size_t trimmedHrefLength(const char* href) { + size_t len = strlen(href); + while (len > 0 && href[len - 1] == '/') { + len--; + } + return len; +} + +std::string_view trimmedHrefView(const char* href) { return std::string_view{href, trimmedHrefLength(href)}; } + +// Returns an OpdsAcquisitionLink if the type and href correspond to a supported +// acquisition format, otherwise returns an empty OpdsAcquisitionLink. +OpdsAcquisitionLink supportedAcquisitionLink(const char* type, const char* href) { + if (!type || !href || type[0] == '\0' || href[0] == '\0') { + return {}; + } + + // Some OPDS feeds append a trailing slash to format URLs like + // `/opds/book/123/kepub/`. Trim it so suffix checks work on the final segment. + const std::string_view trimmedHref = trimmedHrefView(href); + + if (strcmp(type, "application/epub+zip") == 0) { + if (FsHelpers::checkFileExtension(trimmedHref, ".kepub.epub")) { + return {href, type, "kepub", ".kepub.epub"}; + } + + // Calibre-Web-Automated uses trailing path segments like `/kepub/` instead of + // filename extensions, so match `/kepub` after trimming trailing slashes. + if (FsHelpers::checkFileExtension(trimmedHref, ".kepub") || FsHelpers::checkFileExtension(trimmedHref, "/kepub")) { + // Save bare KePub downloads with an `.epub` suffix so the existing ePub + // reader can open them. + return {href, type, "kepub", ".kepub.epub"}; + } + return {href, type, "epub", ".epub"}; + } + + if (strcmp(type, "text/plain") == 0) { + return {href, type, "txt", ".txt"}; + } + + if (strcmp(type, "text/markdown") == 0 || strcmp(type, "text/x-markdown") == 0) { + return {href, type, "md", ".md"}; + } + + if (FsHelpers::checkFileExtension(trimmedHref, ".xtc")) { + return {href, "application/vnd.xteink.xtc", "xtc", ".xtc"}; + } + + if (FsHelpers::checkFileExtension(trimmedHref, ".xth") || FsHelpers::checkFileExtension(trimmedHref, ".xtch")) { + return {href, "application/vnd.xteink.xtch", "xtch", ".xtch"}; + } + + return {}; +} + +// Determine if the given OpdsEntry's acquisition link href is already present, +// to prevent duplicate download targets. +bool hasEquivalentAcquisitionLink(const OpdsEntry& entry, const OpdsAcquisitionLink& candidate) { + const std::string_view normalizedCandidateHref = trimmedHrefView(candidate.href.c_str()); + for (const auto& link : entry.acquisitionLinks) { + if (trimmedHrefView(link.href.c_str()) == normalizedCandidateHref) { + return true; + } + } + + return false; +} +} // namespace + OpdsParser::OpdsParser() { parser = XML_ParserCreate(nullptr); if (!parser) { @@ -116,10 +188,18 @@ void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, cons } 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; + if (rel && strstr(rel, "opds-spec.org/acquisition") != nullptr) { + const auto acquisition = supportedAcquisitionLink(type, href); + if (!acquisition.formatKey.empty() && !hasEquivalentAcquisitionLink(self->currentEntry, acquisition)) { + self->currentEntry.type = OpdsEntryType::BOOK; + if (self->currentEntry.acquisitionLinks.empty()) { + self->currentEntry.href = href; + } else if (self->currentEntry.acquisitionLinks.size() == 1 && + self->currentEntry.acquisitionLinks.capacity() < 3) { + self->currentEntry.acquisitionLinks.reserve(3); + } + self->currentEntry.acquisitionLinks.push_back(acquisition); + } } else if (type && strstr(type, "application/atom+xml") != nullptr) { if (self->currentEntry.type != OpdsEntryType::BOOK) { self->currentEntry.type = OpdsEntryType::NAVIGATION; diff --git a/lib/OpdsParser/OpdsParser.h b/lib/OpdsParser/OpdsParser.h index 5f287495..e97489c7 100644 --- a/lib/OpdsParser/OpdsParser.h +++ b/lib/OpdsParser/OpdsParser.h @@ -13,6 +13,13 @@ enum class OpdsEntryType { BOOK // Downloadable book }; +struct OpdsAcquisitionLink { + std::string href; + std::string mimeType; + std::string formatKey; + std::string fileExtension; +}; + /** * Represents an entry from an OPDS feed (either a navigation link or a book). */ @@ -22,6 +29,7 @@ struct OpdsEntry { std::string author; // Only for books std::string href; // Navigation URL or epub download URL std::string id; + std::vector acquisitionLinks; }; // Legacy alias for backward compatibility diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index bee5a10a..95de67af 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -15,6 +16,7 @@ #include "CrossPointSettings.h" #include "MappedInputManager.h" +#include "OpdsFormatLabel.h" #include "activities/network/WifiSelectionActivity.h" #include "activities/util/KeyboardEntryActivity.h" #include "components/UITheme.h" @@ -25,7 +27,20 @@ namespace { constexpr int PAGE_ITEMS = 23; +constexpr int FORMAT_ITEM_HEIGHT = 30; +constexpr int FORMAT_LIST_TOP_OFFSET = 20; +constexpr int FORMAT_LIST_BOTTOM_PADDING = 20; + +int formatItemsPerPage(const Rect& contentRect) { + const int midY = contentRect.y + contentRect.height / 2; + const int listTop = midY + FORMAT_LIST_TOP_OFFSET; + const int listBottom = contentRect.y + contentRect.height - FORMAT_LIST_BOTTOM_PADDING; + const int rawAvailableHeight = listBottom - listTop; + const int availableHeight = rawAvailableHeight > FORMAT_ITEM_HEIGHT ? rawAvailableHeight : FORMAT_ITEM_HEIGHT; + const int itemsPerPage = availableHeight / FORMAT_ITEM_HEIGHT; + return itemsPerPage > 0 ? itemsPerPage : 1; } +} // namespace void OpdsBookBrowserActivity::onEnter() { Activity::onEnter(); @@ -36,6 +51,9 @@ void OpdsBookBrowserActivity::onEnter() { currentPath = ""; // Root path - user provides full URL in settings searchTemplate.clear(); selectorIndex = 0; + selectedBookIndex = -1; + formatSelectorIndex = 0; + formatSelectionLabels.clear(); consumeConfirm = false; consumeBack = false; errorMessage.clear(); @@ -52,6 +70,7 @@ void OpdsBookBrowserActivity::onExit() { entries.clear(); navigationHistory.clear(); + formatSelectionLabels.clear(); } void OpdsBookBrowserActivity::loop() { @@ -93,11 +112,51 @@ void OpdsBookBrowserActivity::loop() { if (state == BrowserState::DOWNLOADING) return; + if (state == BrowserState::FORMAT_SELECTION) { + if (selectedBookIndex < 0 || selectedBookIndex >= static_cast(entries.size())) { + state = BrowserState::BROWSING; + requestUpdate(); + return; + } + + const auto& entry = entries[selectedBookIndex]; + if (entry.acquisitionLinks.empty()) { + state = BrowserState::BROWSING; + selectedBookIndex = -1; + formatSelectionLabels.clear(); + requestUpdate(); + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + state = BrowserState::BROWSING; + selectedBookIndex = -1; + formatSelectionLabels.clear(); + requestUpdate(); + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + downloadBook(entry, entry.acquisitionLinks[formatSelectorIndex]); + return; + } + + buttonNavigator.onNextRelease([this, &entry] { + formatSelectorIndex = ButtonNavigator::nextIndex(formatSelectorIndex, entry.acquisitionLinks.size()); + requestUpdate(); + }); + buttonNavigator.onPreviousRelease([this, &entry] { + formatSelectorIndex = ButtonNavigator::previousIndex(formatSelectorIndex, entry.acquisitionLinks.size()); + requestUpdate(); + }); + return; + } + if (state == BrowserState::BROWSING) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (!entries.empty()) { const auto& entry = entries[selectorIndex]; - entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry); + entry.type == OpdsEntryType::BOOK ? chooseBookFormat(entry) : navigateToEntry(entry); } } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { navigateBack(); @@ -175,6 +234,34 @@ void OpdsBookBrowserActivity::render(RenderLock&&) { return; } + if (state == BrowserState::FORMAT_SELECTION) { + const auto& entry = entries[selectedBookIndex]; + auto title = renderer.truncatedText(UI_10_FONT_ID, entry.title.c_str(), contentRect.width - 40); + renderer.drawCenteredText(UI_10_FONT_ID, midY - 40, title.c_str(), true, EpdFontFamily::BOLD); + if (!entry.author.empty()) { + auto author = renderer.truncatedText(UI_10_FONT_ID, entry.author.c_str(), contentRect.width - 40); + renderer.drawCenteredText(UI_10_FONT_ID, midY - 10, author.c_str()); + } + + const int listTop = midY + 20; + const int itemsPerPage = formatItemsPerPage(contentRect); + const int pageStartIndex = formatSelectorIndex / itemsPerPage * itemsPerPage; + renderer.fillRect(contentRect.x, listTop + (formatSelectorIndex - pageStartIndex) * FORMAT_ITEM_HEIGHT - 2, + contentRect.width - 1, FORMAT_ITEM_HEIGHT); + for (int i = pageStartIndex; + i < static_cast(entry.acquisitionLinks.size()) && i < pageStartIndex + itemsPerPage; i++) { + const char* label = i < static_cast(formatSelectionLabels.size()) ? formatSelectionLabels[i].c_str() : ""; + auto item = renderer.truncatedText(UI_10_FONT_ID, label, contentRect.width - 40); + renderer.drawText(UI_10_FONT_ID, contentRect.x + 20, listTop + (i - pageStartIndex) * FORMAT_ITEM_HEIGHT, + item.c_str(), i != formatSelectorIndex); + } + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_DOWNLOAD), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + // Browsing state // Show appropriate button hint based on selected entry type const char* confirmLabel = @@ -292,17 +379,43 @@ void OpdsBookBrowserActivity::navigateBack() { } } -void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { +// Opens a screen to allow the user to choose which format they want to download +// if multiple formats are available. If only one format is available, the +// download is started immediately. +void OpdsBookBrowserActivity::chooseBookFormat(const OpdsEntry& book) { + if (book.acquisitionLinks.empty()) { + state = BrowserState::ERROR; + errorMessage = tr(STR_DOWNLOAD_FAILED); + requestUpdate(); + return; + } + + if (book.acquisitionLinks.size() == 1) { + downloadBook(book, book.acquisitionLinks[0]); + return; + } + + selectedBookIndex = selectorIndex; + formatSelectorIndex = 0; + formatSelectionLabels = buildOpdsFormatSelectionLabels(book.acquisitionLinks, SETTINGS.opdsServerUrl); + state = BrowserState::FORMAT_SELECTION; + requestUpdate(); +} + +// Downloads the selected book's acquisition link and saves it to the SD card. +void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book, const OpdsAcquisitionLink& acquisition) { state = BrowserState::DOWNLOADING; statusMessage = book.title; downloadProgress = 0; downloadTotal = 0; requestUpdate(true); - std::string downloadUrl = - (book.href.rfind("http", 0) == 0) ? book.href : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href); - std::string filename = - "/" + StringUtils::sanitizeFilename(book.title + (book.author.empty() ? "" : " - " + book.author)) + ".epub"; + std::string downloadUrl = (acquisition.href.rfind("http", 0) == 0) + ? acquisition.href + : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, acquisition.href); + std::string filename = "/" + + StringUtils::sanitizeFilename(book.title + (book.author.empty() ? "" : " - " + book.author)) + + acquisition.fileExtension; LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str()); @@ -330,10 +443,20 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { LOG_DBG("OPDS", "Download complete: %s", filename.c_str()); - Epub(filename, "/.crosspoint").clearCache(); + // Clear any existing cache for this book just in case it's a redownload of + // a previously opened book. + if (acquisition.mimeType == "application/epub+zip") { + Epub(filename, "/.crosspoint").clearCache(); + } else if (acquisition.formatKey == "xtc" || acquisition.formatKey == "xtch") { + Xtc(filename, "/.crosspoint").clearCache(); + } + selectedBookIndex = -1; + formatSelectionLabels.clear(); state = BrowserState::BROWSING; requestUpdate(); } else { + selectedBookIndex = -1; + formatSelectionLabels.clear(); state = BrowserState::ERROR; errorMessage = tr(STR_DOWNLOAD_FAILED); requestUpdate(); diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h index b1fc3d01..717bf54d 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.h +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -1,7 +1,6 @@ #pragma once #include -#include #include #include @@ -14,7 +13,16 @@ */ class OpdsBookBrowserActivity final : public Activity { public: - enum class BrowserState { CHECK_WIFI, WIFI_SELECTION, LOADING, BROWSING, DOWNLOADING, ERROR, SEARCH_INPUT }; + enum class BrowserState { + CHECK_WIFI, + WIFI_SELECTION, + LOADING, + BROWSING, + FORMAT_SELECTION, + DOWNLOADING, + ERROR, + SEARCH_INPUT + }; explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) : Activity("OpdsBookBrowser", renderer, mappedInput), buttonNavigator() {} @@ -34,6 +42,9 @@ class OpdsBookBrowserActivity final : public Activity { bool consumeConfirm = false; bool consumeBack = false; // Added missing member int selectorIndex = 0; + int selectedBookIndex = -1; + int formatSelectorIndex = 0; + std::vector formatSelectionLabels; std::string errorMessage; std::string statusMessage; size_t downloadProgress = 0; @@ -45,7 +56,8 @@ class OpdsBookBrowserActivity final : public Activity { void fetchFeed(const std::string& path); void navigateToEntry(const OpdsEntry& entry); void navigateBack(); - void downloadBook(const OpdsEntry& book); + void downloadBook(const OpdsEntry& book, const OpdsAcquisitionLink& acquisition); + void chooseBookFormat(const OpdsEntry& book); void fetchOsdTemplate(const std::string& osdUrl); void launchSearch(); void performSearch(const std::string& query); diff --git a/src/activities/browser/OpdsFormatLabel.cpp b/src/activities/browser/OpdsFormatLabel.cpp new file mode 100644 index 00000000..fe72c2d0 --- /dev/null +++ b/src/activities/browser/OpdsFormatLabel.cpp @@ -0,0 +1,122 @@ +#include "OpdsFormatLabel.h" + +#include +#include + +#include "util/UrlUtils.h" + +namespace { +bool hasDuplicateFormatKey(const OpdsAcquisitionLink& acquisition, + const std::vector& acquisitionLinks) { + size_t sameFormatCount = 0; + for (const auto& link : acquisitionLinks) { + if (link.formatKey == acquisition.formatKey) { + sameFormatCount++; + if (sameFormatCount > 1) { + return true; + } + } + } + + return false; +} + +std::string resolvedHostname(const OpdsAcquisitionLink& acquisition, const std::string& serverUrl) { + const std::string resolvedUrl = + acquisition.href.rfind("http", 0) == 0 ? acquisition.href : UrlUtils::buildUrl(serverUrl, acquisition.href); + return UrlUtils::extractHostname(resolvedUrl); +} + +std::vector resolvedHostnames(const std::vector& acquisitionLinks, + const std::string& serverUrl) { + std::vector hostnames; + hostnames.reserve(acquisitionLinks.size()); + std::transform(acquisitionLinks.begin(), acquisitionLinks.end(), std::back_inserter(hostnames), + [&serverUrl](const OpdsAcquisitionLink& link) { return resolvedHostname(link, serverUrl); }); + return hostnames; +} + +std::string buildDuplicateAwareLabel(const OpdsAcquisitionLink& acquisition, + const std::vector& acquisitionLinks, + const std::vector& hostnames, const size_t currentIndex) { + std::string label = opdsBaseFormatLabel(acquisition); + if (!hasDuplicateFormatKey(acquisition, acquisitionLinks)) { + return label; + } + + const std::string& hostname = hostnames[currentIndex]; + if (hostname.empty()) { + return label; + } + + label.reserve(label.size() + hostname.size() + 8); + label += " - "; + label += hostname; + + size_t duplicateCount = 0; + size_t duplicateIndex = 0; + for (size_t i = 0; i < acquisitionLinks.size(); i++) { + if (acquisitionLinks[i].formatKey == acquisition.formatKey && hostnames[i] == hostname) { + duplicateCount++; + if (i <= currentIndex) { + duplicateIndex++; + } + } + } + + if (duplicateCount > 1) { + label += " ("; + label += std::to_string(duplicateIndex); + label += ")"; + } + + return label; +} +} // namespace + +const char* opdsBaseFormatLabel(const OpdsAcquisitionLink& acquisition) { + if (acquisition.formatKey == "kepub") { + return "KEPUB"; + } + if (acquisition.formatKey == "epub") { + return "EPUB"; + } + if (acquisition.formatKey == "txt") { + return "TXT"; + } + if (acquisition.formatKey == "md") { + return "MD"; + } + if (acquisition.formatKey == "xtc") { + return "XTC"; + } + if (acquisition.formatKey == "xtch") { + return "XTCH"; + } + + return ""; +} + +std::string opdsFormatSelectionLabel(const OpdsAcquisitionLink& acquisition, + const std::vector& acquisitionLinks, + const std::string& serverUrl) { + const auto hostnames = resolvedHostnames(acquisitionLinks, serverUrl); + for (size_t i = 0; i < acquisitionLinks.size(); i++) { + if (acquisitionLinks[i].href == acquisition.href && acquisitionLinks[i].formatKey == acquisition.formatKey) { + return buildDuplicateAwareLabel(acquisition, acquisitionLinks, hostnames, i); + } + } + + return opdsBaseFormatLabel(acquisition); +} + +std::vector buildOpdsFormatSelectionLabels(const std::vector& acquisitionLinks, + const std::string& serverUrl) { + const auto hostnames = resolvedHostnames(acquisitionLinks, serverUrl); + std::vector labels; + labels.reserve(acquisitionLinks.size()); + for (size_t i = 0; i < acquisitionLinks.size(); i++) { + labels.push_back(buildDuplicateAwareLabel(acquisitionLinks[i], acquisitionLinks, hostnames, i)); + } + return labels; +} diff --git a/src/activities/browser/OpdsFormatLabel.h b/src/activities/browser/OpdsFormatLabel.h new file mode 100644 index 00000000..af0bee45 --- /dev/null +++ b/src/activities/browser/OpdsFormatLabel.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +#include +#include + +const char* opdsBaseFormatLabel(const OpdsAcquisitionLink& acquisition); +std::string opdsFormatSelectionLabel(const OpdsAcquisitionLink& acquisition, + const std::vector& acquisitionLinks, + const std::string& serverUrl); +std::vector buildOpdsFormatSelectionLabels(const std::vector& acquisitionLinks, + const std::string& serverUrl); diff --git a/test/opds_format_label/OpdsFormatLabelTest.cpp b/test/opds_format_label/OpdsFormatLabelTest.cpp new file mode 100644 index 00000000..02830d9b --- /dev/null +++ b/test/opds_format_label/OpdsFormatLabelTest.cpp @@ -0,0 +1,118 @@ +#include +#include +#include + +#include "../../src/activities/browser/OpdsFormatLabel.h" + +static int testsPassed = 0; +static int testsFailed = 0; + +#define ASSERT_TRUE(cond) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, " FAIL: %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + testsFailed++; \ + return; \ + } \ + } while (0) + +#define ASSERT_EQ(a, b) \ + do { \ + if ((a) != (b)) { \ + fprintf(stderr, " FAIL: %s:%d: %s != %s\n", __FILE__, __LINE__, #a, #b); \ + testsFailed++; \ + return; \ + } \ + } while (0) + +#define PASS() testsPassed++ + +namespace { +OpdsAcquisitionLink makeLink(const char* href, const char* formatKey) { + return OpdsAcquisitionLink{href, "application/epub+zip", formatKey, ".epub"}; +} +} // namespace + +void testUniqueFormatUsesBaseLabel() { + printf("testUniqueFormatUsesBaseLabel...\n"); + const auto link = makeLink("/books/example.epub", "epub"); + const std::vector links{link}; + + ASSERT_EQ(opdsFormatSelectionLabel(link, links, "catalog.example.com"), "EPUB"); + PASS(); +} + +void testDuplicateAbsoluteUrlsIncludeHostname() { + printf("testDuplicateAbsoluteUrlsIncludeHostname...\n"); + const auto primary = makeLink("https://mirror-a.example.com/books/example.epub", "epub"); + const auto secondary = makeLink("https://mirror-b.example.com/books/example.epub", "epub"); + const std::vector links{primary, secondary}; + + ASSERT_EQ(opdsFormatSelectionLabel(primary, links, "catalog.example.com"), "EPUB - mirror-a.example.com"); + ASSERT_EQ(opdsFormatSelectionLabel(secondary, links, "catalog.example.com"), "EPUB - mirror-b.example.com"); + PASS(); +} + +void testDuplicateRootRelativeUrlsUseServerHostname() { + printf("testDuplicateRootRelativeUrlsUseServerHostname...\n"); + const auto primary = makeLink("/opds/download/1/epub", "epub"); + const auto secondary = makeLink("/opds/download/2/epub", "epub"); + const std::vector links{primary, secondary}; + + ASSERT_EQ(opdsFormatSelectionLabel(primary, links, "https://catalog.example.com/opds"), + "EPUB - catalog.example.com (1)"); + ASSERT_EQ(opdsFormatSelectionLabel(secondary, links, "https://catalog.example.com/opds"), + "EPUB - catalog.example.com (2)"); + PASS(); +} + +void testDuplicateRelativeUrlsUseServerHostname() { + printf("testDuplicateRelativeUrlsUseServerHostname...\n"); + const auto primary = makeLink("download/1.epub", "epub"); + const auto secondary = makeLink("download/2.epub", "epub"); + const std::vector links{primary, secondary}; + + ASSERT_EQ(opdsFormatSelectionLabel(primary, links, "catalog.example.com/opds"), "EPUB - catalog.example.com (1)"); + ASSERT_EQ(opdsFormatSelectionLabel(secondary, links, "catalog.example.com/opds"), "EPUB - catalog.example.com (2)"); + PASS(); +} + +void testDuplicateAbsoluteUrlsSameHostnameIncludeNumbering() { + printf("testDuplicateAbsoluteUrlsSameHostnameIncludeNumbering...\n"); + const auto primary = makeLink("https://mirror.example.com/books/example.epub", "epub"); + const auto secondary = makeLink("https://mirror.example.com/books/example-copy.epub", "epub"); + const std::vector links{primary, secondary}; + + ASSERT_EQ(opdsFormatSelectionLabel(primary, links, "catalog.example.com"), "EPUB - mirror.example.com (1)"); + ASSERT_EQ(opdsFormatSelectionLabel(secondary, links, "catalog.example.com"), "EPUB - mirror.example.com (2)"); + PASS(); +} + +void testBatchLabelBuilderMatchesPerLinkLabels() { + printf("testBatchLabelBuilderMatchesPerLinkLabels...\n"); + const auto first = makeLink("https://mirror.example.com/books/example.epub", "epub"); + const auto second = makeLink("https://mirror.example.com/books/example-copy.epub", "epub"); + const auto third = makeLink("/books/example.txt", "txt"); + const std::vector links{first, second, third}; + + const auto labels = buildOpdsFormatSelectionLabels(links, "https://catalog.example.com/opds"); + ASSERT_EQ(labels.size(), static_cast(3)); + ASSERT_EQ(labels[0], opdsFormatSelectionLabel(first, links, "https://catalog.example.com/opds")); + ASSERT_EQ(labels[1], opdsFormatSelectionLabel(second, links, "https://catalog.example.com/opds")); + ASSERT_EQ(labels[2], opdsFormatSelectionLabel(third, links, "https://catalog.example.com/opds")); + PASS(); +} + +int main() { + printf("=== OPDS Format Label Tests ===\n\n"); + + testUniqueFormatUsesBaseLabel(); + testDuplicateAbsoluteUrlsIncludeHostname(); + testDuplicateRootRelativeUrlsUseServerHostname(); + testDuplicateRelativeUrlsUseServerHostname(); + testDuplicateAbsoluteUrlsSameHostnameIncludeNumbering(); + testBatchLabelBuilderMatchesPerLinkLabels(); + + printf("\n=== Results: %d passed, %d failed ===\n", testsPassed, testsFailed); + return testsFailed > 0 ? 1 : 0; +} diff --git a/test/opds_parser/OpdsParserTest.cpp b/test/opds_parser/OpdsParserTest.cpp new file mode 100644 index 00000000..d0cb0b3b --- /dev/null +++ b/test/opds_parser/OpdsParserTest.cpp @@ -0,0 +1,333 @@ +#include + +#include +#include +#include +#include + +static int testsPassed = 0; +static int testsFailed = 0; + +#define ASSERT_TRUE(cond) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, " FAIL: %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + testsFailed++; \ + return; \ + } \ + } while (0) + +#define ASSERT_EQ(a, b) \ + do { \ + if ((a) != (b)) { \ + fprintf(stderr, " FAIL: %s:%d: %s != %s\n", __FILE__, __LINE__, #a, #b); \ + testsFailed++; \ + return; \ + } \ + } while (0) + +#define ASSERT_SIZE(a, b) \ + do { \ + if ((a) != (b)) { \ + fprintf(stderr, " FAIL: %s:%d: %s == %zu, expected %zu\n", __FILE__, __LINE__, #a, static_cast(a), \ + static_cast(b)); \ + testsFailed++; \ + return; \ + } \ + } while (0) + +#define PASS() testsPassed++ + +namespace { +bool parseSingleBookEntry(OpdsEntry& entryOut, const char* href, const char* type = "application/epub+zip") { + const std::string xml = std::string(R"( + + + Example Book + Example Author + book-1 + + +)"; + + OpdsParser parser; + parser.write(reinterpret_cast(xml.data()), xml.size()); + parser.flush(); + + if (parser.error()) { + fprintf(stderr, " FAIL: %s:%d: parser.error()\n", __FILE__, __LINE__); + testsFailed++; + return false; + } + const auto& entries = parser.getEntries(); + if (entries.size() != 1) { + fprintf(stderr, " FAIL: %s:%d: entries.size() == %zu, expected 1\n", __FILE__, __LINE__, entries.size()); + testsFailed++; + return false; + } + entryOut = entries.front(); + return true; +} + +bool assertSingleFormat(const OpdsEntry& entry, const char* formatKey, const char* fileExtension) { + if (entry.type != OpdsEntryType::BOOK) { + fprintf(stderr, " FAIL: %s:%d: entry.type != OpdsEntryType::BOOK\n", __FILE__, __LINE__); + testsFailed++; + return false; + } + if (entry.acquisitionLinks.size() != 1) { + fprintf(stderr, " FAIL: %s:%d: entry.acquisitionLinks.size() == %zu, expected 1\n", __FILE__, __LINE__, + entry.acquisitionLinks.size()); + testsFailed++; + return false; + } + if (entry.acquisitionLinks[0].formatKey != formatKey) { + fprintf(stderr, " FAIL: %s:%d: formatKey actual='%s' expected='%s'\n", __FILE__, __LINE__, + entry.acquisitionLinks[0].formatKey.c_str(), formatKey); + testsFailed++; + return false; + } + if (entry.acquisitionLinks[0].fileExtension != fileExtension) { + fprintf(stderr, " FAIL: %s:%d: fileExtension actual='%s' expected='%s'\n", __FILE__, __LINE__, + entry.acquisitionLinks[0].fileExtension.c_str(), fileExtension); + testsFailed++; + return false; + } + return true; +} +} // namespace + +void testEpubExtension() { + printf("testEpubExtension...\n"); + OpdsEntry entry; + if (!parseSingleBookEntry(entry, "/books/example.epub")) return; + ASSERT_TRUE(assertSingleFormat(entry, "epub", ".epub")); + PASS(); +} + +void testKepubDoubleExtension() { + printf("testKepubDoubleExtension...\n"); + OpdsEntry entry; + if (!parseSingleBookEntry(entry, "/books/example.kepub.epub")) return; + ASSERT_TRUE(assertSingleFormat(entry, "kepub", ".kepub.epub")); + PASS(); +} + +void testBareKepubExtension() { + printf("testBareKepubExtension...\n"); + OpdsEntry entry; + if (!parseSingleBookEntry(entry, "/books/example.kepub")) return; + ASSERT_TRUE(assertSingleFormat(entry, "kepub", ".kepub.epub")); + PASS(); +} + +void testSlashTerminatedKepubPath() { + printf("testSlashTerminatedKepubPath...\n"); + OpdsEntry entry; + if (!parseSingleBookEntry(entry, "/opds/download/6516/kepub/")) return; + ASSERT_TRUE(assertSingleFormat(entry, "kepub", ".kepub.epub")); + PASS(); +} + +void testSlashTerminatedEpubPath() { + printf("testSlashTerminatedEpubPath...\n"); + OpdsEntry entry; + if (!parseSingleBookEntry(entry, "/opds/download/6516/epub/")) return; + ASSERT_TRUE(assertSingleFormat(entry, "epub", ".epub")); + PASS(); +} + +void testDistinctAcquisitionFormatsRemainSeparate() { + printf("testDistinctAcquisitionFormatsRemainSeparate...\n"); + const char* xml = R"( + + + Example Book + Example Author + book-2 + + + + + +)"; + + OpdsParser parser; + parser.write(reinterpret_cast(xml), strlen(xml)); + parser.flush(); + + ASSERT_TRUE(!parser.error()); + const auto& entries = parser.getEntries(); + ASSERT_SIZE(entries.size(), 1); + const auto& links = entries.front().acquisitionLinks; + ASSERT_SIZE(links.size(), 4); + ASSERT_EQ(links[0].formatKey, "epub"); + ASSERT_EQ(links[1].formatKey, "kepub"); + ASSERT_EQ(links[2].formatKey, "txt"); + ASSERT_EQ(links[3].formatKey, "md"); + PASS(); +} + +void testUnsupportedMimeType() { + printf("testUnsupportedMimeType...\n"); + const char* xml = R"( + + + Example Book + Example Author + book-3 + + +)"; + + OpdsParser parser; + parser.write(reinterpret_cast(xml), strlen(xml)); + parser.flush(); + + ASSERT_TRUE(!parser.error()); + ASSERT_SIZE(parser.getEntries().size(), 0); + PASS(); +} + +void testEmptyHrefOrType() { + printf("testEmptyHrefOrType...\n"); + const char* xml = R"( + + + Empty Href + Example Author + book-4 + + + + Empty Type + Example Author + book-5 + + + + Missing Href + Example Author + book-6 + + + + Missing Type + Example Author + book-7 + + +)"; + + OpdsParser parser; + parser.write(reinterpret_cast(xml), strlen(xml)); + parser.flush(); + + ASSERT_TRUE(!parser.error()); + const auto& entries = parser.getEntries(); + ASSERT_SIZE(entries.size(), 0); + PASS(); +} + +void testDuplicateAcquisitionLinks() { + printf("testDuplicateAcquisitionLinks...\n"); + const char* xml = R"( + + + Example Book + Example Author + book-8 + + + +)"; + + OpdsParser parser; + parser.write(reinterpret_cast(xml), strlen(xml)); + parser.flush(); + + ASSERT_TRUE(!parser.error()); + const auto& entries = parser.getEntries(); + ASSERT_SIZE(entries.size(), 1); + const auto& links = entries.front().acquisitionLinks; + ASSERT_SIZE(links.size(), 2); + ASSERT_EQ(links[0].formatKey, "epub"); + ASSERT_EQ(links[0].href, "/books/example.epub"); + ASSERT_EQ(links[1].formatKey, "epub"); + ASSERT_EQ(links[1].href, "/books/example-copy.epub"); + PASS(); +} + +void testIdenticalHrefAcquisitionLinksAreDeduplicated() { + printf("testIdenticalHrefAcquisitionLinksAreDeduplicated...\n"); + const char* xml = R"( + + + Example Book + Example Author + book-9 + + + +)"; + + OpdsParser parser; + parser.write(reinterpret_cast(xml), strlen(xml)); + parser.flush(); + + ASSERT_TRUE(!parser.error()); + const auto& entries = parser.getEntries(); + ASSERT_SIZE(entries.size(), 1); + const auto& links = entries.front().acquisitionLinks; + ASSERT_SIZE(links.size(), 1); + ASSERT_EQ(links[0].formatKey, "epub"); + ASSERT_EQ(links[0].href, "/books/example.epub"); + PASS(); +} + +void testSlashVariantHrefAcquisitionLinksAreDeduplicated() { + printf("testSlashVariantHrefAcquisitionLinksAreDeduplicated...\n"); + const char* xml = R"( + + + Example Book + Example Author + book-10 + + + +)"; + + OpdsParser parser; + parser.write(reinterpret_cast(xml), strlen(xml)); + parser.flush(); + + ASSERT_TRUE(!parser.error()); + const auto& entries = parser.getEntries(); + ASSERT_SIZE(entries.size(), 1); + const auto& links = entries.front().acquisitionLinks; + ASSERT_SIZE(links.size(), 1); + ASSERT_EQ(links[0].formatKey, "epub"); + ASSERT_EQ(links[0].href, "/books/example.epub"); + PASS(); +} + +int main() { + printf("=== OPDS Parser Tests ===\n\n"); + + testEpubExtension(); + testKepubDoubleExtension(); + testBareKepubExtension(); + testSlashTerminatedKepubPath(); + testSlashTerminatedEpubPath(); + testDistinctAcquisitionFormatsRemainSeparate(); + testUnsupportedMimeType(); + testEmptyHrefOrType(); + testDuplicateAcquisitionLinks(); + testIdenticalHrefAcquisitionLinksAreDeduplicated(); + testSlashVariantHrefAcquisitionLinksAreDeduplicated(); + + printf("\n=== Results: %d passed, %d failed ===\n", testsPassed, testsFailed); + return testsFailed > 0 ? 1 : 0; +} diff --git a/test/run_opds_format_label_test.sh b/test/run_opds_format_label_test.sh new file mode 100755 index 00000000..95edc224 --- /dev/null +++ b/test/run_opds_format_label_test.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_DIR="$ROOT_DIR/build/opds_format_label" +BINARY="$BUILD_DIR/OpdsFormatLabelTest" +PLATFORMIO_DIR="${PLATFORMIO_CORE_DIR:-$HOME/.platformio}" +ARDUINO_FRAMEWORK_DIR="$PLATFORMIO_DIR/packages/framework-arduinoespressif32" + +mkdir -p "$BUILD_DIR" + +SOURCES=( + "$ROOT_DIR/test/opds_format_label/OpdsFormatLabelTest.cpp" + "$ROOT_DIR/src/activities/browser/OpdsFormatLabel.cpp" + "$ROOT_DIR/src/util/UrlUtils.cpp" +) + +CXXFLAGS=( + -std=c++20 + -O2 + -Wall + -Wextra + -pedantic + -fno-exceptions + -DARDUINO_USB_MODE=1 + -DARDUINO_USB_CDC_ON_BOOT=1 + -DDESTRUCTOR_CLOSES_FILE=1 + -I"$ROOT_DIR/test/shims" + -I"$ROOT_DIR" + -I"$ROOT_DIR/lib" + -I"$ROOT_DIR/lib/OpdsParser" + -I"$ROOT_DIR/src" + -I"$ARDUINO_FRAMEWORK_DIR/cores/esp32" + -I"$ARDUINO_FRAMEWORK_DIR/variants/esp32c3" +) + +c++ "${CXXFLAGS[@]}" "${SOURCES[@]}" -o "$BINARY" + +"$BINARY" "$@" diff --git a/test/run_opds_parser_test.sh b/test/run_opds_parser_test.sh new file mode 100755 index 00000000..3e856964 --- /dev/null +++ b/test/run_opds_parser_test.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_DIR="$ROOT_DIR/build/opds_parser" +BINARY="$BUILD_DIR/OpdsParserTest" +PLATFORMIO_DIR="${PLATFORMIO_CORE_DIR:-$HOME/.platformio}" +ARDUINO_FRAMEWORK_DIR="$PLATFORMIO_DIR/packages/framework-arduinoespressif32" + +mkdir -p "$BUILD_DIR" + +SOURCES=( + "$ROOT_DIR/test/opds_parser/OpdsParserTest.cpp" + "$ROOT_DIR/lib/FsHelpers/FsHelpers.cpp" + "$ROOT_DIR/lib/OpdsParser/OpdsParser.cpp" +) + +CXXFLAGS=( + -std=c++20 + -O2 + -Wall + -Wextra + -pedantic + -fno-exceptions + -DARDUINO_USB_MODE=1 + -DARDUINO_USB_CDC_ON_BOOT=1 + -DDESTRUCTOR_CLOSES_FILE=1 + -DXML_GE=0 + -DXML_CONTEXT_BYTES=1024 + -DUSE_UTF8_LONG_NAMES=1 + -I"$ROOT_DIR/test/shims" + -I"$ROOT_DIR" + -I"$ROOT_DIR/lib" + -I"$ROOT_DIR/lib/FsHelpers" + -I"$ROOT_DIR/lib/OpdsParser" + -I"$ROOT_DIR/lib/Logging" + -I"$ARDUINO_FRAMEWORK_DIR/cores/esp32" + -I"$ARDUINO_FRAMEWORK_DIR/variants/esp32c3" +) + +c++ "${CXXFLAGS[@]}" "${SOURCES[@]}" -lexpat -o "$BINARY" + +"$BINARY" "$@" diff --git a/test/shims/HardwareSerial.h b/test/shims/HardwareSerial.h new file mode 100644 index 00000000..9bf8b10f --- /dev/null +++ b/test/shims/HardwareSerial.h @@ -0,0 +1,15 @@ +#pragma once + +#include "Print.h" + +class HWCDC : public Print { + public: + void begin(unsigned long) {} + operator bool() const { return true; } + + size_t write(uint8_t) override { return 1; } + size_t write(const uint8_t*, size_t size) override { return size; } + void flush() override {} +}; + +inline HWCDC Serial; diff --git a/test/shims/Print.h b/test/shims/Print.h new file mode 100644 index 00000000..4dd24672 --- /dev/null +++ b/test/shims/Print.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +class Print { + public: + virtual ~Print() = default; + + virtual size_t write(uint8_t) = 0; + + virtual size_t write(const uint8_t* buffer, size_t size) { + size_t written = 0; + for (size_t i = 0; i < size; i++) { + written += write(buffer[i]); + } + return written; + } + + virtual void flush() {} +};