feat: configurable OPDS download folder and filename format (#2571)
This commit is contained in:
@@ -238,6 +238,14 @@ class CrossPointSettings {
|
||||
|
||||
// Reader screen margin settings
|
||||
uint8_t screenMargin = 5;
|
||||
// OPDS download destination folder ("" = SD root). Global; edited from the
|
||||
// OPDS server list. Persisted via a category-less SettingInfo::String in
|
||||
// SettingsList.h, so it stays out of the on-device Settings screen.
|
||||
char opdsDownloadFolder[64] = "";
|
||||
// On-disk filename format for OPDS downloads (0=Author-Title default, 1=Title-Author,
|
||||
// 2=Title). See OpdsFilenameFormat. Persisted via a category-less SettingInfo::Enum,
|
||||
// edited from the OPDS server list; hidden from the on-device Settings screen.
|
||||
uint8_t opdsFilenameFormat = 0;
|
||||
// Hide battery percentage
|
||||
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
||||
// Long-press page turn button behavior
|
||||
|
||||
@@ -194,6 +194,16 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
SettingInfo::Toggle(StrId::STR_MOVE_FINISHED_TO_READ, &CrossPointSettings::moveFinishedToReadFolder,
|
||||
"moveFinishedToReadFolder", StrId::STR_CAT_SYSTEM),
|
||||
|
||||
// OPDS download folder: persisted + web-exposed, but category-less so it
|
||||
// is hidden from the on-device Settings screen (edited via OPDS UI).
|
||||
SettingInfo::String(StrId::STR_OPDS_DOWNLOAD_FOLDER, &SETTINGS.opdsDownloadFolder[0],
|
||||
sizeof(SETTINGS.opdsDownloadFolder), "opdsDownloadFolder"),
|
||||
// OPDS download filename format: persisted + web-exposed, category-less so it
|
||||
// is hidden from the on-device Settings screen (cycled from the OPDS UI).
|
||||
SettingInfo::Enum(StrId::STR_OPDS_FILENAME_FORMAT, &CrossPointSettings::opdsFilenameFormat,
|
||||
{StrId::STR_FMT_AUTHOR_TITLE, StrId::STR_FMT_TITLE_AUTHOR, StrId::STR_FMT_TITLE},
|
||||
"opdsFilenameFormat"),
|
||||
|
||||
// --- KOReader Sync (web-only, uses KOReaderCredentialStore) ---
|
||||
SettingInfo::DynamicString(
|
||||
StrId::STR_KOREADER_USERNAME, [] { return KOREADER_STORE.getUsername(); },
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
#include <OpdsStream.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "SilentRestart.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
@@ -15,6 +17,7 @@
|
||||
#include "fontIds.h"
|
||||
#include "network/HttpDownloader.h"
|
||||
#include "util/BookCacheUtils.h"
|
||||
#include "util/OpdsFilename.h"
|
||||
#include "util/StringUtils.h"
|
||||
#include "util/UrlUtils.h"
|
||||
|
||||
@@ -279,8 +282,26 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
|
||||
// Build full download URL relative to the current feed, not the root server URL
|
||||
const std::string feedUrl = UrlUtils::buildUrl(server.url, currentPath);
|
||||
std::string downloadUrl = UrlUtils::buildUrl(feedUrl, book.href);
|
||||
std::string filename =
|
||||
"/" + StringUtils::sanitizeFilename((book.author.empty() ? "" : book.author + " - ") + book.title) + ".epub";
|
||||
// opdsDownloadFolder is already a null-terminated char[64]; use it directly —
|
||||
// no std::string copy. exists()/mkdir() take const char*.
|
||||
const char* folder = SETTINGS.opdsDownloadFolder; // "" => SD root
|
||||
bool haveFolder = folder[0] != '\0';
|
||||
if (haveFolder && !Storage.exists(folder) && !Storage.mkdir(folder)) {
|
||||
// exists()-guard first: mkdir's return-on-existing is unconfirmed, and every
|
||||
// existing caller checks exists() before mkdir. On real failure, fall back
|
||||
// to SD root so the download is never lost.
|
||||
LOG_ERR("OPDS", "mkdir failed for %s, using SD root", folder);
|
||||
haveFolder = false;
|
||||
}
|
||||
|
||||
// downloadToFile() needs a std::string, and titles are unbounded (a fixed
|
||||
// char[] would truncate). Cold path (a multi-second download follows), so one
|
||||
// reserve'd, in-place-appended owning string is the right call.
|
||||
std::string filename;
|
||||
filename.reserve(96);
|
||||
if (haveFolder) filename += folder;
|
||||
filename += '/';
|
||||
filename += opdsBookFilename(book.author, book.title, static_cast<OpdsFilenameFormat>(SETTINGS.opdsFilenameFormat));
|
||||
LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());
|
||||
|
||||
int lastRenderedPercent = -1;
|
||||
|
||||
@@ -3,19 +3,51 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "OpdsSettingsActivity.h"
|
||||
#include "activities/ActivityManager.h"
|
||||
#include "activities/browser/OpdsBookBrowserActivity.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/OpdsFilename.h"
|
||||
|
||||
namespace {
|
||||
// Normalizes a user-typed folder: trims spaces, "" => SD root, otherwise a
|
||||
// single leading '/' and no trailing '/'. Cold path (runs once per edit).
|
||||
std::string normalizeFolder(std::string v) {
|
||||
while (!v.empty() && (v.front() == ' ' || v.front() == '\t')) v.erase(v.begin());
|
||||
while (!v.empty() && (v.back() == ' ' || v.back() == '\t')) v.pop_back();
|
||||
if (v.empty()) return "";
|
||||
if (v.front() != '/') v.insert(v.begin(), '/');
|
||||
while (v.size() > 1 && v.back() == '/') v.pop_back();
|
||||
if (v == "/") return ""; // a bare slash is SD root, same as empty
|
||||
return v;
|
||||
}
|
||||
|
||||
// Label shown for the current OPDS filename format in the list subtitle.
|
||||
StrId opdsFormatLabel(uint8_t format) {
|
||||
switch (format) {
|
||||
case static_cast<uint8_t>(OpdsFilenameFormat::TitleAuthor):
|
||||
return StrId::STR_FMT_TITLE_AUTHOR;
|
||||
case static_cast<uint8_t>(OpdsFilenameFormat::TitleOnly):
|
||||
return StrId::STR_FMT_TITLE;
|
||||
default:
|
||||
return StrId::STR_FMT_AUTHOR_TITLE;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int OpdsServerListActivity::getItemCount() const {
|
||||
int count = static_cast<int>(OPDS_STORE.getCount());
|
||||
// In settings mode, append a virtual "Add Server" item; in picker mode, only show real servers
|
||||
// Settings mode appends three virtual items: "Add Server", "Download folder"
|
||||
// and "Filename format".
|
||||
if (!pickerMode) {
|
||||
count++;
|
||||
count += 3;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -74,6 +106,34 @@ void OpdsServerListActivity::handleSelection() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Index layout: [servers 0..serverCount-1], [Add Server], [Download folder], [Filename format].
|
||||
if (selectedIndex == serverCount + 1) {
|
||||
auto folderHandler = [this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kb = std::get<KeyboardResult>(result.data);
|
||||
const std::string norm = normalizeFolder(kb.text);
|
||||
strncpy(SETTINGS.opdsDownloadFolder, norm.c_str(), sizeof(SETTINGS.opdsDownloadFolder) - 1);
|
||||
SETTINGS.opdsDownloadFolder[sizeof(SETTINGS.opdsDownloadFolder) - 1] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
requestUpdate();
|
||||
}
|
||||
};
|
||||
startActivityForResult(
|
||||
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_OPDS_DOWNLOAD_FOLDER),
|
||||
std::string(SETTINGS.opdsDownloadFolder), 63, InputType::Text),
|
||||
folderHandler);
|
||||
return;
|
||||
}
|
||||
|
||||
// "Filename format": tap cycles through the available formats.
|
||||
if (selectedIndex == serverCount + 2) {
|
||||
SETTINGS.opdsFilenameFormat =
|
||||
static_cast<uint8_t>((SETTINGS.opdsFilenameFormat + 1) % static_cast<uint8_t>(OpdsFilenameFormat::Count));
|
||||
SETTINGS.saveToFile();
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Settings mode: open editor for selected server, or create a new one
|
||||
auto resultHandler = [this](const ActivityResult&) {
|
||||
// Reload server list when returning from editor
|
||||
@@ -111,17 +171,30 @@ void OpdsServerListActivity::render(RenderLock&&) {
|
||||
// Secondary label: server URL (shown as subtitle when name is set).
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, itemCount, selectedIndex,
|
||||
[&servers, serverCount](int index) {
|
||||
[&servers, serverCount](int index) -> std::string {
|
||||
if (index < serverCount) {
|
||||
const auto& server = servers[index];
|
||||
return server.name.empty() ? server.url : server.name;
|
||||
}
|
||||
return std::string(I18n::getInstance().get(StrId::STR_ADD_SERVER));
|
||||
if (index == serverCount) {
|
||||
return std::string(I18n::getInstance().get(StrId::STR_ADD_SERVER));
|
||||
}
|
||||
if (index == serverCount + 1) {
|
||||
return std::string(I18n::getInstance().get(StrId::STR_OPDS_DOWNLOAD_FOLDER));
|
||||
}
|
||||
return std::string(I18n::getInstance().get(StrId::STR_OPDS_FILENAME_FORMAT));
|
||||
},
|
||||
[&servers, serverCount](int index) {
|
||||
[&servers, serverCount](int index) -> std::string {
|
||||
if (index < serverCount && !servers[index].name.empty()) {
|
||||
return servers[index].url;
|
||||
}
|
||||
if (index == serverCount + 1) {
|
||||
const char* f = SETTINGS.opdsDownloadFolder;
|
||||
return f[0] ? std::string(f) : std::string(I18n::getInstance().get(StrId::STR_OPDS_SD_ROOT));
|
||||
}
|
||||
if (index == serverCount + 2) {
|
||||
return std::string(I18n::getInstance().get(opdsFormatLabel(SETTINGS.opdsFilenameFormat)));
|
||||
}
|
||||
return std::string("");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
#include "OpdsFilename.h"
|
||||
|
||||
#include "StringUtils.h"
|
||||
|
||||
std::string opdsBookFilename(const std::string& author, const std::string& title, OpdsFilenameFormat format) {
|
||||
std::string base;
|
||||
switch (format) {
|
||||
case OpdsFilenameFormat::TitleAuthor:
|
||||
base = author.empty() ? title : title + " - " + author;
|
||||
break;
|
||||
case OpdsFilenameFormat::TitleOnly:
|
||||
base = title;
|
||||
break;
|
||||
case OpdsFilenameFormat::AuthorTitle:
|
||||
default:
|
||||
base = author.empty() ? title : author + " - " + title;
|
||||
break;
|
||||
}
|
||||
// sanitizeFilename caps at 100 bytes and never returns empty (falls back to
|
||||
// "book"); ".epub" is appended after so the extension is never truncated —
|
||||
// identical treatment to the previous inline construction.
|
||||
return StringUtils::sanitizeFilename(base) + ".epub";
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
// On-disk filename format for books downloaded from an OPDS server. Stored as a
|
||||
// uint8_t in CrossPointSettings; cast to this enum at the call sites. `Count` is
|
||||
// the number of selectable formats (used to cycle the setting in the UI).
|
||||
enum class OpdsFilenameFormat : uint8_t {
|
||||
AuthorTitle = 0, // "Author - Title.epub" (default; matches legacy behaviour)
|
||||
TitleAuthor = 1, // "Title - Author.epub"
|
||||
TitleOnly = 2, // "Title.epub"
|
||||
Count = 3,
|
||||
};
|
||||
|
||||
// Composes and sanitizes the on-disk filename (including the ".epub" extension)
|
||||
// for a downloaded OPDS book, according to `format`. When the author is empty,
|
||||
// every format collapses to just the sanitized title. Pure: no I/O, no globals.
|
||||
std::string opdsBookFilename(const std::string& author, const std::string& title, OpdsFilenameFormat format);
|
||||
Reference in New Issue
Block a user