From 42011d5977e5f357dfabc2e7e5ea22974ccc82e4 Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 2 Mar 2026 04:28:57 -0500 Subject: [PATCH] feat: add directory picker for OPDS downloads with per-server default path When downloading a book via OPDS, a directory picker now lets the user choose the save location instead of always saving to the SD root. Each OPDS server has a configurable default download path (persisted in opds.json) that the picker opens to. Falls back to "/" if the saved path no longer exists on disk. - Add DirectoryPickerActivity (browse-only directory view with "Save Here") - Add PICKING_DIRECTORY state to OpdsBookBrowserActivity - Add downloadPath field to OpdsServer with JSON serialization - Add Download Path setting to OPDS server edit screen - Extract sortFileList() to StringUtils for shared use - Add i18n strings: STR_SAVE_HERE, STR_SELECT_FOLDER, STR_DOWNLOAD_PATH Made-with: Cursor --- lib/I18n/I18nKeys.h | 3 + lib/I18n/translations/czech.yaml | 3 + lib/I18n/translations/english.yaml | 3 + lib/I18n/translations/french.yaml | 3 + lib/I18n/translations/german.yaml | 3 + lib/I18n/translations/portuguese.yaml | 3 + lib/I18n/translations/romanian.yaml | 3 + lib/I18n/translations/russian.yaml | 3 + lib/I18n/translations/spanish.yaml | 3 + lib/I18n/translations/swedish.yaml | 3 + src/OpdsServerStore.cpp | 2 + src/OpdsServerStore.h | 1 + .../browser/OpdsBookBrowserActivity.cpp | 41 ++++- .../browser/OpdsBookBrowserActivity.h | 20 ++- src/activities/home/MyLibraryActivity.cpp | 56 +----- .../settings/OpdsSettingsActivity.cpp | 28 ++- .../util/DirectoryPickerActivity.cpp | 166 ++++++++++++++++++ src/activities/util/DirectoryPickerActivity.h | 44 +++++ src/util/StringUtils.cpp | 40 +++++ src/util/StringUtils.h | 7 + 20 files changed, 363 insertions(+), 72 deletions(-) create mode 100644 src/activities/util/DirectoryPickerActivity.cpp create mode 100644 src/activities/util/DirectoryPickerActivity.h diff --git a/lib/I18n/I18nKeys.h b/lib/I18n/I18nKeys.h index 140d694e..6d1a39e3 100644 --- a/lib/I18n/I18nKeys.h +++ b/lib/I18n/I18nKeys.h @@ -422,6 +422,9 @@ enum class StrId : uint16_t { STR_DELETE_SERVER, STR_DELETE_CONFIRM, STR_OPDS_SERVERS, + STR_SAVE_HERE, + STR_SELECT_FOLDER, + STR_DOWNLOAD_PATH, // Sentinel - must be last _COUNT }; diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index cebc9330..0fcaadc4 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -350,3 +350,6 @@ STR_NO_SERVERS: "Žádné OPDS servery nejsou nakonfigurovány" STR_DELETE_SERVER: "Smazat server" STR_DELETE_CONFIRM: "Smazat tento server?" STR_OPDS_SERVERS: "OPDS servery" +STR_SAVE_HERE: "Uložit zde" +STR_SELECT_FOLDER: "Vybrat složku" +STR_DOWNLOAD_PATH: "Cesta ke stažení" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index b8cd5bb0..79482b5f 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -386,3 +386,6 @@ STR_NO_SERVERS: "No OPDS servers configured" STR_DELETE_SERVER: "Delete Server" STR_DELETE_CONFIRM: "Delete this server?" STR_OPDS_SERVERS: "OPDS Servers" +STR_SAVE_HERE: "Save Here" +STR_SELECT_FOLDER: "Select Folder" +STR_DOWNLOAD_PATH: "Download Path" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index faab4577..82ffd718 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -350,3 +350,6 @@ STR_NO_SERVERS: "Aucun serveur OPDS configuré" STR_DELETE_SERVER: "Supprimer le serveur" STR_DELETE_CONFIRM: "Supprimer ce serveur ?" STR_OPDS_SERVERS: "Serveurs OPDS" +STR_SAVE_HERE: "Enregistrer ici" +STR_SELECT_FOLDER: "Sélectionner un dossier" +STR_DOWNLOAD_PATH: "Chemin de téléchargement" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index f5f40a27..d3b7b3c0 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -350,3 +350,6 @@ STR_NO_SERVERS: "Keine OPDS-Server konfiguriert" STR_DELETE_SERVER: "Server löschen" STR_DELETE_CONFIRM: "Diesen Server löschen?" STR_OPDS_SERVERS: "OPDS-Server" +STR_SAVE_HERE: "Hier speichern" +STR_SELECT_FOLDER: "Ordner auswählen" +STR_DOWNLOAD_PATH: "Download-Pfad" diff --git a/lib/I18n/translations/portuguese.yaml b/lib/I18n/translations/portuguese.yaml index 7cff8584..7ba59f83 100644 --- a/lib/I18n/translations/portuguese.yaml +++ b/lib/I18n/translations/portuguese.yaml @@ -350,3 +350,6 @@ STR_NO_SERVERS: "Nenhum servidor OPDS configurado" STR_DELETE_SERVER: "Excluir servidor" STR_DELETE_CONFIRM: "Excluir este servidor?" STR_OPDS_SERVERS: "Servidores OPDS" +STR_SAVE_HERE: "Salvar aqui" +STR_SELECT_FOLDER: "Selecionar pasta" +STR_DOWNLOAD_PATH: "Caminho de download" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index bc8301fc..52d331b6 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -325,3 +325,6 @@ STR_NO_SERVERS: "Niciun server OPDS configurat" STR_DELETE_SERVER: "Șterge serverul" STR_DELETE_CONFIRM: "Ștergi acest server?" STR_OPDS_SERVERS: "Servere OPDS" +STR_SAVE_HERE: "Salvează aici" +STR_SELECT_FOLDER: "Selectează dosar" +STR_DOWNLOAD_PATH: "Cale descărcare" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index ed198678..7582e173 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -350,3 +350,6 @@ STR_NO_SERVERS: "Нет настроенных серверов OPDS" STR_DELETE_SERVER: "Удалить сервер" STR_DELETE_CONFIRM: "Удалить этот сервер?" STR_OPDS_SERVERS: "Серверы OPDS" +STR_SAVE_HERE: "Сохранить здесь" +STR_SELECT_FOLDER: "Выбрать папку" +STR_DOWNLOAD_PATH: "Путь загрузки" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index ba4431ce..ad7640e9 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -350,3 +350,6 @@ STR_NO_SERVERS: "No hay servidores OPDS configurados" STR_DELETE_SERVER: "Eliminar servidor" STR_DELETE_CONFIRM: "¿Eliminar este servidor?" STR_OPDS_SERVERS: "Servidores OPDS" +STR_SAVE_HERE: "Guardar aquí" +STR_SELECT_FOLDER: "Seleccionar carpeta" +STR_DOWNLOAD_PATH: "Ruta de descarga" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 1ae19c53..50698909 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -350,3 +350,6 @@ STR_NO_SERVERS: "Inga OPDS-servrar konfigurerade" STR_DELETE_SERVER: "Ta bort server" STR_DELETE_CONFIRM: "Ta bort denna server?" STR_OPDS_SERVERS: "OPDS-servrar" +STR_SAVE_HERE: "Spara här" +STR_SELECT_FOLDER: "Välj mapp" +STR_DOWNLOAD_PATH: "Nedladdningssökväg" diff --git a/src/OpdsServerStore.cpp b/src/OpdsServerStore.cpp index e616d167..2d57a22b 100644 --- a/src/OpdsServerStore.cpp +++ b/src/OpdsServerStore.cpp @@ -80,6 +80,7 @@ bool OpdsServerStore::saveToFile() const { obj["url"] = server.url; obj["username"] = server.username; obj["password_obf"] = obfuscateToBase64(server.password); + obj["download_path"] = server.downloadPath; } String json; @@ -114,6 +115,7 @@ bool OpdsServerStore::loadFromFile() { server.password = obj["password"] | std::string(""); if (!server.password.empty()) needsResave = true; } + server.downloadPath = obj["download_path"] | std::string("/"); servers.push_back(std::move(server)); } diff --git a/src/OpdsServerStore.h b/src/OpdsServerStore.h index 67d94f10..49e86a8a 100644 --- a/src/OpdsServerStore.h +++ b/src/OpdsServerStore.h @@ -7,6 +7,7 @@ struct OpdsServer { std::string url; std::string username; std::string password; // Plaintext in memory; obfuscated with hardware key on disk + std::string downloadPath = "/"; }; /** diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index dfccc15d..1a58952e 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -9,6 +9,7 @@ #include "MappedInputManager.h" #include "activities/network/WifiSelectionActivity.h" +#include "activities/util/DirectoryPickerActivity.h" #include "components/UITheme.h" #include "fontIds.h" #include "network/HttpDownloader.h" @@ -52,6 +53,12 @@ void OpdsBookBrowserActivity::loop() { return; } + // Handle directory picker subactivity + if (state == BrowserState::PICKING_DIRECTORY) { + ActivityWithSubactivity::loop(); + return; + } + // Handle error state - Confirm retries, Back goes back or home if (state == BrowserState::ERROR) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { @@ -101,7 +108,7 @@ void OpdsBookBrowserActivity::loop() { if (!entries.empty()) { const auto& entry = entries[selectorIndex]; if (entry.type == OpdsEntryType::BOOK) { - downloadBook(entry); + launchDirectoryPicker(entry); } else { navigateToEntry(entry); } @@ -304,22 +311,45 @@ void OpdsBookBrowserActivity::navigateBack() { } } -void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { +void OpdsBookBrowserActivity::launchDirectoryPicker(const OpdsEntry& book) { + pendingBook = book; + state = BrowserState::PICKING_DIRECTORY; + requestUpdate(); + + enterNewActivity(new DirectoryPickerActivity( + renderer, mappedInput, [this](const std::string& dir) { onDirectorySelected(dir); }, + [this] { onDirectoryPickerCancelled(); }, server.downloadPath)); +} + +void OpdsBookBrowserActivity::onDirectorySelected(const std::string& directory) { + // Copy before exitActivity() destroys the subactivity (and the referenced string) + std::string dir = directory; + exitActivity(); + downloadBook(pendingBook, dir); +} + +void OpdsBookBrowserActivity::onDirectoryPickerCancelled() { + exitActivity(); + state = BrowserState::BROWSING; + requestUpdate(); +} + +void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book, const std::string& directory) { state = BrowserState::DOWNLOADING; statusMessage = book.title; downloadProgress = 0; downloadTotal = 0; requestUpdate(); - // Build full download URL std::string downloadUrl = UrlUtils::buildUrl(server.url, book.href); - // Create sanitized filename: "Title - Author.epub" or just "Title.epub" if no author std::string baseName = book.title; if (!book.author.empty()) { baseName += " - " + book.author; } - std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + ".epub"; + std::string dir = directory; + if (dir.back() != '/') dir += '/'; + std::string filename = dir + StringUtils::sanitizeFilename(baseName) + ".epub"; LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str()); @@ -335,7 +365,6 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { if (result == HttpDownloader::OK) { LOG_DBG("OPDS", "Download complete: %s", filename.c_str()); - // Invalidate any existing cache for this file to prevent stale metadata issues Epub epub(filename, "/.crosspoint"); epub.clearCache(); LOG_DBG("OPDS", "Cleared cache for: %s", filename.c_str()); diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h index 6aaa582e..ea1fe850 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.h +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -17,12 +17,13 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity { public: enum class BrowserState { - CHECK_WIFI, // Checking WiFi connection - WIFI_SELECTION, // WiFi selection subactivity is active - LOADING, // Fetching OPDS feed - BROWSING, // Displaying entries (navigation or books) - DOWNLOADING, // Downloading selected EPUB - ERROR // Error state with message + CHECK_WIFI, // Checking WiFi connection + WIFI_SELECTION, // WiFi selection subactivity is active + LOADING, // Fetching OPDS feed + BROWSING, // Displaying entries (navigation or books) + PICKING_DIRECTORY, // Directory picker subactivity is active + DOWNLOADING, // Downloading selected EPUB + ERROR // Error state with message }; explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, @@ -55,6 +56,11 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity { void fetchFeed(const std::string& path); void navigateToEntry(const OpdsEntry& entry); void navigateBack(); - void downloadBook(const OpdsEntry& book); + void launchDirectoryPicker(const OpdsEntry& book); + void onDirectorySelected(const std::string& directory); + void onDirectoryPickerCancelled(); + void downloadBook(const OpdsEntry& book, const std::string& directory); bool preventAutoSleep() override { return true; } + + OpdsEntry pendingBook; }; diff --git a/src/activities/home/MyLibraryActivity.cpp b/src/activities/home/MyLibraryActivity.cpp index 21414e57..78df7bdb 100644 --- a/src/activities/home/MyLibraryActivity.cpp +++ b/src/activities/home/MyLibraryActivity.cpp @@ -4,8 +4,6 @@ #include #include -#include - #include "BookManageMenuActivity.h" #include "MappedInputManager.h" #include "components/UITheme.h" @@ -17,58 +15,6 @@ namespace { constexpr unsigned long GO_HOME_MS = 1000; } // namespace -void sortFileList(std::vector& strs) { - std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) { - // Directories first - bool isDir1 = str1.back() == '/'; - bool isDir2 = str2.back() == '/'; - if (isDir1 != isDir2) return isDir1; - - // Start naive natural sort - const char* s1 = str1.c_str(); - const char* s2 = str2.c_str(); - - // Iterate while both strings have characters - while (*s1 && *s2) { - // Check if both are at the start of a number - if (isdigit(*s1) && isdigit(*s2)) { - // Skip leading zeros and track them - const char* start1 = s1; - const char* start2 = s2; - while (*s1 == '0') s1++; - while (*s2 == '0') s2++; - - // Count digits to compare lengths first - int len1 = 0, len2 = 0; - while (isdigit(s1[len1])) len1++; - while (isdigit(s2[len2])) len2++; - - // Different length so return smaller integer value - if (len1 != len2) return len1 < len2; - - // Same length so compare digit by digit - for (int i = 0; i < len1; i++) { - if (s1[i] != s2[i]) return s1[i] < s2[i]; - } - - // Numbers equal so advance pointers - s1 += len1; - s2 += len2; - } else { - // Regular case-insensitive character comparison - char c1 = tolower(*s1); - char c2 = tolower(*s2); - if (c1 != c2) return c1 < c2; - s1++; - s2++; - } - } - - // One string is prefix of other - return *s1 == '\0' && *s2 != '\0'; - }); -} - void MyLibraryActivity::loadFiles() { files.clear(); @@ -101,7 +47,7 @@ void MyLibraryActivity::loadFiles() { file.close(); } root.close(); - sortFileList(files); + StringUtils::sortFileList(files); } void MyLibraryActivity::onEnter() { diff --git a/src/activities/settings/OpdsSettingsActivity.cpp b/src/activities/settings/OpdsSettingsActivity.cpp index d78deb13..1346809b 100644 --- a/src/activities/settings/OpdsSettingsActivity.cpp +++ b/src/activities/settings/OpdsSettingsActivity.cpp @@ -7,14 +7,15 @@ #include "MappedInputManager.h" #include "OpdsServerStore.h" +#include "activities/util/DirectoryPickerActivity.h" #include "activities/util/KeyboardEntryActivity.h" #include "components/UITheme.h" #include "fontIds.h" namespace { -// Editable fields: Name, URL, Username, Password. +// Editable fields: Name, URL, Username, Password, Download Path. // Existing servers also show a Delete option (BASE_ITEMS + 1). -constexpr int BASE_ITEMS = 4; +constexpr int BASE_ITEMS = 5; } // namespace int OpdsSettingsActivity::getMenuItemCount() const { @@ -144,7 +145,24 @@ void OpdsSettingsActivity::handleSelection() { exitActivity(); requestUpdate(); })); - } else if (selectedIndex == 4 && !isNewServer) { + } else if (selectedIndex == 4) { + // Download Path + exitActivity(); + enterNewActivity(new DirectoryPickerActivity( + renderer, mappedInput, + [this](const std::string& path) { + std::string dir = path; + editServer.downloadPath = dir; + saveServer(); + exitActivity(); + requestUpdate(); + }, + [this]() { + exitActivity(); + requestUpdate(); + }, + editServer.downloadPath)); + } else if (selectedIndex == 5 && !isNewServer) { // Delete server OPDS_STORE.removeServer(static_cast(serverIndex)); onBack(); @@ -167,7 +185,7 @@ void OpdsSettingsActivity::render(Activity::RenderLock&&) { const int menuItems = getMenuItemCount(); const StrId fieldNames[] = {StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL, StrId::STR_USERNAME, - StrId::STR_PASSWORD}; + StrId::STR_PASSWORD, StrId::STR_DOWNLOAD_PATH}; GUI.drawList( renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast(selectedIndex), @@ -187,6 +205,8 @@ void OpdsSettingsActivity::render(Activity::RenderLock&&) { return editServer.username.empty() ? std::string(tr(STR_NOT_SET)) : editServer.username; } else if (index == 3) { return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******"); + } else if (index == 4) { + return editServer.downloadPath; } return std::string(""); }, diff --git a/src/activities/util/DirectoryPickerActivity.cpp b/src/activities/util/DirectoryPickerActivity.cpp new file mode 100644 index 00000000..a10b4302 --- /dev/null +++ b/src/activities/util/DirectoryPickerActivity.cpp @@ -0,0 +1,166 @@ +#include "DirectoryPickerActivity.h" + +#include +#include + +#include + +#include "MappedInputManager.h" +#include "components/UITheme.h" +#include "fontIds.h" +#include "util/StringUtils.h" + +void DirectoryPickerActivity::onEnter() { + Activity::onEnter(); + + basepath = initialPath; + if (basepath.empty()) basepath = "/"; + + // Validate the initial path exists; fall back to root if not + auto dir = Storage.open(basepath.c_str()); + if (!dir || !dir.isDirectory()) { + if (dir) dir.close(); + basepath = "/"; + } else { + dir.close(); + } + + selectorIndex = 0; + loadDirectories(); + requestUpdate(); +} + +void DirectoryPickerActivity::onExit() { + directories.clear(); + Activity::onExit(); +} + +void DirectoryPickerActivity::loadDirectories() { + directories.clear(); + + auto root = Storage.open(basepath.c_str()); + if (!root || !root.isDirectory()) { + if (root) root.close(); + return; + } + + root.rewindDirectory(); + + char name[256]; + for (auto file = root.openNextFile(); file; file = root.openNextFile()) { + file.getName(name, sizeof(name)); + if (name[0] == '.' || strcmp(name, "System Volume Information") == 0) { + file.close(); + continue; + } + + if (file.isDirectory()) { + directories.emplace_back(std::string(name) + "/"); + } + file.close(); + } + root.close(); + StringUtils::sortFileList(directories); +} + +void DirectoryPickerActivity::loop() { + // Absorb the Confirm release from the parent activity that launched us + if (waitForConfirmRelease) { + if (!mappedInput.isPressed(MappedInputManager::Button::Confirm)) { + waitForConfirmRelease = false; + } + return; + } + + // Index 0 = "Save Here", indices 1..N = directory entries + const int totalItems = 1 + static_cast(directories.size()); + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (selectorIndex == 0) { + onSelect(basepath); + } else { + const auto& dirName = directories[selectorIndex - 1]; + // Strip trailing '/' + std::string folderName = dirName.substr(0, dirName.length() - 1); + basepath = (basepath.back() == '/' ? basepath : basepath + "/") + folderName; + selectorIndex = 0; + loadDirectories(); + requestUpdate(); + } + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + if (basepath == "/") { + onCancel(); + } else { + auto slash = basepath.find_last_of('/'); + basepath = (slash == 0) ? "/" : basepath.substr(0, slash); + selectorIndex = 0; + loadDirectories(); + requestUpdate(); + } + return; + } + + buttonNavigator.onNextRelease([this, totalItems] { + selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems); + requestUpdate(); + }); + + buttonNavigator.onPreviousRelease([this, totalItems] { + selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems); + requestUpdate(); + }); + + const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false); + + buttonNavigator.onNextContinuous([this, totalItems, pageItems] { + selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems); + requestUpdate(); + }); + + buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] { + selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems); + requestUpdate(); + }); +} + +void DirectoryPickerActivity::render(Activity::RenderLock&&) { + renderer.clearScreen(); + + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + std::string folderName = (basepath == "/") ? tr(STR_SD_CARD) : basepath.substr(basepath.rfind('/') + 1); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_SELECT_FOLDER)); + + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing; + + const int totalItems = 1 + static_cast(directories.size()); + + GUI.drawList( + renderer, Rect{0, contentTop, pageWidth, contentHeight}, totalItems, selectorIndex, + [this](int index) -> std::string { + if (index == 0) { + std::string label = std::string(tr(STR_SAVE_HERE)) + " (" + basepath + ")"; + return label; + } + // Strip trailing '/' for display + const auto& dir = directories[index - 1]; + return dir.substr(0, dir.length() - 1); + }, + nullptr, + [this](int index) -> UIIcon { + return (index == 0) ? UIIcon::File : UIIcon::Folder; + }); + + const char* backLabel = (basepath == "/") ? tr(STR_CANCEL) : tr(STR_BACK); + const char* confirmLabel = (selectorIndex == 0) ? tr(STR_SAVE_HERE) : tr(STR_OPEN); + const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/util/DirectoryPickerActivity.h b/src/activities/util/DirectoryPickerActivity.h new file mode 100644 index 00000000..f4eb4aca --- /dev/null +++ b/src/activities/util/DirectoryPickerActivity.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include + +#include "../Activity.h" +#include "util/ButtonNavigator.h" + +/** + * Directory picker subactivity for selecting a save location on the SD card. + * Shows only directories and a "Save Here" option at index 0. + * Navigating into a subdirectory updates the current path; Back goes up. + * Pressing Back at root calls onCancel. + */ +class DirectoryPickerActivity final : public Activity { + public: + explicit DirectoryPickerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + std::function onSelect, + std::function onCancel, + std::string initialPath = "/") + : Activity("DirectoryPicker", renderer, mappedInput), + initialPath(std::move(initialPath)), + onSelect(std::move(onSelect)), + onCancel(std::move(onCancel)) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + + private: + ButtonNavigator buttonNavigator; + std::string initialPath; + std::string basepath = "/"; + std::vector directories; + int selectorIndex = 0; + bool waitForConfirmRelease = true; + + std::function onSelect; + std::function onCancel; + + void loadDirectories(); +}; diff --git a/src/util/StringUtils.cpp b/src/util/StringUtils.cpp index 8e2ce58e..0920158c 100644 --- a/src/util/StringUtils.cpp +++ b/src/util/StringUtils.cpp @@ -1,5 +1,6 @@ #include "StringUtils.h" +#include #include namespace StringUtils { @@ -61,4 +62,43 @@ bool checkFileExtension(const String& fileName, const char* extension) { return localFile.endsWith(localExtension); } +void sortFileList(std::vector& entries) { + std::sort(begin(entries), end(entries), [](const std::string& str1, const std::string& str2) { + bool isDir1 = str1.back() == '/'; + bool isDir2 = str2.back() == '/'; + if (isDir1 != isDir2) return isDir1; + + const char* s1 = str1.c_str(); + const char* s2 = str2.c_str(); + + while (*s1 && *s2) { + if (isdigit(*s1) && isdigit(*s2)) { + while (*s1 == '0') s1++; + while (*s2 == '0') s2++; + + int len1 = 0, len2 = 0; + while (isdigit(s1[len1])) len1++; + while (isdigit(s2[len2])) len2++; + + if (len1 != len2) return len1 < len2; + + for (int i = 0; i < len1; i++) { + if (s1[i] != s2[i]) return s1[i] < s2[i]; + } + + s1 += len1; + s2 += len2; + } else { + char c1 = tolower(*s1); + char c2 = tolower(*s2); + if (c1 != c2) return c1 < c2; + s1++; + s2++; + } + } + + return *s1 == '\0' && *s2 != '\0'; + }); +} + } // namespace StringUtils diff --git a/src/util/StringUtils.h b/src/util/StringUtils.h index 4b93729b..cb26ffa6 100644 --- a/src/util/StringUtils.h +++ b/src/util/StringUtils.h @@ -3,6 +3,7 @@ #include #include +#include namespace StringUtils { @@ -19,4 +20,10 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100); bool checkFileExtension(const std::string& fileName, const char* extension); bool checkFileExtension(const String& fileName, const char* extension); +/** + * Sort a file/directory list with directories first, using case-insensitive natural sort. + * Directory entries are identified by a trailing '/'. + */ +void sortFileList(std::vector& entries); + } // namespace StringUtils