From e4837a49ef8a2278a475d957e73c2abda999cc67 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:51:10 +0000 Subject: [PATCH] feat(opds): ask before overwriting a book already on SD Before downloading, open the destination read-only; if a book is already there, show a Cancel/Confirm dialog (Cancel focused) with its size and FAT modification date, formatted by hand without strftime. No SD writes unless the user confirms. The download path is computed once and passed through. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01XgLRarbAmBVepTPKCK7mgd --- CHANGELOG.md | 1 + lib/I18n/translations/english.yaml | 1 + .../browser/OpdsBookBrowserActivity.cpp | 100 ++++++++++++++++-- .../browser/OpdsBookBrowserActivity.h | 6 +- 4 files changed, 97 insertions(+), 11 deletions(-) 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;