feat: configurable OPDS download folder and filename format (#2571)

This commit is contained in:
Oscar Nogueira Neto
2026-07-15 10:53:27 -04:00
committed by GitHub
parent b9867b0d09
commit 95d8cb712b
35 changed files with 362 additions and 7 deletions
+8
View File
@@ -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
+10
View File
@@ -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("");
});
}
+23
View File
@@ -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";
}
+18
View File
@@ -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);