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:
@@ -422,6 +422,9 @@ enum class StrId : uint16_t {
|
|||||||
STR_DELETE_SERVER,
|
STR_DELETE_SERVER,
|
||||||
STR_DELETE_CONFIRM,
|
STR_DELETE_CONFIRM,
|
||||||
STR_OPDS_SERVERS,
|
STR_OPDS_SERVERS,
|
||||||
|
STR_SAVE_HERE,
|
||||||
|
STR_SELECT_FOLDER,
|
||||||
|
STR_DOWNLOAD_PATH,
|
||||||
// Sentinel - must be last
|
// Sentinel - must be last
|
||||||
_COUNT
|
_COUNT
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -350,3 +350,6 @@ STR_NO_SERVERS: "Žádné OPDS servery nejsou nakonfigurovány"
|
|||||||
STR_DELETE_SERVER: "Smazat server"
|
STR_DELETE_SERVER: "Smazat server"
|
||||||
STR_DELETE_CONFIRM: "Smazat tento server?"
|
STR_DELETE_CONFIRM: "Smazat tento server?"
|
||||||
STR_OPDS_SERVERS: "OPDS servery"
|
STR_OPDS_SERVERS: "OPDS servery"
|
||||||
|
STR_SAVE_HERE: "Uložit zde"
|
||||||
|
STR_SELECT_FOLDER: "Vybrat složku"
|
||||||
|
STR_DOWNLOAD_PATH: "Cesta ke stažení"
|
||||||
|
|||||||
@@ -386,3 +386,6 @@ STR_NO_SERVERS: "No OPDS servers configured"
|
|||||||
STR_DELETE_SERVER: "Delete Server"
|
STR_DELETE_SERVER: "Delete Server"
|
||||||
STR_DELETE_CONFIRM: "Delete this server?"
|
STR_DELETE_CONFIRM: "Delete this server?"
|
||||||
STR_OPDS_SERVERS: "OPDS Servers"
|
STR_OPDS_SERVERS: "OPDS Servers"
|
||||||
|
STR_SAVE_HERE: "Save Here"
|
||||||
|
STR_SELECT_FOLDER: "Select Folder"
|
||||||
|
STR_DOWNLOAD_PATH: "Download Path"
|
||||||
|
|||||||
@@ -350,3 +350,6 @@ STR_NO_SERVERS: "Aucun serveur OPDS configuré"
|
|||||||
STR_DELETE_SERVER: "Supprimer le serveur"
|
STR_DELETE_SERVER: "Supprimer le serveur"
|
||||||
STR_DELETE_CONFIRM: "Supprimer ce serveur ?"
|
STR_DELETE_CONFIRM: "Supprimer ce serveur ?"
|
||||||
STR_OPDS_SERVERS: "Serveurs OPDS"
|
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"
|
||||||
|
|||||||
@@ -350,3 +350,6 @@ STR_NO_SERVERS: "Keine OPDS-Server konfiguriert"
|
|||||||
STR_DELETE_SERVER: "Server löschen"
|
STR_DELETE_SERVER: "Server löschen"
|
||||||
STR_DELETE_CONFIRM: "Diesen Server löschen?"
|
STR_DELETE_CONFIRM: "Diesen Server löschen?"
|
||||||
STR_OPDS_SERVERS: "OPDS-Server"
|
STR_OPDS_SERVERS: "OPDS-Server"
|
||||||
|
STR_SAVE_HERE: "Hier speichern"
|
||||||
|
STR_SELECT_FOLDER: "Ordner auswählen"
|
||||||
|
STR_DOWNLOAD_PATH: "Download-Pfad"
|
||||||
|
|||||||
@@ -350,3 +350,6 @@ STR_NO_SERVERS: "Nenhum servidor OPDS configurado"
|
|||||||
STR_DELETE_SERVER: "Excluir servidor"
|
STR_DELETE_SERVER: "Excluir servidor"
|
||||||
STR_DELETE_CONFIRM: "Excluir este servidor?"
|
STR_DELETE_CONFIRM: "Excluir este servidor?"
|
||||||
STR_OPDS_SERVERS: "Servidores OPDS"
|
STR_OPDS_SERVERS: "Servidores OPDS"
|
||||||
|
STR_SAVE_HERE: "Salvar aqui"
|
||||||
|
STR_SELECT_FOLDER: "Selecionar pasta"
|
||||||
|
STR_DOWNLOAD_PATH: "Caminho de download"
|
||||||
|
|||||||
@@ -325,3 +325,6 @@ STR_NO_SERVERS: "Niciun server OPDS configurat"
|
|||||||
STR_DELETE_SERVER: "Șterge serverul"
|
STR_DELETE_SERVER: "Șterge serverul"
|
||||||
STR_DELETE_CONFIRM: "Ștergi acest server?"
|
STR_DELETE_CONFIRM: "Ștergi acest server?"
|
||||||
STR_OPDS_SERVERS: "Servere OPDS"
|
STR_OPDS_SERVERS: "Servere OPDS"
|
||||||
|
STR_SAVE_HERE: "Salvează aici"
|
||||||
|
STR_SELECT_FOLDER: "Selectează dosar"
|
||||||
|
STR_DOWNLOAD_PATH: "Cale descărcare"
|
||||||
|
|||||||
@@ -350,3 +350,6 @@ STR_NO_SERVERS: "Нет настроенных серверов OPDS"
|
|||||||
STR_DELETE_SERVER: "Удалить сервер"
|
STR_DELETE_SERVER: "Удалить сервер"
|
||||||
STR_DELETE_CONFIRM: "Удалить этот сервер?"
|
STR_DELETE_CONFIRM: "Удалить этот сервер?"
|
||||||
STR_OPDS_SERVERS: "Серверы OPDS"
|
STR_OPDS_SERVERS: "Серверы OPDS"
|
||||||
|
STR_SAVE_HERE: "Сохранить здесь"
|
||||||
|
STR_SELECT_FOLDER: "Выбрать папку"
|
||||||
|
STR_DOWNLOAD_PATH: "Путь загрузки"
|
||||||
|
|||||||
@@ -350,3 +350,6 @@ STR_NO_SERVERS: "No hay servidores OPDS configurados"
|
|||||||
STR_DELETE_SERVER: "Eliminar servidor"
|
STR_DELETE_SERVER: "Eliminar servidor"
|
||||||
STR_DELETE_CONFIRM: "¿Eliminar este servidor?"
|
STR_DELETE_CONFIRM: "¿Eliminar este servidor?"
|
||||||
STR_OPDS_SERVERS: "Servidores OPDS"
|
STR_OPDS_SERVERS: "Servidores OPDS"
|
||||||
|
STR_SAVE_HERE: "Guardar aquí"
|
||||||
|
STR_SELECT_FOLDER: "Seleccionar carpeta"
|
||||||
|
STR_DOWNLOAD_PATH: "Ruta de descarga"
|
||||||
|
|||||||
@@ -350,3 +350,6 @@ STR_NO_SERVERS: "Inga OPDS-servrar konfigurerade"
|
|||||||
STR_DELETE_SERVER: "Ta bort server"
|
STR_DELETE_SERVER: "Ta bort server"
|
||||||
STR_DELETE_CONFIRM: "Ta bort denna server?"
|
STR_DELETE_CONFIRM: "Ta bort denna server?"
|
||||||
STR_OPDS_SERVERS: "OPDS-servrar"
|
STR_OPDS_SERVERS: "OPDS-servrar"
|
||||||
|
STR_SAVE_HERE: "Spara här"
|
||||||
|
STR_SELECT_FOLDER: "Välj mapp"
|
||||||
|
STR_DOWNLOAD_PATH: "Nedladdningssökväg"
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ bool OpdsServerStore::saveToFile() const {
|
|||||||
obj["url"] = server.url;
|
obj["url"] = server.url;
|
||||||
obj["username"] = server.username;
|
obj["username"] = server.username;
|
||||||
obj["password_obf"] = obfuscateToBase64(server.password);
|
obj["password_obf"] = obfuscateToBase64(server.password);
|
||||||
|
obj["download_path"] = server.downloadPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
String json;
|
String json;
|
||||||
@@ -114,6 +115,7 @@ bool OpdsServerStore::loadFromFile() {
|
|||||||
server.password = obj["password"] | std::string("");
|
server.password = obj["password"] | std::string("");
|
||||||
if (!server.password.empty()) needsResave = true;
|
if (!server.password.empty()) needsResave = true;
|
||||||
}
|
}
|
||||||
|
server.downloadPath = obj["download_path"] | std::string("/");
|
||||||
servers.push_back(std::move(server));
|
servers.push_back(std::move(server));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ struct OpdsServer {
|
|||||||
std::string url;
|
std::string url;
|
||||||
std::string username;
|
std::string username;
|
||||||
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
|
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
|
||||||
|
std::string downloadPath = "/";
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "activities/network/WifiSelectionActivity.h"
|
#include "activities/network/WifiSelectionActivity.h"
|
||||||
|
#include "activities/util/DirectoryPickerActivity.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
#include "network/HttpDownloader.h"
|
#include "network/HttpDownloader.h"
|
||||||
@@ -52,6 +53,12 @@ void OpdsBookBrowserActivity::loop() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle directory picker subactivity
|
||||||
|
if (state == BrowserState::PICKING_DIRECTORY) {
|
||||||
|
ActivityWithSubactivity::loop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle error state - Confirm retries, Back goes back or home
|
// Handle error state - Confirm retries, Back goes back or home
|
||||||
if (state == BrowserState::ERROR) {
|
if (state == BrowserState::ERROR) {
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
@@ -101,7 +108,7 @@ void OpdsBookBrowserActivity::loop() {
|
|||||||
if (!entries.empty()) {
|
if (!entries.empty()) {
|
||||||
const auto& entry = entries[selectorIndex];
|
const auto& entry = entries[selectorIndex];
|
||||||
if (entry.type == OpdsEntryType::BOOK) {
|
if (entry.type == OpdsEntryType::BOOK) {
|
||||||
downloadBook(entry);
|
launchDirectoryPicker(entry);
|
||||||
} else {
|
} else {
|
||||||
navigateToEntry(entry);
|
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;
|
state = BrowserState::DOWNLOADING;
|
||||||
statusMessage = book.title;
|
statusMessage = book.title;
|
||||||
downloadProgress = 0;
|
downloadProgress = 0;
|
||||||
downloadTotal = 0;
|
downloadTotal = 0;
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
|
|
||||||
// Build full download URL
|
|
||||||
std::string downloadUrl = UrlUtils::buildUrl(server.url, book.href);
|
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;
|
std::string baseName = book.title;
|
||||||
if (!book.author.empty()) {
|
if (!book.author.empty()) {
|
||||||
baseName += " - " + book.author;
|
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());
|
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) {
|
if (result == HttpDownloader::OK) {
|
||||||
LOG_DBG("OPDS", "Download complete: %s", filename.c_str());
|
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 epub(filename, "/.crosspoint");
|
||||||
epub.clearCache();
|
epub.clearCache();
|
||||||
LOG_DBG("OPDS", "Cleared cache for: %s", filename.c_str());
|
LOG_DBG("OPDS", "Cleared cache for: %s", filename.c_str());
|
||||||
|
|||||||
@@ -17,12 +17,13 @@
|
|||||||
class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
|
class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
|
||||||
public:
|
public:
|
||||||
enum class BrowserState {
|
enum class BrowserState {
|
||||||
CHECK_WIFI, // Checking WiFi connection
|
CHECK_WIFI, // Checking WiFi connection
|
||||||
WIFI_SELECTION, // WiFi selection subactivity is active
|
WIFI_SELECTION, // WiFi selection subactivity is active
|
||||||
LOADING, // Fetching OPDS feed
|
LOADING, // Fetching OPDS feed
|
||||||
BROWSING, // Displaying entries (navigation or books)
|
BROWSING, // Displaying entries (navigation or books)
|
||||||
DOWNLOADING, // Downloading selected EPUB
|
PICKING_DIRECTORY, // Directory picker subactivity is active
|
||||||
ERROR // Error state with message
|
DOWNLOADING, // Downloading selected EPUB
|
||||||
|
ERROR // Error state with message
|
||||||
};
|
};
|
||||||
|
|
||||||
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
@@ -55,6 +56,11 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
|
|||||||
void fetchFeed(const std::string& path);
|
void fetchFeed(const std::string& path);
|
||||||
void navigateToEntry(const OpdsEntry& entry);
|
void navigateToEntry(const OpdsEntry& entry);
|
||||||
void navigateBack();
|
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; }
|
bool preventAutoSleep() override { return true; }
|
||||||
|
|
||||||
|
OpdsEntry pendingBook;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,8 +4,6 @@
|
|||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
#include <I18n.h>
|
#include <I18n.h>
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
|
|
||||||
#include "BookManageMenuActivity.h"
|
#include "BookManageMenuActivity.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
@@ -17,58 +15,6 @@ namespace {
|
|||||||
constexpr unsigned long GO_HOME_MS = 1000;
|
constexpr unsigned long GO_HOME_MS = 1000;
|
||||||
} // namespace
|
} // 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() {
|
void MyLibraryActivity::loadFiles() {
|
||||||
files.clear();
|
files.clear();
|
||||||
|
|
||||||
@@ -101,7 +47,7 @@ void MyLibraryActivity::loadFiles() {
|
|||||||
file.close();
|
file.close();
|
||||||
}
|
}
|
||||||
root.close();
|
root.close();
|
||||||
sortFileList(files);
|
StringUtils::sortFileList(files);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MyLibraryActivity::onEnter() {
|
void MyLibraryActivity::onEnter() {
|
||||||
|
|||||||
@@ -7,14 +7,15 @@
|
|||||||
|
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "OpdsServerStore.h"
|
#include "OpdsServerStore.h"
|
||||||
|
#include "activities/util/DirectoryPickerActivity.h"
|
||||||
#include "activities/util/KeyboardEntryActivity.h"
|
#include "activities/util/KeyboardEntryActivity.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
namespace {
|
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).
|
// Existing servers also show a Delete option (BASE_ITEMS + 1).
|
||||||
constexpr int BASE_ITEMS = 4;
|
constexpr int BASE_ITEMS = 5;
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int OpdsSettingsActivity::getMenuItemCount() const {
|
int OpdsSettingsActivity::getMenuItemCount() const {
|
||||||
@@ -144,7 +145,24 @@ void OpdsSettingsActivity::handleSelection() {
|
|||||||
exitActivity();
|
exitActivity();
|
||||||
requestUpdate();
|
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
|
// Delete server
|
||||||
OPDS_STORE.removeServer(static_cast<size_t>(serverIndex));
|
OPDS_STORE.removeServer(static_cast<size_t>(serverIndex));
|
||||||
onBack();
|
onBack();
|
||||||
@@ -167,7 +185,7 @@ void OpdsSettingsActivity::render(Activity::RenderLock&&) {
|
|||||||
const int menuItems = getMenuItemCount();
|
const int menuItems = getMenuItemCount();
|
||||||
|
|
||||||
const StrId fieldNames[] = {StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL, StrId::STR_USERNAME,
|
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(
|
GUI.drawList(
|
||||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast<int>(selectedIndex),
|
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;
|
return editServer.username.empty() ? std::string(tr(STR_NOT_SET)) : editServer.username;
|
||||||
} else if (index == 3) {
|
} else if (index == 3) {
|
||||||
return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
|
return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
|
||||||
|
} else if (index == 4) {
|
||||||
|
return editServer.downloadPath;
|
||||||
}
|
}
|
||||||
return std::string("");
|
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();
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "StringUtils.h"
|
#include "StringUtils.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
namespace StringUtils {
|
namespace StringUtils {
|
||||||
@@ -61,4 +62,43 @@ bool checkFileExtension(const String& fileName, const char* extension) {
|
|||||||
return localFile.endsWith(localExtension);
|
return localFile.endsWith(localExtension);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void sortFileList(std::vector<std::string>& 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
|
} // namespace StringUtils
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <WString.h>
|
#include <WString.h>
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace StringUtils {
|
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 std::string& fileName, const char* extension);
|
||||||
bool checkFileExtension(const 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<std::string>& entries);
|
||||||
|
|
||||||
} // namespace StringUtils
|
} // namespace StringUtils
|
||||||
|
|||||||
Reference in New Issue
Block a user