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
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#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<std::string>& 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() {
|
||||
|
||||
@@ -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<size_t>(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<int>(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("");
|
||||
},
|
||||
|
||||
166
src/activities/util/DirectoryPickerActivity.cpp
Normal file
166
src/activities/util/DirectoryPickerActivity.cpp
Normal file
@@ -0,0 +1,166 @@
|
||||
#include "DirectoryPickerActivity.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#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<int>(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<int>(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();
|
||||
}
|
||||
44
src/activities/util/DirectoryPickerActivity.h
Normal file
44
src/activities/util/DirectoryPickerActivity.h
Normal file
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<void(const std::string& path)> onSelect,
|
||||
std::function<void()> 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<std::string> directories;
|
||||
int selectorIndex = 0;
|
||||
bool waitForConfirmRelease = true;
|
||||
|
||||
std::function<void(const std::string& path)> onSelect;
|
||||
std::function<void()> onCancel;
|
||||
|
||||
void loadDirectories();
|
||||
};
|
||||
Reference in New Issue
Block a user