diff --git a/lib/OpdsParser/OpdsParser.cpp b/lib/OpdsParser/OpdsParser.cpp index 807f82da..851180b1 100644 --- a/lib/OpdsParser/OpdsParser.cpp +++ b/lib/OpdsParser/OpdsParser.cpp @@ -146,7 +146,6 @@ void OpdsParser::flush() { bool OpdsParser::error() const { return errorOccured; } void OpdsParser::clear() { - entries.clear(); searchTemplate.clear(); osdUrl.clear(); nextPageUrl.clear(); @@ -156,14 +155,6 @@ void OpdsParser::clear() { inEntry = inTitle = inAuthor = inAuthorName = 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) return atts[i + 1]; @@ -243,7 +234,7 @@ void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) { if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) { if (!self->currentEntry.title.empty() && !self->currentEntry.href.empty()) { - self->entries.push_back(self->currentEntry); + if (self->onEntryParsed) self->onEntryParsed(self->currentEntry); } self->inEntry = false; } else if (self->inEntry) { diff --git a/lib/OpdsParser/OpdsParser.h b/lib/OpdsParser/OpdsParser.h index e97489c7..db537cfd 100644 --- a/lib/OpdsParser/OpdsParser.h +++ b/lib/OpdsParser/OpdsParser.h @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -73,24 +74,13 @@ class OpdsParser final : public Print { operator bool() { return !error(); } - /** - * Get the parsed entries (both navigation and book entries). - * @return Vector of OpdsEntry entries - */ - const std::vector& getEntries() const& { return entries; } - std::vector getEntries() && { return std::move(entries); } - - /** - * Get only book entries (legacy compatibility). - * @return Vector of book entries - */ - std::vector getBooks() const; - /** * Clear all parsed entries. */ void clear(); + std::function onEntryParsed; + private: // Expat callbacks static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts); @@ -105,7 +95,6 @@ class OpdsParser final : public Print { static const char* findAttribute(const XML_Char** atts, const char* name); XML_Parser parser = nullptr; - std::vector entries; OpdsEntry currentEntry; std::string currentText; diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index 98ad8053..e3cfa10f 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -39,13 +40,78 @@ int formatItemsPerPage(const Rect& contentRect) { const int itemsPerPage = availableHeight / FORMAT_ITEM_HEIGHT; return itemsPerPage > 0 ? itemsPerPage : 1; } + +void writeString(HalFile& f, const std::string& s) { + uint16_t len = s.length(); + f.write(reinterpret_cast(&len), sizeof(len)); + if (len > 0) f.write(reinterpret_cast(s.data()), len); +} + +std::string readString(HalFile& f) { + uint16_t len = 0; + if (f.read(&len, sizeof(len)) != sizeof(len)) return ""; + if (len == 0) return ""; + std::string s(len, '\0'); + f.read(s.data(), len); + return s; +} + +void writeEntryToCache(HalFile& f, const OpdsEntry& entry) { + uint8_t type = static_cast(entry.type); + f.write(&type, sizeof(type)); + writeString(f, entry.title); + writeString(f, entry.author); + writeString(f, entry.href); + writeString(f, entry.id); + uint16_t numLinks = entry.acquisitionLinks.size(); + f.write(reinterpret_cast(&numLinks), sizeof(numLinks)); + for (const auto& link : entry.acquisitionLinks) { + writeString(f, link.href); + writeString(f, link.mimeType); + writeString(f, link.formatKey); + writeString(f, link.fileExtension); + } +} + +OpdsEntry readEntryFromCache(HalFile& f) { + OpdsEntry entry; + uint8_t type = 0; + if (f.read(&type, sizeof(type)) == sizeof(type)) { + entry.type = static_cast(type); + } + entry.title = readString(f); + entry.author = readString(f); + entry.href = readString(f); + entry.id = readString(f); + uint16_t numLinks = 0; + if (f.read(&numLinks, sizeof(numLinks)) == sizeof(numLinks)) { + for (uint16_t i = 0; i < numLinks; ++i) { + OpdsAcquisitionLink link; + link.href = readString(f); + link.mimeType = readString(f); + link.formatKey = readString(f); + link.fileExtension = readString(f); + entry.acquisitionLinks.push_back(link); + } + } + return entry; +} } // namespace +OpdsEntry OpdsBookBrowserActivity::getEntry(size_t index) const { + if (index >= entryOffsets.size()) return {}; + HalFile f; + if (!Storage.openFileForRead("OPDS", "/.tmp_opds_cache.dat", f)) return {}; + f.seek(entryOffsets[index]); + OpdsEntry entry = readEntryFromCache(f); + return entry; +} + void OpdsBookBrowserActivity::onEnter() { Activity::onEnter(); state = BrowserState::CHECK_WIFI; - entries.clear(); + entryOffsets.clear(); navigationHistory.clear(); currentPath = ""; // Root path - user provides full URL in settings searchTemplate.clear(); @@ -67,9 +133,10 @@ void OpdsBookBrowserActivity::onExit() { HalClock::wifiOff(); - entries.clear(); + entryOffsets.clear(); navigationHistory.clear(); formatSelectionLabels.clear(); + Storage.remove("/.tmp_opds_cache.dat"); } void OpdsBookBrowserActivity::loop() { @@ -112,13 +179,13 @@ void OpdsBookBrowserActivity::loop() { if (state == BrowserState::DOWNLOADING) return; if (state == BrowserState::FORMAT_SELECTION) { - if (selectedBookIndex < 0 || selectedBookIndex >= static_cast(entries.size())) { + if (selectedBookIndex < 0 || selectedBookIndex >= static_cast(entryOffsets.size())) { state = BrowserState::BROWSING; requestUpdate(); return; } - const auto& entry = entries[selectedBookIndex]; + const auto entry = getEntry(selectedBookIndex); if (entry.acquisitionLinks.empty()) { state = BrowserState::BROWSING; selectedBookIndex = -1; @@ -140,11 +207,11 @@ void OpdsBookBrowserActivity::loop() { return; } - buttonNavigator.onNextRelease([this, &entry] { + buttonNavigator.onNextRelease([this, entry] { formatSelectorIndex = ButtonNavigator::nextIndex(formatSelectorIndex, entry.acquisitionLinks.size()); requestUpdate(); }); - buttonNavigator.onPreviousRelease([this, &entry] { + buttonNavigator.onPreviousRelease([this, entry] { formatSelectorIndex = ButtonNavigator::previousIndex(formatSelectorIndex, entry.acquisitionLinks.size()); requestUpdate(); }); @@ -153,8 +220,8 @@ void OpdsBookBrowserActivity::loop() { if (state == BrowserState::BROWSING) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - if (!entries.empty()) { - const auto& entry = entries[selectorIndex]; + if (!entryOffsets.empty()) { + const auto entry = getEntry(selectorIndex); entry.type == OpdsEntryType::BOOK ? chooseBookFormat(entry) : navigateToEntry(entry); } } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { @@ -163,24 +230,24 @@ void OpdsBookBrowserActivity::loop() { if (!searchTemplate.empty() && selectorIndex == 0) launchSearch(); } - if (!entries.empty()) { + if (!entryOffsets.empty()) { // Navigator is restricted to Up/Down so a Left release used to launch // search (line above) cannot also be consumed here as a previous-item // step on the same tick. buttonNavigator.onRelease({MappedInputManager::Button::Down}, [this] { - selectorIndex = ButtonNavigator::nextIndex(selectorIndex, entries.size()); + selectorIndex = ButtonNavigator::nextIndex(selectorIndex, entryOffsets.size()); requestUpdate(); }); buttonNavigator.onRelease({MappedInputManager::Button::Up}, [this] { - selectorIndex = ButtonNavigator::previousIndex(selectorIndex, entries.size()); + selectorIndex = ButtonNavigator::previousIndex(selectorIndex, entryOffsets.size()); requestUpdate(); }); buttonNavigator.onContinuous({MappedInputManager::Button::Down}, [this] { - selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, entries.size(), PAGE_ITEMS); + selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, entryOffsets.size(), PAGE_ITEMS); requestUpdate(); }); buttonNavigator.onContinuous({MappedInputManager::Button::Up}, [this] { - selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, entries.size(), PAGE_ITEMS); + selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, entryOffsets.size(), PAGE_ITEMS); requestUpdate(); }); } @@ -239,7 +306,7 @@ void OpdsBookBrowserActivity::render(RenderLock&&) { } if (state == BrowserState::FORMAT_SELECTION) { - const auto& entry = entries[selectedBookIndex]; + const auto entry = getEntry(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()) { @@ -269,12 +336,12 @@ void OpdsBookBrowserActivity::render(RenderLock&&) { // Browsing state // Show appropriate button hint based on selected entry type const char* confirmLabel = - (!entries.empty() && entries[selectorIndex].type == OpdsEntryType::BOOK) ? tr(STR_DOWNLOAD) : tr(STR_OPEN); + (!entryOffsets.empty() && getEntry(selectorIndex).type == OpdsEntryType::BOOK) ? tr(STR_DOWNLOAD) : tr(STR_OPEN); const char* searchLabel = (!searchTemplate.empty() && selectorIndex == 0) ? tr(STR_SEARCH) : tr(STR_DIR_UP); const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, searchLabel, tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - if (entries.empty()) { + if (entryOffsets.empty()) { renderer.drawCenteredText(UI_10_FONT_ID, midY, tr(STR_NO_ENTRIES)); renderer.displayBuffer(); return; @@ -283,8 +350,9 @@ void OpdsBookBrowserActivity::render(RenderLock&&) { const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; renderer.fillRect(contentRect.x, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, contentRect.width - 1, 30); - for (size_t i = pageStartIndex; i < entries.size() && i < static_cast(pageStartIndex + PAGE_ITEMS); i++) { - const auto& entry = entries[i]; + for (size_t i = pageStartIndex; i < entryOffsets.size() && i < static_cast(pageStartIndex + PAGE_ITEMS); + i++) { + const auto entry = getEntry(i); // Format display text with type indicator std::string displayText; @@ -318,6 +386,21 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { LOG_DBG("OPDS", "Fetching: %s", url.c_str()); OpdsParser parser; + HalFile cacheFile; + + if (!Storage.openFileForWrite("OPDS", "/.tmp_opds_cache.dat", cacheFile)) { + LOG_ERR("OPDS", "Could not open cache file"); + state = BrowserState::ERROR; + errorMessage = "Cache Error"; + requestUpdate(); + return; + } + + parser.onEntryParsed = [&](OpdsEntry entry) { + uint32_t offset = cacheFile.position(); + entryOffsets.push_back(offset); + writeEntryToCache(cacheFile, entry); + }; { OpdsParserStream stream{parser}; @@ -342,18 +425,21 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { } const auto& nextUrl = parser.getNextPageUrl(); const auto& prevUrl = parser.getPrevPageUrl(); - entries = std::move(parser).getEntries(); if (!prevUrl.empty()) { - entries.insert(entries.begin(), OpdsEntry{OpdsEntryType::NAVIGATION, tr(STR_PREV_PAGE), "", prevUrl, ""}); + OpdsEntry prevEntry{OpdsEntryType::NAVIGATION, tr(STR_PREV_PAGE), "", prevUrl, ""}; + entryOffsets.insert(entryOffsets.begin(), cacheFile.position()); + writeEntryToCache(cacheFile, prevEntry); } if (!nextUrl.empty()) { - entries.push_back(OpdsEntry{OpdsEntryType::NAVIGATION, tr(STR_NEXT_PAGE), "", nextUrl, ""}); + OpdsEntry nextEntry{OpdsEntryType::NAVIGATION, tr(STR_NEXT_PAGE), "", nextUrl, ""}; + entryOffsets.push_back(cacheFile.position()); + writeEntryToCache(cacheFile, nextEntry); } selectorIndex = 0; - state = entries.empty() ? BrowserState::ERROR : BrowserState::BROWSING; - if (entries.empty()) errorMessage = tr(STR_NO_ENTRIES); + state = entryOffsets.empty() ? BrowserState::ERROR : BrowserState::BROWSING; + if (entryOffsets.empty()) errorMessage = tr(STR_NO_ENTRIES); requestUpdate(); } @@ -362,7 +448,7 @@ void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) { currentPath = entry.href; state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); - entries.clear(); + entryOffsets.clear(); selectorIndex = 0; requestUpdate(true); fetchFeed(currentPath); @@ -376,7 +462,7 @@ void OpdsBookBrowserActivity::navigateBack() { navigationHistory.pop_back(); state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); - entries.clear(); + entryOffsets.clear(); selectorIndex = 0; requestUpdate(); fetchFeed(currentPath); diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h index 45b20647..107a2541 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.h +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -37,7 +37,7 @@ class OpdsBookBrowserActivity final : public Activity { private: ButtonNavigator buttonNavigator; BrowserState state = BrowserState::LOADING; - std::vector entries; + std::vector entryOffsets; std::vector navigationHistory; std::string currentPath; std::string searchTemplate; @@ -54,6 +54,8 @@ class OpdsBookBrowserActivity final : public Activity { OpdsServer server; // Copied at construction — safe even if the store changes during browsing + OpdsEntry getEntry(size_t index) const; + void checkAndConnectWifi(); void launchWifiSelection(); void onWifiSelectionComplete(bool connected);