diff --git a/CHANGELOG.md b/CHANGELOG.md index 72b10a5ffb0..15ef0113be5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - View a selected book's reading stats from its Library or File Browser action menu. - Library replaces Recent Books with a searchable book list, and adds various book metadata sort options. - Reset a book's reader settings from the in-reader Settings tab. +- The OPDS browser asks before downloading a book that is already on the SD card, showing the existing file's size and date. Cancel is selected by default. - Assign actions to upward and downward slides along either screen edge on touch devices. - Automatic light sleep is available on the X3/X4 and Sticky and in the X4 Pro light-sleep firmware profiles, reducing idle power while retaining normal button, touch, and frontlight behavior. USB serial stays available while a computer is connected, because the device skips light sleep for as long as a USB host is attached. USB Drive also works in these profiles and keeps the device awake while it is open. - Add the continuous **IncreMENTAL** EPUB indexing method for background chapter indexing. diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index a6f1ead6afb..300258094b0 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -391,6 +391,7 @@ STR_RESTARTING_HINT: "Restarting... If device does not restart, hold the power b STR_NO_ENTRIES: "No entries found" STR_DOWNLOADING: "Downloading..." STR_DOWNLOAD_FAILED: "Download failed" +STR_BOOK_EXISTS_OVERWRITE: "Book already on SD card. Overwrite?" STR_ERROR_MSG: "Error:" STR_UNNAMED: "Unnamed" STR_NO_SERVER_URL: "No server URL configured" diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index a7fb2cfa627..e28d3698bf4 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -23,6 +23,7 @@ #include "SdCardFontSystem.h" #include "SilentRestart.h" #include "activities/network/WifiSelectionActivity.h" +#include "activities/util/ConfirmationActivity.h" #include "activities/util/KeyboardEntryActivity.h" #include "components/TouchHeaderBackButton.h" #include "components/UIScale.h" @@ -33,6 +34,7 @@ #include "fontIds.h" #include "network/HttpDownloader.h" #include "util/BookCacheUtils.h" +#include "util/DaylightSaving.h" #include "util/StringUtils.h" #include "util/UrlUtils.h" @@ -63,6 +65,52 @@ std::string buildBookFilenameBase(const OpdsEntry& book, const OpdsFilenameForma return book.author + " - " + book.title; } +// SD path a book downloads to: the configured folder (or the root) plus the +// sanitized " - .epub" name. +std::string bookDownloadPath(const OpdsEntry& book, const OpdsFilenameFormat format) { + const char* downloadFolder = SETTINGS.opdsDownloadFolder; + std::string path; + path.reserve(96); + if (downloadFolder[0] != '\0') path += downloadFolder; + path += '/'; + path += StringUtils::sanitizeFilename(buildBookFilenameBase(book, format)); + path += ".epub"; + return path; +} + +// "12.3 MB", "456 KB" or "789 B", with integer math only. +void formatFileSize(const uint64_t bytes, char* out, const size_t outSize) { + constexpr uint64_t KB = 1024; + constexpr uint64_t MB = KB * KB; + const uint64_t roundedKb = (bytes + KB / 2) / KB; + if (roundedKb >= KB) { + const uint64_t tenths = (bytes * 10 + MB / 2) / MB; + snprintf(out, outSize, "%lu.%lu MB", static_cast<unsigned long>(tenths / 10), + static_cast<unsigned long>(tenths % 10)); + } else if (bytes >= KB) { + snprintf(out, outSize, "%lu KB", static_cast<unsigned long>(roundedKb)); + } else { + snprintf(out, outSize, "%lu B", static_cast<unsigned long>(bytes)); + } +} + +// Packed FAT date/time (HalFile::modificationTime) as "YYYY-MM-DD HH:MM", +// decoded by hand rather than through strftime. False when the file has none. +bool formatFatDateTime(const uint32_t packed, char* out, const size_t outSize) { + if (packed == 0) return false; + const uint16_t date = static_cast<uint16_t>(packed >> 16); + const uint16_t time = static_cast<uint16_t>(packed & 0xFFFF); + const uint16_t year = static_cast<uint16_t>(1980 + (date >> 9)); + const uint8_t month = static_cast<uint8_t>((date >> 5) & 0x0F); + const uint8_t day = static_cast<uint8_t>(date & 0x1F); + const unsigned hour = time >> 11; + const unsigned minute = (time >> 5) & 0x3F; + if (!DaylightSaving::isValidDate(year, month, day) || hour > 23 || minute > 59) return false; + snprintf(out, outSize, "%04u-%02u-%02u %02u:%02u", static_cast<unsigned>(year), static_cast<unsigned>(month), + static_cast<unsigned>(day), hour, minute); + return true; +} + // Mayberry prefixes folder titles with U+1F4C1 (file folder), which the UI // fonts lack; show "/name" instead. void replaceFolderEmoji(std::string& title) { @@ -154,7 +202,7 @@ void OpdsBookBrowserActivity::activateSelected() { if (!entries || entryCount == 0 || selectorIndex < 0 || selectorIndex >= static_cast<int>(entryCount)) return; const auto& entry = entries[selectorIndex]; if (entry.type == OpdsEntryType::BOOK) { - downloadBook(entry); + requestDownload(entry); return; } const bool pageLink = @@ -752,7 +800,46 @@ void OpdsBookBrowserActivity::navigateBack() { } } -void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { +void OpdsBookBrowserActivity::requestDownload(const OpdsEntry& book) { + // Read-only open: one directory lookup for existence, size and date, and no + // SD write unless the user chooses to overwrite. + std::string path = bookDownloadPath(book, server.filenameFormat); + HalFile existing = Storage.open(path.c_str()); + if (!existing || existing.isDirectory()) { + if (existing) existing.close(); + downloadBook(book, path); + return; + } + char sizeLabel[16]; + formatFileSize(existing.fileSize64(), sizeLabel, sizeof(sizeLabel)); + char dateLabel[20]; + const bool hasDate = formatFatDateTime(existing.modificationTime(), dateLabel, sizeof(dateLabel)); + existing.close(); + + // ConfirmationActivity appends "<size>, <date>" to the question in one + // popup title; Cancel is the focused option. + std::string details = sizeLabel; + if (hasDate) { + details += ", "; + details += dateLabel; + } + LOG_INF("OPDS", "Already on SD: %s (%s)", path.c_str(), details.c_str()); + + auto dialog = makeUniqueNoThrow<ConfirmationActivity>(renderer, mappedInput, tr(STR_BOOK_EXISTS_OVERWRITE), details); + if (!dialog) { + LOG_ERR("OPDS", "Cannot allocate overwrite dialog"); + return; + } + // entries and selectorIndex stay put while the dialog is on top (this + // activity's loop does not run), so the index is enough to find the book. + const int bookIndex = selectorIndex; + startActivityForResult(std::move(dialog), [this, bookIndex, path = std::move(path)](const ActivityResult& result) { + if (result.isCancelled || !entries || bookIndex < 0 || bookIndex >= static_cast<int>(entryCount)) return; + downloadBook(entries[bookIndex], path); + }); +} + +void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book, const std::string& filename) { state = BrowserState::DOWNLOADING; statusMessage = book.title; downloadProgress = downloadTotal = 0; @@ -777,8 +864,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { // DownloadOptions borrows it to avoid copying the server URL per transfer. const std::string authorizationOrigin = UrlUtils::ensureProtocol(server.url); const char* downloadFolder = SETTINGS.opdsDownloadFolder; - bool useDownloadFolder = downloadFolder[0] != '\0'; - if (useDownloadFolder && !Storage.exists(downloadFolder) && !Storage.mkdir(downloadFolder)) { + if (downloadFolder[0] != '\0' && !Storage.exists(downloadFolder) && !Storage.mkdir(downloadFolder)) { LOG_ERR("OPDS", "Could not create download folder %s", downloadFolder); state = BrowserState::ERROR; errorMessage = tr(STR_DOWNLOAD_FAILED); @@ -786,12 +872,6 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { return; } - std::string filename; - filename.reserve(96); - if (useDownloadFolder) filename += downloadFolder; - filename += '/'; - filename += StringUtils::sanitizeFilename(buildBookFilenameBase(book, server.filenameFormat)); - filename += ".epub"; LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str()); bool cancelRequested = false; diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h index 19feafd3754..cb3da5b6e05 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.h +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -106,7 +106,11 @@ class OpdsBookBrowserActivity final : public Activity { // listing instead of pushing it onto the Back history. void navigateToEntry(const OpdsEntry& entry, bool pageLink); void navigateBack(); - void downloadBook(const OpdsEntry& book); + // Asks before replacing a book already on SD (showing its size and date), + // otherwise downloads straight away. + void requestDownload(const OpdsEntry& book); + // filename: the SD destination from requestDownload. + void downloadBook(const OpdsEntry& book, const std::string& filename); void launchSearch(); void performSearch(const std::string& query); bool preventAutoSleep() override;