Merge pull request #87 from jgoguen/opds-multi-format-select
feat(opds): Allow selecting the download format
This commit is contained in:
@@ -1,9 +1,81 @@
|
|||||||
#include "OpdsParser.h"
|
#include "OpdsParser.h"
|
||||||
|
|
||||||
|
#include <FsHelpers.h>
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
|
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() {
|
OpdsParser::OpdsParser() {
|
||||||
parser = XML_ParserCreate(nullptr);
|
parser = XML_ParserCreate(nullptr);
|
||||||
if (!parser) {
|
if (!parser) {
|
||||||
@@ -122,10 +194,18 @@ void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, cons
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (self->inEntry) {
|
if (self->inEntry) {
|
||||||
if (rel && type && strstr(rel, "opds-spec.org/acquisition") != nullptr &&
|
if (rel && strstr(rel, "opds-spec.org/acquisition") != nullptr) {
|
||||||
strcmp(type, "application/epub+zip") == 0) {
|
const auto acquisition = supportedAcquisitionLink(type, href);
|
||||||
self->currentEntry.type = OpdsEntryType::BOOK;
|
if (!acquisition.formatKey.empty() && !hasEquivalentAcquisitionLink(self->currentEntry, acquisition)) {
|
||||||
self->currentEntry.href = href;
|
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) {
|
} else if (type && strstr(type, "application/atom+xml") != nullptr) {
|
||||||
if (self->currentEntry.type != OpdsEntryType::BOOK) {
|
if (self->currentEntry.type != OpdsEntryType::BOOK) {
|
||||||
self->currentEntry.type = OpdsEntryType::NAVIGATION;
|
self->currentEntry.type = OpdsEntryType::NAVIGATION;
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ enum class OpdsEntryType {
|
|||||||
BOOK // Downloadable book
|
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).
|
* 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 author; // Only for books
|
||||||
std::string href; // Navigation URL or epub download URL
|
std::string href; // Navigation URL or epub download URL
|
||||||
std::string id;
|
std::string id;
|
||||||
|
std::vector<OpdsAcquisitionLink> acquisitionLinks;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Legacy alias for backward compatibility
|
// Legacy alias for backward compatibility
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <OpdsStream.h>
|
#include <OpdsStream.h>
|
||||||
#include <WiFi.h>
|
#include <WiFi.h>
|
||||||
|
#include <Xtc.h>
|
||||||
#include <expat.h>
|
#include <expat.h>
|
||||||
|
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
|
#include "OpdsFormatLabel.h"
|
||||||
#include "activities/network/WifiSelectionActivity.h"
|
#include "activities/network/WifiSelectionActivity.h"
|
||||||
#include "activities/util/KeyboardEntryActivity.h"
|
#include "activities/util/KeyboardEntryActivity.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
@@ -25,7 +27,20 @@
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr int PAGE_ITEMS = 23;
|
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() {
|
void OpdsBookBrowserActivity::onEnter() {
|
||||||
Activity::onEnter();
|
Activity::onEnter();
|
||||||
@@ -36,6 +51,9 @@ void OpdsBookBrowserActivity::onEnter() {
|
|||||||
currentPath = ""; // Root path - user provides full URL in settings
|
currentPath = ""; // Root path - user provides full URL in settings
|
||||||
searchTemplate.clear();
|
searchTemplate.clear();
|
||||||
selectorIndex = 0;
|
selectorIndex = 0;
|
||||||
|
selectedBookIndex = -1;
|
||||||
|
formatSelectorIndex = 0;
|
||||||
|
formatSelectionLabels.clear();
|
||||||
consumeConfirm = false;
|
consumeConfirm = false;
|
||||||
consumeBack = false;
|
consumeBack = false;
|
||||||
errorMessage.clear();
|
errorMessage.clear();
|
||||||
@@ -52,6 +70,7 @@ void OpdsBookBrowserActivity::onExit() {
|
|||||||
|
|
||||||
entries.clear();
|
entries.clear();
|
||||||
navigationHistory.clear();
|
navigationHistory.clear();
|
||||||
|
formatSelectionLabels.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
void OpdsBookBrowserActivity::loop() {
|
void OpdsBookBrowserActivity::loop() {
|
||||||
@@ -93,11 +112,51 @@ void OpdsBookBrowserActivity::loop() {
|
|||||||
|
|
||||||
if (state == BrowserState::DOWNLOADING) return;
|
if (state == BrowserState::DOWNLOADING) return;
|
||||||
|
|
||||||
|
if (state == BrowserState::FORMAT_SELECTION) {
|
||||||
|
if (selectedBookIndex < 0 || selectedBookIndex >= static_cast<int>(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 (state == BrowserState::BROWSING) {
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
if (!entries.empty()) {
|
if (!entries.empty()) {
|
||||||
const auto& entry = entries[selectorIndex];
|
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)) {
|
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
navigateBack();
|
navigateBack();
|
||||||
@@ -175,6 +234,34 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
|
|||||||
return;
|
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<int>(entry.acquisitionLinks.size()) && i < pageStartIndex + itemsPerPage; i++) {
|
||||||
|
const char* label = i < static_cast<int>(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
|
// Browsing state
|
||||||
// Show appropriate button hint based on selected entry type
|
// Show appropriate button hint based on selected entry type
|
||||||
const char* confirmLabel =
|
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;
|
state = BrowserState::DOWNLOADING;
|
||||||
statusMessage = book.title;
|
statusMessage = book.title;
|
||||||
downloadProgress = 0;
|
downloadProgress = 0;
|
||||||
downloadTotal = 0;
|
downloadTotal = 0;
|
||||||
requestUpdate(true);
|
requestUpdate(true);
|
||||||
|
|
||||||
std::string downloadUrl =
|
std::string downloadUrl = (acquisition.href.rfind("http", 0) == 0)
|
||||||
(book.href.rfind("http", 0) == 0) ? book.href : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href);
|
? acquisition.href
|
||||||
std::string filename =
|
: UrlUtils::buildUrl(SETTINGS.opdsServerUrl, acquisition.href);
|
||||||
"/" + StringUtils::sanitizeFilename(book.title + (book.author.empty() ? "" : " - " + book.author)) + ".epub";
|
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());
|
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());
|
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;
|
state = BrowserState::BROWSING;
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
} else {
|
} else {
|
||||||
|
selectedBookIndex = -1;
|
||||||
|
formatSelectionLabels.clear();
|
||||||
state = BrowserState::ERROR;
|
state = BrowserState::ERROR;
|
||||||
errorMessage = tr(STR_DOWNLOAD_FAILED);
|
errorMessage = tr(STR_DOWNLOAD_FAILED);
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <OpdsParser.h>
|
#include <OpdsParser.h>
|
||||||
|
|
||||||
#include <functional>
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -14,7 +13,16 @@
|
|||||||
*/
|
*/
|
||||||
class OpdsBookBrowserActivity final : public Activity {
|
class OpdsBookBrowserActivity final : public Activity {
|
||||||
public:
|
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)
|
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||||
: Activity("OpdsBookBrowser", renderer, mappedInput), buttonNavigator() {}
|
: Activity("OpdsBookBrowser", renderer, mappedInput), buttonNavigator() {}
|
||||||
@@ -34,6 +42,9 @@ class OpdsBookBrowserActivity final : public Activity {
|
|||||||
bool consumeConfirm = false;
|
bool consumeConfirm = false;
|
||||||
bool consumeBack = false; // Added missing member
|
bool consumeBack = false; // Added missing member
|
||||||
int selectorIndex = 0;
|
int selectorIndex = 0;
|
||||||
|
int selectedBookIndex = -1;
|
||||||
|
int formatSelectorIndex = 0;
|
||||||
|
std::vector<std::string> formatSelectionLabels;
|
||||||
std::string errorMessage;
|
std::string errorMessage;
|
||||||
std::string statusMessage;
|
std::string statusMessage;
|
||||||
size_t downloadProgress = 0;
|
size_t downloadProgress = 0;
|
||||||
@@ -45,7 +56,8 @@ class OpdsBookBrowserActivity final : public Activity {
|
|||||||
void fetchFeed(const std::string& path);
|
void fetchFeed(const std::string& path);
|
||||||
void navigateToEntry(const OpdsEntry& entry);
|
void navigateToEntry(const OpdsEntry& entry);
|
||||||
void navigateBack();
|
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 fetchOsdTemplate(const std::string& osdUrl);
|
||||||
void launchSearch();
|
void launchSearch();
|
||||||
void performSearch(const std::string& query);
|
void performSearch(const std::string& query);
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#include "OpdsFormatLabel.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <iterator>
|
||||||
|
|
||||||
|
#include "util/UrlUtils.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
bool hasDuplicateFormatKey(const OpdsAcquisitionLink& acquisition,
|
||||||
|
const std::vector<OpdsAcquisitionLink>& 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<std::string> resolvedHostnames(const std::vector<OpdsAcquisitionLink>& acquisitionLinks,
|
||||||
|
const std::string& serverUrl) {
|
||||||
|
std::vector<std::string> 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<OpdsAcquisitionLink>& acquisitionLinks,
|
||||||
|
const std::vector<std::string>& 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<OpdsAcquisitionLink>& 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<std::string> buildOpdsFormatSelectionLabels(const std::vector<OpdsAcquisitionLink>& acquisitionLinks,
|
||||||
|
const std::string& serverUrl) {
|
||||||
|
const auto hostnames = resolvedHostnames(acquisitionLinks, serverUrl);
|
||||||
|
std::vector<std::string> 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <OpdsParser.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
const char* opdsBaseFormatLabel(const OpdsAcquisitionLink& acquisition);
|
||||||
|
std::string opdsFormatSelectionLabel(const OpdsAcquisitionLink& acquisition,
|
||||||
|
const std::vector<OpdsAcquisitionLink>& acquisitionLinks,
|
||||||
|
const std::string& serverUrl);
|
||||||
|
std::vector<std::string> buildOpdsFormatSelectionLabels(const std::vector<OpdsAcquisitionLink>& acquisitionLinks,
|
||||||
|
const std::string& serverUrl);
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#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<OpdsAcquisitionLink> 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<OpdsAcquisitionLink> 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<OpdsAcquisitionLink> 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<OpdsAcquisitionLink> 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<OpdsAcquisitionLink> 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<OpdsAcquisitionLink> links{first, second, third};
|
||||||
|
|
||||||
|
const auto labels = buildOpdsFormatSelectionLabels(links, "https://catalog.example.com/opds");
|
||||||
|
ASSERT_EQ(labels.size(), static_cast<size_t>(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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
#include <OpdsParser.h>
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
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<size_t>(a), \
|
||||||
|
static_cast<size_t>(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"(<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<entry>
|
||||||
|
<title>Example Book</title>
|
||||||
|
<author><name>Example Author</name></author>
|
||||||
|
<id>book-1</id>
|
||||||
|
<link rel="http://opds-spec.org/acquisition" type=")") +
|
||||||
|
type + R"(" href=")" + href + R"("/>
|
||||||
|
</entry>
|
||||||
|
</feed>)";
|
||||||
|
|
||||||
|
OpdsParser parser;
|
||||||
|
parser.write(reinterpret_cast<const uint8_t*>(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"(<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<entry>
|
||||||
|
<title>Example Book</title>
|
||||||
|
<author><name>Example Author</name></author>
|
||||||
|
<id>book-2</id>
|
||||||
|
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip" href="/books/example.epub"/>
|
||||||
|
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip" href="/books/example.kepub.epub"/>
|
||||||
|
<link rel="http://opds-spec.org/acquisition" type="text/plain" href="/books/example.txt"/>
|
||||||
|
<link rel="http://opds-spec.org/acquisition" type="text/markdown" href="/books/example.md"/>
|
||||||
|
</entry>
|
||||||
|
</feed>)";
|
||||||
|
|
||||||
|
OpdsParser parser;
|
||||||
|
parser.write(reinterpret_cast<const uint8_t*>(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"(<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<entry>
|
||||||
|
<title>Example Book</title>
|
||||||
|
<author><name>Example Author</name></author>
|
||||||
|
<id>book-3</id>
|
||||||
|
<link rel="http://opds-spec.org/acquisition" type="application/x-mobipocket-ebook" href="/books/example.mobi"/>
|
||||||
|
</entry>
|
||||||
|
</feed>)";
|
||||||
|
|
||||||
|
OpdsParser parser;
|
||||||
|
parser.write(reinterpret_cast<const uint8_t*>(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"(<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<entry>
|
||||||
|
<title>Empty Href</title>
|
||||||
|
<author><name>Example Author</name></author>
|
||||||
|
<id>book-4</id>
|
||||||
|
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip" href=""/>
|
||||||
|
</entry>
|
||||||
|
<entry>
|
||||||
|
<title>Empty Type</title>
|
||||||
|
<author><name>Example Author</name></author>
|
||||||
|
<id>book-5</id>
|
||||||
|
<link rel="http://opds-spec.org/acquisition" type="" href="/books/example.epub"/>
|
||||||
|
</entry>
|
||||||
|
<entry>
|
||||||
|
<title>Missing Href</title>
|
||||||
|
<author><name>Example Author</name></author>
|
||||||
|
<id>book-6</id>
|
||||||
|
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip"/>
|
||||||
|
</entry>
|
||||||
|
<entry>
|
||||||
|
<title>Missing Type</title>
|
||||||
|
<author><name>Example Author</name></author>
|
||||||
|
<id>book-7</id>
|
||||||
|
<link rel="http://opds-spec.org/acquisition" href="/books/example.epub"/>
|
||||||
|
</entry>
|
||||||
|
</feed>)";
|
||||||
|
|
||||||
|
OpdsParser parser;
|
||||||
|
parser.write(reinterpret_cast<const uint8_t*>(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"(<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<entry>
|
||||||
|
<title>Example Book</title>
|
||||||
|
<author><name>Example Author</name></author>
|
||||||
|
<id>book-8</id>
|
||||||
|
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip" href="/books/example.epub"/>
|
||||||
|
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip" href="/books/example-copy.epub"/>
|
||||||
|
</entry>
|
||||||
|
</feed>)";
|
||||||
|
|
||||||
|
OpdsParser parser;
|
||||||
|
parser.write(reinterpret_cast<const uint8_t*>(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"(<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<entry>
|
||||||
|
<title>Example Book</title>
|
||||||
|
<author><name>Example Author</name></author>
|
||||||
|
<id>book-9</id>
|
||||||
|
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip" href="/books/example.epub"/>
|
||||||
|
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip" href="/books/example.epub"/>
|
||||||
|
</entry>
|
||||||
|
</feed>)";
|
||||||
|
|
||||||
|
OpdsParser parser;
|
||||||
|
parser.write(reinterpret_cast<const uint8_t*>(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"(<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<entry>
|
||||||
|
<title>Example Book</title>
|
||||||
|
<author><name>Example Author</name></author>
|
||||||
|
<id>book-10</id>
|
||||||
|
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip" href="/books/example.epub"/>
|
||||||
|
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip" href="/books/example.epub/"/>
|
||||||
|
</entry>
|
||||||
|
</feed>)";
|
||||||
|
|
||||||
|
OpdsParser parser;
|
||||||
|
parser.write(reinterpret_cast<const uint8_t*>(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;
|
||||||
|
}
|
||||||
Executable
+39
@@ -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" "$@"
|
||||||
Executable
+43
@@ -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" "$@"
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
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() {}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user