diff --git a/USER_GUIDE.md b/USER_GUIDE.md index c918f0cba..f79d35d89 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -20,7 +20,8 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control - [3.6.2 Reader](#362-reader) - [3.6.3 Controls](#363-controls) - [3.6.4 System](#364-system) - - [3.6.5 KOReader Sync Quick Setup](#365-koreader-sync-quick-setup) + - [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries) + - [3.6.6 KOReader Sync Quick Setup](#366-koreader-sync-quick-setup) - [3.7 Sleep Screen](#37-sleep-screen) - [4. Reading Mode](#4-reading-mode) - [Page Turning](#page-turning) @@ -194,12 +195,35 @@ The Settings screen allows you to configure the device's behavior. There are a f - **WiFi Networks**: Connect to WiFi networks for file transfers and firmware updates. - **KOReader Sync**: Options for setting up KOReader for syncing book progress. -- **OPDS Browser**: Configure OPDS server settings for browsing and downloading books. Set the server URL (for Calibre Content Server, add `/opds` to the end), and optionally configure username and password for servers requiring authentication. Note: Only HTTP Basic authentication is supported. If using Calibre Content Server with authentication enabled, you must set it to use Basic authentication instead of the default Digest authentication. +- **OPDS Servers**: Manage one or more OPDS libraries for browsing and downloading books. See [OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries) below. - **Clear Reading Cache**: Clear the internal SD card cache. - **Check for updates**: Check for Crosspoint firmware updates over WiFi. - **Language**: Set the system language (see **[Supported Languages](#supported-languages)** for more information). -#### 3.6.5 KOReader Sync Quick Setup +#### 3.6.5 OPDS Servers (Multiple Libraries) + +CrossPoint supports saving multiple OPDS servers and switching between them when browsing catalogs. + +1. Open **Settings -> System -> OPDS Servers**. +2. Select **Add Server** to create a new entry, or select an existing server to edit it. +3. Configure these fields: + - **Server Name**: Optional display name (for example, "Home Calibre" or "Public Catalog"). + - **OPDS Server URL**: Full catalog root URL (for Calibre Content Server, usually ends with `/opds`). + - **Username / Password**: Optional credentials for authenticated servers. +4. Use **Delete Server** inside a server entry to remove it. + +Behavior notes: + +- You can store up to 8 OPDS servers. +- OPDS authentication supports HTTP Basic auth. If you use Calibre Content Server with authentication enabled, set it to Basic (not Digest). + +You can also manage OPDS servers from the web interface while in File Transfer mode: + +1. Connect to the device web UI. +2. Open `http:///settings`. +3. Use the **OPDS Servers** card to add, edit, or delete entries. + +#### 3.6.6 KOReader Sync Quick Setup CrossPoint can sync reading progress with KOReader-compatible sync servers. It also interoperates with KOReader apps/devices when they use the same server and credentials. diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 08979da72..64316bcf4 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -292,6 +292,12 @@ STR_FOOTNOTES: "Footnotes" STR_NO_FOOTNOTES: "No footnotes on this page" STR_LINK: "[link]" STR_SCREENSHOT_BUTTON: "Take screenshot" +STR_ADD_SERVER: "Add Server" +STR_SERVER_NAME: "Server Name" +STR_NO_SERVERS: "No OPDS servers configured" +STR_DELETE_SERVER: "Delete Server" +STR_DELETE_CONFIRM: "Delete this server?" +STR_OPDS_SERVERS: "OPDS Servers" STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: " STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)" STR_CRASH_TITLE: "System Crash" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 81c25a9d9..bb5f263bb 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -290,3 +290,9 @@ STR_OPDS_SERVER_URL: "URL OPDS сервера" STR_SCREENSHOT_BUTTON: "Сделать снимок экрана" STR_AUTO_TURN_ENABLED: "Автоперелистывание: " STR_AUTO_TURN_PAGES_PER_MIN: "Автоперелистывание (стр./мин)" +STR_ADD_SERVER: "Добавить сервер" +STR_SERVER_NAME: "Имя сервера" +STR_NO_SERVERS: "Нет настроенных серверов OPDS" +STR_DELETE_SERVER: "Удалить сервер" +STR_DELETE_CONFIRM: "Удалить этот сервер?" +STR_OPDS_SERVERS: "Серверы OPDS" diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index eeb454dff..487ac6f32 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -11,6 +11,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "KOReaderCredentialStore.h" +#include "OpdsServerStore.h" #include "RecentBooksStore.h" #include "SettingsList.h" #include "WifiCredentialStore.h" @@ -351,3 +352,56 @@ bool JsonSettingsIO::loadRecentBooks(RecentBooksStore& store, const char* json) LOG_DBG("RBS", "Recent books loaded from file (%d entries)", store.getCount()); return true; } + +// ---- OpdsServerStore ---- +// Follows the same save/load pattern as WifiCredentialStore above. +// Passwords are XOR-obfuscated with the device MAC and base64-encoded ("password_obf" key). + +bool JsonSettingsIO::saveOpds(const OpdsServerStore& store, const char* path) { + JsonDocument doc; + + JsonArray arr = doc["servers"].to(); + for (const auto& server : store.getServers()) { + JsonObject obj = arr.add(); + obj["name"] = server.name; + obj["url"] = server.url; + obj["username"] = server.username; + obj["password_obf"] = obfuscation::obfuscateToBase64(server.password); + } + + String json; + serializeJson(doc, json); + return Storage.writeFile(path, json); +} + +bool JsonSettingsIO::loadOpds(OpdsServerStore& store, const char* json, bool* needsResave) { + if (needsResave) *needsResave = false; + JsonDocument doc; + auto error = deserializeJson(doc, json); + if (error) { + LOG_ERR("OPS", "JSON parse error: %s", error.c_str()); + return false; + } + + store.servers.clear(); + JsonArray arr = doc["servers"].as(); + for (JsonObject obj : arr) { + if (store.servers.size() >= OpdsServerStore::MAX_SERVERS) break; + OpdsServer server; + server.name = obj["name"] | std::string(""); + server.url = obj["url"] | std::string(""); + server.username = obj["username"] | std::string(""); + // Try the obfuscated key first; fall back to plaintext "password" for + // files written before obfuscation was added (or hand-edited JSON). + bool ok = false; + server.password = obfuscation::deobfuscateFromBase64(obj["password_obf"] | "", &ok); + if (!ok || server.password.empty()) { + server.password = obj["password"] | std::string(""); + if (!server.password.empty() && needsResave) *needsResave = true; + } + store.servers.push_back(std::move(server)); + } + + LOG_DBG("OPS", "Loaded %zu OPDS servers from file", store.servers.size()); + return true; +} diff --git a/src/JsonSettingsIO.h b/src/JsonSettingsIO.h index f1528565d..40d6c4d2d 100644 --- a/src/JsonSettingsIO.h +++ b/src/JsonSettingsIO.h @@ -5,6 +5,7 @@ class CrossPointState; class WifiCredentialStore; class KOReaderCredentialStore; class RecentBooksStore; +class OpdsServerStore; namespace JsonSettingsIO { @@ -28,4 +29,8 @@ bool loadKOReader(KOReaderCredentialStore& store, const char* json, bool* needsR bool saveRecentBooks(const RecentBooksStore& store, const char* path); bool loadRecentBooks(RecentBooksStore& store, const char* json); +// OpdsServerStore +bool saveOpds(const OpdsServerStore& store, const char* path); +bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave = nullptr); + } // namespace JsonSettingsIO diff --git a/src/OpdsServerStore.cpp b/src/OpdsServerStore.cpp new file mode 100644 index 000000000..b21516828 --- /dev/null +++ b/src/OpdsServerStore.cpp @@ -0,0 +1,110 @@ +#include "OpdsServerStore.h" + +#include +#include +#include + +#include + +#include "CrossPointSettings.h" + +OpdsServerStore OpdsServerStore::instance; + +namespace { +constexpr char OPDS_FILE_JSON[] = "/.crosspoint/opds.json"; +} // namespace + +bool OpdsServerStore::saveToFile() const { + Storage.mkdir("/.crosspoint"); + return JsonSettingsIO::saveOpds(*this, OPDS_FILE_JSON); +} + +bool OpdsServerStore::loadFromFile() { + if (Storage.exists(OPDS_FILE_JSON)) { + String json = Storage.readFile(OPDS_FILE_JSON); + if (!json.isEmpty()) { + // resave flag is set when passwords were stored in plaintext and need re-obfuscation + bool resave = false; + bool result = JsonSettingsIO::loadOpds(*this, json.c_str(), &resave); + if (result && resave) { + LOG_DBG("OPS", "Resaving JSON with obfuscated passwords"); + saveToFile(); + } + return result; + } + } + + // No opds.json found — attempt one-time migration from the legacy single-server + // fields in CrossPointSettings (opdsServerUrl/opdsUsername/opdsPassword). + if (migrateFromSettings()) { + LOG_DBG("OPS", "Migrated legacy OPDS settings"); + return true; + } + + return false; +} + +bool OpdsServerStore::migrateFromSettings() { + if (strlen(SETTINGS.opdsServerUrl) == 0) { + return false; + } + + OpdsServer server; + server.name = "OPDS Server"; + server.url = SETTINGS.opdsServerUrl; + server.username = SETTINGS.opdsUsername; + server.password = SETTINGS.opdsPassword; + servers.push_back(std::move(server)); + + if (saveToFile()) { + // Clear legacy fields so migration won't run again on next boot + SETTINGS.opdsServerUrl[0] = '\0'; + SETTINGS.opdsUsername[0] = '\0'; + SETTINGS.opdsPassword[0] = '\0'; + SETTINGS.saveToFile(); + LOG_DBG("OPS", "Migrated single-server OPDS config to opds.json"); + return true; + } + + // Save failed — roll back in-memory state so we don't have a partial migration + servers.clear(); + return false; +} + +bool OpdsServerStore::addServer(const OpdsServer& server) { + if (servers.size() >= MAX_SERVERS) { + LOG_DBG("OPS", "Cannot add more servers, limit of %zu reached", MAX_SERVERS); + return false; + } + + servers.push_back(server); + LOG_DBG("OPS", "Added server: %s", server.name.c_str()); + return saveToFile(); +} + +bool OpdsServerStore::updateServer(size_t index, const OpdsServer& server) { + if (index >= servers.size()) { + return false; + } + + servers[index] = server; + LOG_DBG("OPS", "Updated server: %s", server.name.c_str()); + return saveToFile(); +} + +bool OpdsServerStore::removeServer(size_t index) { + if (index >= servers.size()) { + return false; + } + + LOG_DBG("OPS", "Removed server: %s", servers[index].name.c_str()); + servers.erase(servers.begin() + static_cast(index)); + return saveToFile(); +} + +const OpdsServer* OpdsServerStore::getServer(size_t index) const { + if (index >= servers.size()) { + return nullptr; + } + return &servers[index]; +} diff --git a/src/OpdsServerStore.h b/src/OpdsServerStore.h new file mode 100644 index 000000000..87571f651 --- /dev/null +++ b/src/OpdsServerStore.h @@ -0,0 +1,60 @@ +#pragma once +#include +#include + +struct OpdsServer { + std::string name; + std::string url; + std::string username; + std::string password; // Plaintext in memory; obfuscated with hardware key on disk +}; + +class OpdsServerStore; +namespace JsonSettingsIO { +bool saveOpds(const OpdsServerStore& store, const char* path); +bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave); +} // namespace JsonSettingsIO + +/** + * Singleton class for storing OPDS server configurations on the SD card. + * Passwords are XOR-obfuscated with the device's unique hardware MAC address + * and base64-encoded before writing to JSON. + */ +class OpdsServerStore { + private: + static OpdsServerStore instance; + std::vector servers; + + static constexpr size_t MAX_SERVERS = 8; + + OpdsServerStore() = default; + + friend bool JsonSettingsIO::saveOpds(const OpdsServerStore&, const char*); + friend bool JsonSettingsIO::loadOpds(OpdsServerStore&, const char*, bool*); + + public: + OpdsServerStore(const OpdsServerStore&) = delete; + OpdsServerStore& operator=(const OpdsServerStore&) = delete; + + static OpdsServerStore& getInstance() { return instance; } + + bool saveToFile() const; + bool loadFromFile(); + + bool addServer(const OpdsServer& server); + bool updateServer(size_t index, const OpdsServer& server); + bool removeServer(size_t index); + + const std::vector& getServers() const { return servers; } + const OpdsServer* getServer(size_t index) const; + size_t getCount() const { return servers.size(); } + bool hasServers() const { return !servers.empty(); } + + /** + * Migrate from legacy single-server settings in CrossPointSettings. + * Called once during first load if no opds.json exists. + */ + bool migrateFromSettings(); +}; + +#define OPDS_STORE OpdsServerStore::getInstance() diff --git a/src/SettingsList.h b/src/SettingsList.h index 72f19dc3c..70b4c4a36 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -111,15 +111,6 @@ inline const std::vector& getSettingsList() { KOREADER_STORE.saveToFile(); }, "koMatchMethod", StrId::STR_KOREADER_SYNC), - - // --- OPDS Browser (web-only, uses CrossPointSettings char arrays) --- - SettingInfo::String(StrId::STR_OPDS_SERVER_URL, SETTINGS.opdsServerUrl, sizeof(SETTINGS.opdsServerUrl), - "opdsServerUrl", StrId::STR_OPDS_BROWSER), - SettingInfo::String(StrId::STR_USERNAME, SETTINGS.opdsUsername, sizeof(SETTINGS.opdsUsername), "opdsUsername", - StrId::STR_OPDS_BROWSER), - SettingInfo::String(StrId::STR_PASSWORD, SETTINGS.opdsPassword, sizeof(SETTINGS.opdsPassword), "opdsPassword", - StrId::STR_OPDS_BROWSER) - .withObfuscated(), // --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) --- SettingInfo::Toggle(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarChapterPageCount, "statusBarChapterPageCount", StrId::STR_CUSTOMISE_STATUS_BAR), diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index bd15abbaf..915f956e1 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -2,6 +2,7 @@ #include +#include "OpdsServerStore.h" #include "boot_sleep/BootActivity.h" #include "boot_sleep/SleepActivity.h" #include "browser/OpdsBookBrowserActivity.h" @@ -11,6 +12,7 @@ #include "home/RecentBooksActivity.h" #include "network/CrossPointWebServerActivity.h" #include "reader/ReaderActivity.h" +#include "settings/OpdsServerListActivity.h" #include "settings/SettingsActivity.h" #include "util/FullScreenMessageActivity.h" @@ -179,7 +181,13 @@ void ActivityManager::goToRecentBooks() { } void ActivityManager::goToBrowser() { - replaceActivity(std::make_unique(renderer, mappedInput)); + const auto& servers = OPDS_STORE.getServers(); + // Skip the server picker when there's only one server configured + if (servers.size() == 1) { + replaceActivity(std::make_unique(renderer, mappedInput, servers[0])); + } else { + replaceActivity(std::make_unique(renderer, mappedInput, true)); + } } void ActivityManager::goToReader(std::string path) { diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index e78e9373a..3fe2fa865 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -7,7 +7,6 @@ #include #include -#include "CrossPointSettings.h" #include "MappedInputManager.h" #include "activities/network/WifiSelectionActivity.h" #include "activities/util/KeyboardEntryActivity.h" @@ -123,7 +122,9 @@ void OpdsBookBrowserActivity::render(RenderLock&&) { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); - renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_OPDS_BROWSER), true, EpdFontFamily::BOLD); + // Show server name in header if available, otherwise generic title + const char* headerTitle = server.name.empty() ? tr(STR_OPDS_BROWSER) : server.name.c_str(); + renderer.drawCenteredText(UI_12_FONT_ID, 15, headerTitle, true, EpdFontFamily::BOLD); if (state == BrowserState::CHECK_WIFI || state == BrowserState::LOADING) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); @@ -179,18 +180,19 @@ void OpdsBookBrowserActivity::render(RenderLock&&) { } void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { - if (strlen(SETTINGS.opdsServerUrl) == 0) { + if (server.url.empty()) { state = BrowserState::ERROR; errorMessage = tr(STR_NO_SERVER_URL); requestUpdate(); return; } - std::string url = (path.find("http") == 0) ? path : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, path); + std::string url = (path.find("http") == 0) ? path : UrlUtils::buildUrl(server.url, path); + LOG_DBG("OPDS", "Fetching: %s", url.c_str()); OpdsParser parser; { OpdsParserStream stream{parser}; - if (!HttpDownloader::fetchUrl(url, stream)) { + if (!HttpDownloader::fetchUrl(url, stream, server.username, server.password)) { state = BrowserState::ERROR; errorMessage = tr(STR_FETCH_FEED_FAILED); requestUpdate(); @@ -226,7 +228,7 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) { navigationHistory.push_back(currentPath); // Resolve to a full URL so sub-sub-navigation retains parent path context - const std::string feedUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, currentPath); + const std::string feedUrl = UrlUtils::buildUrl(server.url, currentPath); currentPath = UrlUtils::buildUrl(feedUrl, entry.href); state = BrowserState::LOADING; @@ -259,18 +261,20 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { requestUpdate(true); // Build full download URL relative to the current feed, not the root server URL - const std::string feedUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, currentPath); + const std::string feedUrl = UrlUtils::buildUrl(server.url, currentPath); std::string downloadUrl = UrlUtils::buildUrl(feedUrl, book.href); std::string filename = "/" + StringUtils::sanitizeFilename(book.title + (book.author.empty() ? "" : " - " + book.author)) + ".epub"; LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str()); - const auto result = - HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) { + const auto result = HttpDownloader::downloadToFile( + downloadUrl, filename, + [this](const size_t downloaded, const size_t total) { downloadProgress = downloaded; downloadTotal = total; requestUpdate(true); - }); + }, + server.username, server.password); if (result == HttpDownloader::OK) { Epub(filename, "/.crosspoint").clearCache(); @@ -346,7 +350,6 @@ void OpdsBookBrowserActivity::checkAndConnectWifi() { } void OpdsBookBrowserActivity::launchWifiSelection() { - consumeBack = consumeConfirm = true; state = BrowserState::WIFI_SELECTION; requestUpdate(); diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h index 8b55343a3..853f23e1d 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.h +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -1,11 +1,12 @@ #pragma once #include -#include #include +#include #include #include "../Activity.h" +#include "OpdsServerStore.h" #include "util/ButtonNavigator.h" /** @@ -16,8 +17,8 @@ class OpdsBookBrowserActivity final : public Activity { public: enum class BrowserState { CHECK_WIFI, WIFI_SELECTION, LOADING, BROWSING, DOWNLOADING, ERROR, SEARCH_INPUT }; - explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) - : Activity("OpdsBookBrowser", renderer, mappedInput), buttonNavigator() {} + explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, OpdsServer server) + : Activity("OpdsBookBrowser", renderer, mappedInput), buttonNavigator(), server(std::move(server)) {} void onEnter() override; void onExit() override; @@ -39,6 +40,8 @@ class OpdsBookBrowserActivity final : public Activity { size_t downloadProgress = 0; size_t downloadTotal = 0; + OpdsServer server; // Copied at construction — safe even if the store changes during browsing + void checkAndConnectWifi(); void launchWifiSelection(); void onWifiSelectionComplete(bool connected); diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 3cc72fb85..4e1338292 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -15,6 +15,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" +#include "OpdsServerStore.h" #include "RecentBooksStore.h" #include "components/UITheme.h" #include "fontIds.h" @@ -24,7 +25,7 @@ int HomeActivity::getMenuItemCount() const { if (!recentBooks.empty()) { count += recentBooks.size(); } - if (hasOpdsUrl) { + if (hasOpdsServers) { count++; } return count; @@ -110,8 +111,7 @@ void HomeActivity::loadRecentCovers(int coverHeight) { void HomeActivity::onEnter() { Activity::onEnter(); - // Check if OPDS browser URL is configured - hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0; + hasOpdsServers = OPDS_STORE.hasServers(); selectorIndex = 0; @@ -190,7 +190,7 @@ void HomeActivity::loop() { int menuSelectedIndex = selectorIndex - static_cast(recentBooks.size()); const int fileBrowserIdx = idx++; const int recentsIdx = idx++; - const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; + const int opdsLibraryIdx = hasOpdsServers ? idx++ : -1; const int fileTransferIdx = idx++; const int settingsIdx = idx; @@ -229,8 +229,7 @@ void HomeActivity::render(RenderLock&&) { tr(STR_SETTINGS_TITLE)}; std::vector menuIcons = {Folder, Recent, Transfer, Settings}; - if (hasOpdsUrl) { - // Insert OPDS Browser after File Browser + if (hasOpdsServers) { menuItems.insert(menuItems.begin() + 2, tr(STR_OPDS_BROWSER)); menuIcons.insert(menuIcons.begin() + 2, Library); } diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index e2b24beaf..7b743e771 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -15,7 +15,7 @@ class HomeActivity final : public Activity { bool recentsLoading = false; bool recentsLoaded = false; bool firstRenderDone = false; - bool hasOpdsUrl = false; + bool hasOpdsServers = false; bool coverRendered = false; // Track if cover has been rendered once bool coverBufferStored = false; // Track if cover buffer is stored uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image diff --git a/src/activities/settings/CalibreSettingsActivity.cpp b/src/activities/settings/CalibreSettingsActivity.cpp deleted file mode 100644 index a353dd4a3..000000000 --- a/src/activities/settings/CalibreSettingsActivity.cpp +++ /dev/null @@ -1,131 +0,0 @@ -#include "CalibreSettingsActivity.h" - -#include -#include - -#include - -#include "CrossPointSettings.h" -#include "MappedInputManager.h" -#include "activities/util/KeyboardEntryActivity.h" -#include "components/UITheme.h" -#include "fontIds.h" - -namespace { -constexpr int MENU_ITEMS = 3; -const StrId menuNames[MENU_ITEMS] = {StrId::STR_CALIBRE_WEB_URL, StrId::STR_USERNAME, StrId::STR_PASSWORD}; -} // namespace - -void CalibreSettingsActivity::onEnter() { - Activity::onEnter(); - - selectedIndex = 0; - requestUpdate(); -} - -void CalibreSettingsActivity::onExit() { Activity::onExit(); } - -void CalibreSettingsActivity::loop() { - if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { - finish(); - return; - } - - if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - handleSelection(); - return; - } - - // Handle navigation - buttonNavigator.onNext([this] { - selectedIndex = (selectedIndex + 1) % MENU_ITEMS; - requestUpdate(); - }); - - buttonNavigator.onPrevious([this] { - selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS; - requestUpdate(); - }); -} - -void CalibreSettingsActivity::handleSelection() { - if (selectedIndex == 0) { - // OPDS Server URL - prefill with https:// if empty to save typing - const std::string currentUrl = SETTINGS.opdsServerUrl; - const std::string prefillUrl = currentUrl.empty() ? "https://" : currentUrl; - startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_CALIBRE_WEB_URL), - prefillUrl, 127, InputType::Url), - [this](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& kb = std::get(result.data); - const std::string urlToSave = - (kb.text == "https://" || kb.text == "http://") ? "" : kb.text; - strncpy(SETTINGS.opdsServerUrl, urlToSave.c_str(), sizeof(SETTINGS.opdsServerUrl) - 1); - SETTINGS.opdsServerUrl[sizeof(SETTINGS.opdsServerUrl) - 1] = '\0'; - SETTINGS.saveToFile(); - } - }); - } else if (selectedIndex == 1) { - // Username - startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_USERNAME), - SETTINGS.opdsUsername, 63, InputType::Text), - [this](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& kb = std::get(result.data); - strncpy(SETTINGS.opdsUsername, kb.text.c_str(), sizeof(SETTINGS.opdsUsername) - 1); - SETTINGS.opdsUsername[sizeof(SETTINGS.opdsUsername) - 1] = '\0'; - SETTINGS.saveToFile(); - } - }); - } else if (selectedIndex == 2) { - // Password - startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_PASSWORD), - SETTINGS.opdsPassword, 63, InputType::Password), - [this](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& kb = std::get(result.data); - strncpy(SETTINGS.opdsPassword, kb.text.c_str(), sizeof(SETTINGS.opdsPassword) - 1); - SETTINGS.opdsPassword[sizeof(SETTINGS.opdsPassword) - 1] = '\0'; - SETTINGS.saveToFile(); - } - }); - } -} - -void CalibreSettingsActivity::render(RenderLock&&) { - renderer.clearScreen(); - - const auto& metrics = UITheme::getInstance().getMetrics(); - const auto pageWidth = renderer.getScreenWidth(); - const auto pageHeight = renderer.getScreenHeight(); - GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_OPDS_BROWSER)); - GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, - tr(STR_CALIBRE_URL_HINT)); - - const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.tabBarHeight; - const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; - GUI.drawList( - renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast(MENU_ITEMS), - static_cast(selectedIndex), [](int index) { return std::string(I18N.get(menuNames[index])); }, nullptr, - nullptr, - [this](int index) { - // Draw status for each setting - if (index == 0) { - return (strlen(SETTINGS.opdsServerUrl) > 0) ? std::string(SETTINGS.opdsServerUrl) - : std::string(tr(STR_NOT_SET)); - } else if (index == 1) { - return (strlen(SETTINGS.opdsUsername) > 0) ? std::string(SETTINGS.opdsUsername) - : std::string(tr(STR_NOT_SET)); - } else if (index == 2) { - return (strlen(SETTINGS.opdsPassword) > 0) ? std::string("******") : std::string(tr(STR_NOT_SET)); - } - return std::string(tr(STR_NOT_SET)); - }, - true); - - // Draw help text at bottom - const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), 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/settings/CalibreSettingsActivity.h b/src/activities/settings/CalibreSettingsActivity.h deleted file mode 100644 index c7990d9a1..000000000 --- a/src/activities/settings/CalibreSettingsActivity.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include "activities/Activity.h" -#include "util/ButtonNavigator.h" - -/** - * Submenu for OPDS Browser settings. - * Shows OPDS Server URL and HTTP authentication options. - */ -class CalibreSettingsActivity final : public Activity { - public: - explicit CalibreSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) - : Activity("CalibreSettings", renderer, mappedInput) {} - - void onEnter() override; - void onExit() override; - void loop() override; - void render(RenderLock&&) override; - - private: - ButtonNavigator buttonNavigator; - - size_t selectedIndex = 0; - void handleSelection(); -}; diff --git a/src/activities/settings/OpdsServerListActivity.cpp b/src/activities/settings/OpdsServerListActivity.cpp new file mode 100644 index 000000000..52987d250 --- /dev/null +++ b/src/activities/settings/OpdsServerListActivity.cpp @@ -0,0 +1,133 @@ +#include "OpdsServerListActivity.h" + +#include +#include + +#include "MappedInputManager.h" +#include "OpdsServerStore.h" +#include "OpdsSettingsActivity.h" +#include "activities/ActivityManager.h" +#include "activities/browser/OpdsBookBrowserActivity.h" +#include "components/UITheme.h" +#include "fontIds.h" + +int OpdsServerListActivity::getItemCount() const { + int count = static_cast(OPDS_STORE.getCount()); + // In settings mode, append a virtual "Add Server" item; in picker mode, only show real servers + if (!pickerMode) { + count++; + } + return count; +} + +void OpdsServerListActivity::onEnter() { + Activity::onEnter(); + + // Reload from disk in case servers were added/removed by a subactivity or the web UI + OPDS_STORE.loadFromFile(); + selectedIndex = 0; + requestUpdate(); +} + +void OpdsServerListActivity::onExit() { Activity::onExit(); } + +void OpdsServerListActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + if (pickerMode) { + activityManager.goHome(); + } else { + finish(); + } + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + handleSelection(); + return; + } + + const int itemCount = getItemCount(); + if (itemCount > 0) { + buttonNavigator.onNext([this, itemCount] { + selectedIndex = ButtonNavigator::nextIndex(selectedIndex, itemCount); + requestUpdate(); + }); + + buttonNavigator.onPrevious([this, itemCount] { + selectedIndex = ButtonNavigator::previousIndex(selectedIndex, itemCount); + requestUpdate(); + }); + } +} + +void OpdsServerListActivity::handleSelection() { + const auto serverCount = static_cast(OPDS_STORE.getCount()); + + if (pickerMode) { + // Picker mode: selecting a server navigates to the OPDS browser + if (selectedIndex < serverCount) { + const auto* server = OPDS_STORE.getServer(static_cast(selectedIndex)); + if (server) { + activityManager.replaceActivity(std::make_unique(renderer, mappedInput, *server)); + } + } + 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 + OPDS_STORE.loadFromFile(); + selectedIndex = 0; + }; + + if (selectedIndex < serverCount) { + startActivityForResult(std::make_unique(renderer, mappedInput, selectedIndex), resultHandler); + } else { + startActivityForResult(std::make_unique(renderer, mappedInput, -1), resultHandler); + } +} + +void OpdsServerListActivity::render(RenderLock&&) { + renderer.clearScreen(); + + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_OPDS_SERVERS)); + + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; + const int itemCount = getItemCount(); + + if (itemCount == 0) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_NO_SERVERS)); + } else { + const auto& servers = OPDS_STORE.getServers(); + const auto serverCount = static_cast(servers.size()); + + // Primary label: server name (falling back to URL if unnamed). + // 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) { + 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)); + }, + [&servers, serverCount](int index) { + if (index < serverCount && !servers[index].name.empty()) { + return servers[index].url; + } + return std::string(""); + }); + } + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), 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/settings/OpdsServerListActivity.h b/src/activities/settings/OpdsServerListActivity.h new file mode 100644 index 000000000..b03c3a92e --- /dev/null +++ b/src/activities/settings/OpdsServerListActivity.h @@ -0,0 +1,29 @@ +#pragma once + +#include "activities/Activity.h" +#include "util/ButtonNavigator.h" + +/** + * Activity showing the list of configured OPDS servers. + * Allows adding new servers and editing/deleting existing ones. + * When pickerMode is true, selecting a server navigates to the OPDS browser + * instead of opening the editor (used from the home screen). + */ +class OpdsServerListActivity final : public Activity { + public: + explicit OpdsServerListActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, bool pickerMode = false) + : Activity("OpdsServerList", renderer, mappedInput), pickerMode(pickerMode) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + + private: + ButtonNavigator buttonNavigator; + int selectedIndex = 0; + bool pickerMode = false; + + int getItemCount() const; + void handleSelection(); +}; diff --git a/src/activities/settings/OpdsSettingsActivity.cpp b/src/activities/settings/OpdsSettingsActivity.cpp new file mode 100644 index 000000000..ec07c1f9d --- /dev/null +++ b/src/activities/settings/OpdsSettingsActivity.cpp @@ -0,0 +1,222 @@ +#include "OpdsSettingsActivity.h" + +#include +#include +#include + +#include + +#include "MappedInputManager.h" +#include "OpdsServerStore.h" +#include "activities/util/KeyboardEntryActivity.h" +#include "components/UITheme.h" +#include "fontIds.h" + +namespace { +// Editable fields: Name, URL, Username, Password. +// Existing servers also show a Delete option (BASE_ITEMS + 1). +constexpr int BASE_ITEMS = 4; +} // namespace + +int OpdsSettingsActivity::getMenuItemCount() const { + return isNewServer ? BASE_ITEMS : BASE_ITEMS + 1; // +1 for Delete +} + +void OpdsSettingsActivity::onEnter() { + Activity::onEnter(); + + selectedIndex = 0; + isNewServer = (serverIndex < 0); + showSaveError = false; + + if (!isNewServer) { + // Edit flow: copy the selected server into local editable state. + // Changes are persisted field-by-field through saveServer(). + const auto* server = OPDS_STORE.getServer(static_cast(serverIndex)); + if (server) { + editServer = *server; + } else { + // Server was deleted between navigation and entering this screen — treat as new + isNewServer = true; + serverIndex = -1; + } + } + + requestUpdate(); +} + +void OpdsSettingsActivity::onExit() { Activity::onExit(); } + +void OpdsSettingsActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + handleSelection(); + return; + } + + const int menuItems = getMenuItemCount(); + buttonNavigator.onNext([this, menuItems] { + selectedIndex = (selectedIndex + 1) % menuItems; + requestUpdate(); + }); + + buttonNavigator.onPrevious([this, menuItems] { + selectedIndex = (selectedIndex + menuItems - 1) % menuItems; + requestUpdate(); + }); +} + +bool OpdsSettingsActivity::saveServer() { + bool success = false; + + if (isNewServer) { + // Create flow: first save inserts a new server record into the multi-server store. + success = OPDS_STORE.addServer(editServer); + if (success) { + // After the first successful save, promote to an existing server so + // subsequent field edits update in-place rather than creating duplicates. + isNewServer = false; + serverIndex = static_cast(OPDS_STORE.getCount()) - 1; + } else { + LOG_ERR("OPS", "Failed to add OPDS server"); + } + } else { + // Edit flow: update the same server entry in-place. + success = OPDS_STORE.updateServer(static_cast(serverIndex), editServer); + if (!success) { + LOG_ERR("OPS", "Failed to update OPDS server at index %d", serverIndex); + } + } + + showSaveError = !success; + if (showSaveError) { + requestUpdate(); + } + + return success; +} + +void OpdsSettingsActivity::handleSelection() { + // Each field edit is saved immediately so partially configured servers + // survive navigation and power-loss scenarios. + if (selectedIndex == 0) { + // Server Name + auto handler = [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& kb = std::get(result.data); + editServer.name = kb.text; + saveServer(); + requestUpdate(); + } + }; + startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_SERVER_NAME), + editServer.name, 63, InputType::Text), + handler); + } else if (selectedIndex == 1) { + // Server URL + const std::string prefillUrl = editServer.url.empty() ? "https://" : editServer.url; + auto handler = [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& kb = std::get(result.data); + editServer.url = (kb.text == "https://" || kb.text == "http://") ? "" : kb.text; + saveServer(); + requestUpdate(); + } + }; + startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_OPDS_SERVER_URL), + prefillUrl, 127, InputType::Url), + handler); + } else if (selectedIndex == 2) { + // Username + auto handler = [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& kb = std::get(result.data); + editServer.username = kb.text; + saveServer(); + requestUpdate(); + } + }; + startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_USERNAME), + editServer.username, 63, InputType::Text), + handler); + } else if (selectedIndex == 3) { + // Password + auto handler = [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& kb = std::get(result.data); + editServer.password = kb.text; + saveServer(); + requestUpdate(); + } + }; + startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_PASSWORD), + editServer.password, 63, InputType::Password), + handler); + } else if (selectedIndex == 4 && !isNewServer) { + // Delete flow is only available for existing servers. + if (!OPDS_STORE.removeServer(static_cast(serverIndex))) { + LOG_ERR("OPS", "Failed to remove OPDS server at index %d", serverIndex); + showSaveError = true; + requestUpdate(); + return; + } + finish(); + } +} + +void OpdsSettingsActivity::render(RenderLock&&) { + renderer.clearScreen(); + + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + // Reuse STR_OPDS_BROWSER as the "edit existing server" title. + // New server creation uses STR_ADD_SERVER. + const char* header = isNewServer ? tr(STR_ADD_SERVER) : tr(STR_OPDS_BROWSER); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, header); + GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, + tr(STR_CALIBRE_URL_HINT)); + + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.tabBarHeight; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; + const int menuItems = getMenuItemCount(); + + const StrId fieldNames[] = {StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL, StrId::STR_USERNAME, + StrId::STR_PASSWORD}; + + GUI.drawList( + renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast(selectedIndex), + [this, &fieldNames](int index) { + if (index < BASE_ITEMS) { + return std::string(I18N.get(fieldNames[index])); + } + return std::string(tr(STR_DELETE_SERVER)); + }, + nullptr, nullptr, + [this](int index) { + if (index == 0) { + return editServer.name.empty() ? std::string(tr(STR_NOT_SET)) : editServer.name; + } else if (index == 1) { + return editServer.url.empty() ? std::string(tr(STR_NOT_SET)) : editServer.url; + } else if (index == 2) { + 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("******"); + } + return std::string(""); + }, + true); + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + if (showSaveError) { + GUI.drawPopup(renderer, tr(STR_ERROR_GENERAL_FAILURE)); + } + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/OpdsSettingsActivity.h b/src/activities/settings/OpdsSettingsActivity.h new file mode 100644 index 000000000..4ea2d61b6 --- /dev/null +++ b/src/activities/settings/OpdsSettingsActivity.h @@ -0,0 +1,37 @@ +#pragma once + +#include "OpdsServerStore.h" +#include "activities/Activity.h" +#include "util/ButtonNavigator.h" + +/** + * Edit screen for a single OPDS server. + * Shows Name, URL, Username, Password fields and a Delete option. + * Used for both adding new servers and editing existing ones. + */ +class OpdsSettingsActivity final : public Activity { + public: + /** + * @param serverIndex Index into OpdsServerStore, or -1 for a new server + */ + explicit OpdsSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, int serverIndex = -1) + : Activity("OpdsSettings", renderer, mappedInput), serverIndex(serverIndex) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + + private: + ButtonNavigator buttonNavigator; + + size_t selectedIndex = 0; + int serverIndex; + OpdsServer editServer; + bool isNewServer = false; + bool showSaveError = false; + + int getMenuItemCount() const; + void handleSelection(); + bool saveServer(); +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index e5824a89a..f93600123 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -4,12 +4,12 @@ #include #include "ButtonRemapActivity.h" -#include "CalibreSettingsActivity.h" #include "ClearCacheActivity.h" #include "CrossPointSettings.h" #include "KOReaderSettingsActivity.h" #include "LanguageSelectActivity.h" #include "MappedInputManager.h" +#include "OpdsServerListActivity.h" #include "OtaUpdateActivity.h" #include "SettingsList.h" #include "StatusBarSettingsActivity.h" @@ -48,7 +48,7 @@ void SettingsActivity::onEnter() { SettingInfo::Action(StrId::STR_REMAP_FRONT_BUTTONS, SettingAction::RemapFrontButtons)); systemSettings.push_back(SettingInfo::Action(StrId::STR_WIFI_NETWORKS, SettingAction::Network)); systemSettings.push_back(SettingInfo::Action(StrId::STR_KOREADER_SYNC, SettingAction::KOReaderSync)); - systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_BROWSER, SettingAction::OPDSBrowser)); + systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser)); systemSettings.push_back(SettingInfo::Action(StrId::STR_CLEAR_READING_CACHE, SettingAction::ClearCache)); systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates)); systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language)); @@ -178,7 +178,7 @@ void SettingsActivity::toggleCurrentSetting() { startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); break; case SettingAction::OPDSBrowser: - startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); + startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); break; case SettingAction::Network: startActivityForResult(std::make_unique(renderer, mappedInput, false), resultHandler); diff --git a/src/main.cpp b/src/main.cpp index 4aeb5a855..a3929d069 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -19,6 +19,7 @@ #include "CrossPointState.h" #include "KOReaderCredentialStore.h" #include "MappedInputManager.h" +#include "OpdsServerStore.h" #include "RecentBooksStore.h" #include "activities/Activity.h" #include "activities/ActivityManager.h" @@ -257,6 +258,7 @@ void setup() { SETTINGS.loadFromFile(); I18N.loadSettings(); KOREADER_STORE.loadFromFile(); + OPDS_STORE.loadFromFile(); UITheme::getInstance().reload(); ButtonNavigator::setMappedInputManager(mappedInputManager); diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index d18eebd13..0ad2f1ab9 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -11,6 +11,7 @@ #include #include "CrossPointSettings.h" +#include "OpdsServerStore.h" #include "SettingsList.h" #include "WebDAVHandler.h" #include "html/FilesPageHtml.generated.h" @@ -160,6 +161,11 @@ void CrossPointWebServer::begin() { server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); }); server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); }); + // OPDS server endpoints + server->on("/api/opds", HTTP_GET, [this] { handleGetOpdsServers(); }); + server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); }); + server->on("/api/opds/delete", HTTP_POST, [this] { handleDeleteOpdsServer(); }); + server->onNotFound([this] { handleNotFound(); }); LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap()); @@ -1244,6 +1250,122 @@ void CrossPointWebServer::handlePostSettings() { server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s)"); } +// ---- OPDS Server API ---- + +void CrossPointWebServer::handleGetOpdsServers() const { + const auto& servers = OPDS_STORE.getServers(); + + // Stream JSON array incrementally to avoid allocating the full response in memory + server->setContentLength(CONTENT_LENGTH_UNKNOWN); + server->send(200, "application/json", ""); + server->sendContent("["); + + char output[512]; + constexpr size_t outputSize = sizeof(output); + JsonDocument doc; + + for (size_t i = 0; i < servers.size(); i++) { + doc.clear(); + doc["index"] = i; + doc["name"] = servers[i].name; + doc["url"] = servers[i].url; + doc["username"] = servers[i].username; + // Never expose passwords over the API — only indicate whether one is set + doc["hasPassword"] = !servers[i].password.empty(); + + const size_t written = serializeJson(doc, output, outputSize); + if (written >= outputSize) continue; + + if (i > 0) server->sendContent(","); + server->sendContent(output); + } + + server->sendContent("]"); + server->sendContent(""); + LOG_DBG("WEB", "Served OPDS servers API (%zu servers)", servers.size()); +} + +void CrossPointWebServer::handlePostOpdsServer() { + if (!server->hasArg("plain")) { + server->send(400, "text/plain", "Missing JSON body"); + return; + } + + const String body = server->arg("plain"); + JsonDocument doc; + const DeserializationError err = deserializeJson(doc, body); + if (err) { + server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str()); + return; + } + + OpdsServer opdsServer; + opdsServer.name = doc["name"] | std::string(""); + opdsServer.url = doc["url"] | std::string(""); + opdsServer.username = doc["username"] | std::string(""); + + // The password field is optional in the JSON payload. When absent (vs. present but empty), + // we preserve the existing password — the web UI omits it when the user hasn't changed it. + bool hasPasswordField = doc["password"].is() || doc["password"].is(); + std::string password = doc["password"] | std::string(""); + + if (doc["index"].is()) { + int idx = doc["index"].as(); + if (idx < 0 || idx >= static_cast(OPDS_STORE.getCount())) { + server->send(400, "text/plain", "Invalid server index"); + return; + } + // Preserve existing password if not explicitly provided + if (!hasPasswordField) { + const auto* existing = OPDS_STORE.getServer(static_cast(idx)); + if (existing) password = existing->password; + } + opdsServer.password = password; + OPDS_STORE.updateServer(static_cast(idx), opdsServer); + LOG_DBG("WEB", "Updated OPDS server at index %d", idx); + } else { + opdsServer.password = password; + if (!OPDS_STORE.addServer(opdsServer)) { + server->send(400, "text/plain", "Cannot add server (limit reached)"); + return; + } + LOG_DBG("WEB", "Added new OPDS server: %s", opdsServer.name.c_str()); + } + + server->send(200, "text/plain", "OK"); +} + +// Uses POST (not HTTP DELETE) because ESP32 WebServer doesn't support DELETE with body. +void CrossPointWebServer::handleDeleteOpdsServer() { + if (!server->hasArg("plain")) { + server->send(400, "text/plain", "Missing JSON body"); + return; + } + + const String body = server->arg("plain"); + JsonDocument doc; + const DeserializationError err = deserializeJson(doc, body); + if (err) { + server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str()); + return; + } + + if (!doc["index"].is()) { + server->send(400, "text/plain", "Missing index"); + return; + } + + int idx = doc["index"].as(); + if (idx < 0 || idx >= static_cast(OPDS_STORE.getCount())) { + server->send(400, "text/plain", "Invalid server index"); + return; + } + + OPDS_STORE.removeServer(static_cast(idx)); + LOG_DBG("WEB", "Deleted OPDS server at index %d", idx); + server->send(200, "text/plain", "OK"); +} + // WebSocket callback trampoline void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { if (wsInstance) { diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 6568d30ac..aac60e19e 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -107,4 +107,9 @@ class CrossPointWebServer { void handleSettingsPage() const; void handleGetSettings() const; void handlePostSettings(); + + // OPDS server handlers + void handleGetOpdsServers() const; + void handlePostOpdsServer(); + void handleDeleteOpdsServer(); }; diff --git a/src/network/HttpDownloader.cpp b/src/network/HttpDownloader.cpp index 799b88746..b881c859c 100644 --- a/src/network/HttpDownloader.cpp +++ b/src/network/HttpDownloader.cpp @@ -11,7 +11,6 @@ #include #include -#include "CrossPointSettings.h" #include "util/UrlUtils.h" namespace { @@ -52,8 +51,8 @@ class FileWriteStream final : public Stream { }; } // namespace -bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) { - // Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP +bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent, const std::string& username, + const std::string& password) { std::unique_ptr client; if (UrlUtils::isHttpsUrl(url)) { auto* secureClient = new NetworkClientSecure(); @@ -70,9 +69,8 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) { http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); - // Add Basic HTTP auth if credentials are configured - if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { - std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword; + if (!username.empty() && !password.empty()) { + std::string credentials = username + ":" + password; String encoded = base64::encode(credentials.c_str()); http.addHeader("Authorization", "Basic " + encoded); } @@ -92,9 +90,10 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) { return true; } -bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) { +bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent, const std::string& username, + const std::string& password) { StreamString stream; - if (!fetchUrl(url, stream)) { + if (!fetchUrl(url, stream, username, password)) { return false; } outContent = stream.c_str(); @@ -102,8 +101,8 @@ bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) { } HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath, - ProgressCallback progress) { - // Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP + ProgressCallback progress, const std::string& username, + const std::string& password) { std::unique_ptr client; if (UrlUtils::isHttpsUrl(url)) { auto* secureClient = new NetworkClientSecure(); @@ -121,9 +120,8 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); - // Add Basic HTTP auth if credentials are configured - if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { - std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword; + if (!username.empty() && !password.empty()) { + std::string credentials = username + ":" + password; String encoded = base64::encode(credentials.c_str()); http.addHeader("Authorization", "Basic " + encoded); } diff --git a/src/network/HttpDownloader.h b/src/network/HttpDownloader.h index a8f1cd252..216840a57 100644 --- a/src/network/HttpDownloader.h +++ b/src/network/HttpDownloader.h @@ -20,22 +20,18 @@ class HttpDownloader { }; /** - * Fetch text content from a URL. - * @param url The URL to fetch - * @param outContent The fetched content (output) - * @return true if fetch succeeded, false on error + * Fetch text content from a URL with optional credentials. */ - static bool fetchUrl(const std::string& url, std::string& outContent); + static bool fetchUrl(const std::string& url, std::string& outContent, const std::string& username = "", + const std::string& password = ""); - static bool fetchUrl(const std::string& url, Stream& stream); + static bool fetchUrl(const std::string& url, Stream& stream, const std::string& username = "", + const std::string& password = ""); /** - * Download a file to the SD card. - * @param url The URL to download - * @param destPath The destination path on SD card - * @param progress Optional progress callback - * @return DownloadError indicating success or failure type + * Download a file to the SD card with optional credentials. */ static DownloadError downloadToFile(const std::string& url, const std::string& destPath, - ProgressCallback progress = nullptr); + ProgressCallback progress = nullptr, const std::string& username = "", + const std::string& password = ""); }; diff --git a/src/network/html/SettingsPage.html b/src/network/html/SettingsPage.html index fa1f06544..9b9ac8ec4 100644 --- a/src/network/html/SettingsPage.html +++ b/src/network/html/SettingsPage.html @@ -207,6 +207,48 @@ from { transform: rotate(0deg); } to { transform: rotate(360deg); } } + .opds-server { + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 12px; + margin: 10px 0; + } + .opds-server .setting-row:last-child { + border-bottom: none; + } + .opds-actions { + display: flex; + gap: 8px; + margin-top: 8px; + } + .btn-small { + padding: 6px 14px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9em; + } + .btn-add { + background-color: var(--accent-color); + color: white; + } + .btn-add:hover { + background-color: var(--accent-hover-color); + } + .btn-delete { + background-color: #e74c3c; + color: white; + } + .btn-delete:hover { + background-color: #c0392b; + } + .btn-save-server { + background-color: #27ae60; + color: white; + } + .btn-save-server:hover { + background-color: #219a52; + } @media (max-width: 600px) { body { padding: 10px; @@ -257,6 +299,8 @@ +
+

CrossPoint E-Reader • Open Source @@ -435,6 +479,122 @@ } loadSettings(); + + // --- OPDS Server Management --- + // Dynamically renders an editable list of OPDS servers, communicating with the + // /api/opds REST endpoints. Password fields are never pre-filled for security; + // the "(unchanged)" placeholder indicates an existing password is preserved on save. + let opdsServers = []; + + function renderOpdsServer(srv, idx) { + const isNew = idx === -1; + const id = isNew ? 'new' : idx; + return '

' + + '
' + + 'Server Name' + + '' + + '
' + + '
' + + 'URL' + + '' + + '
' + + '
' + + 'Username' + + '' + + '
' + + '
' + + 'Password' + + '' + + '
' + + '
' + + '' + + (isNew ? '' : '') + + '
' + + '
'; + } + + function renderOpdsSection() { + const container = document.getElementById('opds-container'); + let html = '

OPDS Servers

'; + + if (opdsServers.length === 0) { + html += '

No OPDS servers configured

'; + } else { + opdsServers.forEach(function(srv, idx) { + html += renderOpdsServer(srv, idx); + }); + } + + html += '
' + + '' + + '
'; + container.innerHTML = html; + } + + async function loadOpdsServers() { + try { + const resp = await fetch('/api/opds'); + if (!resp.ok) throw new Error('Failed to load'); + opdsServers = await resp.json(); + renderOpdsSection(); + } catch (e) { + console.error('OPDS load error:', e); + } + } + + function addOpdsServer() { + const container = document.getElementById('opds-container'); + const card = container.querySelector('.card'); + const addBtn = card.querySelector('.btn-add').parentElement; + // Prevent multiple unsaved new-server forms at once (idx -1 → id "new") + if (document.getElementById('opds-new')) return; + addBtn.insertAdjacentHTML('beforebegin', renderOpdsServer({name:'',url:'',username:'',hasPassword:false}, -1)); + } + + async function saveOpdsServer(idx) { + const id = idx === -1 ? 'new' : idx; + const data = { + name: document.getElementById('opds-name-' + id).value, + url: document.getElementById('opds-url-' + id).value, + username: document.getElementById('opds-user-' + id).value, + }; + // Only include password in payload when the user actually typed something; + // omitting it tells the server to keep the existing password. + const pass = document.getElementById('opds-pass-' + id).value; + if (pass) data.password = pass; + if (idx >= 0) data.index = idx; + + try { + const resp = await fetch('/api/opds', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(data) + }); + if (!resp.ok) throw new Error(await resp.text()); + showMessage('OPDS server saved!', false); + await loadOpdsServers(); + } catch (e) { + showMessage('Error: ' + e.message, true); + } + } + + async function deleteOpdsServer(idx) { + if (!confirm('Delete this OPDS server?')) return; + try { + const resp = await fetch('/api/opds/delete', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({index: idx}) + }); + if (!resp.ok) throw new Error(await resp.text()); + showMessage('OPDS server deleted', false); + await loadOpdsServers(); + } catch (e) { + showMessage('Error: ' + e.message, true); + } + } + + loadOpdsServers();