A fork of https://github.com/crosspoint-reader/crosspoint-reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: Support for multiple OPDS servers (#1209)

## Summary

* Add support for configuring and using multiple OPDS servers, replacing
the previous single-server limitation. Closes
https://github.com/crosspoint-reader/crosspoint-reader/issues/1178
* New OpdsServerStore singleton (modeled after WifiCredentialStore) that
persists up to 8 OPDS servers to /.crosspoint/opds.json with MAC-based
password obfuscation.
* One-time migration from legacy single-server fields in
CrossPointSettings to the new store on first boot.
* New OpdsServerListActivity for the device UI — works in two modes: a
settings list (add/edit/delete servers) and a picker (select which
server to browse). When only one server is configured, the picker is
skipped automatically.
* Renamed CalibreSettingsActivity → OpdsSettingsActivity for clarity. It
now edits individual OpdsServer entries (name, URL, username, password,
delete).
* OpdsBookBrowserActivity now receives an OpdsServer at construction and
uses its credentials for all fetches/downloads, and shows the server
name in the header.
* HttpDownloader::fetchUrl and downloadToFile accept optional per-call
username/password parameters instead of reading from global settings.
* REST API endpoints on CrossPointWebServer: GET /api/opds, POST
/api/opds, POST /api/opds/delete — passwords are never exposed over the
API (only a hasPassword flag), and omitting the password field on update
preserves the existing one.
* Web UI (SettingsPage.html) with dynamic OPDS server management cards —
add, edit, save, and delete servers from the browser.
<img width="932" height="906" alt="SCR-20260416-stvu"
src="https://github.com/user-attachments/assets/a8f18d84-4204-46a0-bb31-b73d24b3255f"
/>


## Additional Context

* The OpdsServerStore JSON format and obfuscation scheme are identical
to WifiCredentialStore, so the same JsonSettingsIO infrastructure
handles both.
* The web API uses POST /api/opds/delete instead of DELETE /api/opds
because the ESP32 WebServer doesn't support the DELETE method with a
request body.
* Existing single-server configurations are migrated automatically — no
user action required. After migration the legacy CrossPointSettings
fields are cleared so it only runs once.
* The HttpDownloader changes are backward-compatible: the credential
parameters default to empty strings, so existing callers are unaffected.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**< YES >**_

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

authored by

Arthur Tazhitdinov
Copilot
and committed by
GitHub
1cf22397 c5f82709

+1035 -218
+27 -3
USER_GUIDE.md
··· 20 20 - [3.6.2 Reader](#362-reader) 21 21 - [3.6.3 Controls](#363-controls) 22 22 - [3.6.4 System](#364-system) 23 - - [3.6.5 KOReader Sync Quick Setup](#365-koreader-sync-quick-setup) 23 + - [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries) 24 + - [3.6.6 KOReader Sync Quick Setup](#366-koreader-sync-quick-setup) 24 25 - [3.7 Sleep Screen](#37-sleep-screen) 25 26 - [4. Reading Mode](#4-reading-mode) 26 27 - [Page Turning](#page-turning) ··· 194 195 195 196 - **WiFi Networks**: Connect to WiFi networks for file transfers and firmware updates. 196 197 - **KOReader Sync**: Options for setting up KOReader for syncing book progress. 197 - - **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. 198 + - **OPDS Servers**: Manage one or more OPDS libraries for browsing and downloading books. See [OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries) below. 198 199 - **Clear Reading Cache**: Clear the internal SD card cache. 199 200 - **Check for updates**: Check for Crosspoint firmware updates over WiFi. 200 201 - **Language**: Set the system language (see **[Supported Languages](#supported-languages)** for more information). 201 202 202 - #### 3.6.5 KOReader Sync Quick Setup 203 + #### 3.6.5 OPDS Servers (Multiple Libraries) 204 + 205 + CrossPoint supports saving multiple OPDS servers and switching between them when browsing catalogs. 206 + 207 + 1. Open **Settings -> System -> OPDS Servers**. 208 + 2. Select **Add Server** to create a new entry, or select an existing server to edit it. 209 + 3. Configure these fields: 210 + - **Server Name**: Optional display name (for example, "Home Calibre" or "Public Catalog"). 211 + - **OPDS Server URL**: Full catalog root URL (for Calibre Content Server, usually ends with `/opds`). 212 + - **Username / Password**: Optional credentials for authenticated servers. 213 + 4. Use **Delete Server** inside a server entry to remove it. 214 + 215 + Behavior notes: 216 + 217 + - You can store up to 8 OPDS servers. 218 + - OPDS authentication supports HTTP Basic auth. If you use Calibre Content Server with authentication enabled, set it to Basic (not Digest). 219 + 220 + You can also manage OPDS servers from the web interface while in File Transfer mode: 221 + 222 + 1. Connect to the device web UI. 223 + 2. Open `http://<device-ip>/settings`. 224 + 3. Use the **OPDS Servers** card to add, edit, or delete entries. 225 + 226 + #### 3.6.6 KOReader Sync Quick Setup 203 227 204 228 CrossPoint can sync reading progress with KOReader-compatible sync servers. 205 229 It also interoperates with KOReader apps/devices when they use the same server and credentials.
+6
lib/I18n/translations/english.yaml
··· 292 292 STR_NO_FOOTNOTES: "No footnotes on this page" 293 293 STR_LINK: "[link]" 294 294 STR_SCREENSHOT_BUTTON: "Take screenshot" 295 + STR_ADD_SERVER: "Add Server" 296 + STR_SERVER_NAME: "Server Name" 297 + STR_NO_SERVERS: "No OPDS servers configured" 298 + STR_DELETE_SERVER: "Delete Server" 299 + STR_DELETE_CONFIRM: "Delete this server?" 300 + STR_OPDS_SERVERS: "OPDS Servers" 295 301 STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: " 296 302 STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)" 297 303 STR_CRASH_TITLE: "System Crash"
+6
lib/I18n/translations/russian.yaml
··· 290 290 STR_SCREENSHOT_BUTTON: "Сделать снимок экрана" 291 291 STR_AUTO_TURN_ENABLED: "Автоперелистывание: " 292 292 STR_AUTO_TURN_PAGES_PER_MIN: "Автоперелистывание (стр./мин)" 293 + STR_ADD_SERVER: "Добавить сервер" 294 + STR_SERVER_NAME: "Имя сервера" 295 + STR_NO_SERVERS: "Нет настроенных серверов OPDS" 296 + STR_DELETE_SERVER: "Удалить сервер" 297 + STR_DELETE_CONFIRM: "Удалить этот сервер?" 298 + STR_OPDS_SERVERS: "Серверы OPDS"
+54
src/JsonSettingsIO.cpp
··· 11 11 #include "CrossPointSettings.h" 12 12 #include "CrossPointState.h" 13 13 #include "KOReaderCredentialStore.h" 14 + #include "OpdsServerStore.h" 14 15 #include "RecentBooksStore.h" 15 16 #include "SettingsList.h" 16 17 #include "WifiCredentialStore.h" ··· 351 352 LOG_DBG("RBS", "Recent books loaded from file (%d entries)", store.getCount()); 352 353 return true; 353 354 } 355 + 356 + // ---- OpdsServerStore ---- 357 + // Follows the same save/load pattern as WifiCredentialStore above. 358 + // Passwords are XOR-obfuscated with the device MAC and base64-encoded ("password_obf" key). 359 + 360 + bool JsonSettingsIO::saveOpds(const OpdsServerStore& store, const char* path) { 361 + JsonDocument doc; 362 + 363 + JsonArray arr = doc["servers"].to<JsonArray>(); 364 + for (const auto& server : store.getServers()) { 365 + JsonObject obj = arr.add<JsonObject>(); 366 + obj["name"] = server.name; 367 + obj["url"] = server.url; 368 + obj["username"] = server.username; 369 + obj["password_obf"] = obfuscation::obfuscateToBase64(server.password); 370 + } 371 + 372 + String json; 373 + serializeJson(doc, json); 374 + return Storage.writeFile(path, json); 375 + } 376 + 377 + bool JsonSettingsIO::loadOpds(OpdsServerStore& store, const char* json, bool* needsResave) { 378 + if (needsResave) *needsResave = false; 379 + JsonDocument doc; 380 + auto error = deserializeJson(doc, json); 381 + if (error) { 382 + LOG_ERR("OPS", "JSON parse error: %s", error.c_str()); 383 + return false; 384 + } 385 + 386 + store.servers.clear(); 387 + JsonArray arr = doc["servers"].as<JsonArray>(); 388 + for (JsonObject obj : arr) { 389 + if (store.servers.size() >= OpdsServerStore::MAX_SERVERS) break; 390 + OpdsServer server; 391 + server.name = obj["name"] | std::string(""); 392 + server.url = obj["url"] | std::string(""); 393 + server.username = obj["username"] | std::string(""); 394 + // Try the obfuscated key first; fall back to plaintext "password" for 395 + // files written before obfuscation was added (or hand-edited JSON). 396 + bool ok = false; 397 + server.password = obfuscation::deobfuscateFromBase64(obj["password_obf"] | "", &ok); 398 + if (!ok || server.password.empty()) { 399 + server.password = obj["password"] | std::string(""); 400 + if (!server.password.empty() && needsResave) *needsResave = true; 401 + } 402 + store.servers.push_back(std::move(server)); 403 + } 404 + 405 + LOG_DBG("OPS", "Loaded %zu OPDS servers from file", store.servers.size()); 406 + return true; 407 + }
+5
src/JsonSettingsIO.h
··· 5 5 class WifiCredentialStore; 6 6 class KOReaderCredentialStore; 7 7 class RecentBooksStore; 8 + class OpdsServerStore; 8 9 9 10 namespace JsonSettingsIO { 10 11 ··· 27 28 // RecentBooksStore 28 29 bool saveRecentBooks(const RecentBooksStore& store, const char* path); 29 30 bool loadRecentBooks(RecentBooksStore& store, const char* json); 31 + 32 + // OpdsServerStore 33 + bool saveOpds(const OpdsServerStore& store, const char* path); 34 + bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave = nullptr); 30 35 31 36 } // namespace JsonSettingsIO
+110
src/OpdsServerStore.cpp
··· 1 + #include "OpdsServerStore.h" 2 + 3 + #include <HalStorage.h> 4 + #include <JsonSettingsIO.h> 5 + #include <Logging.h> 6 + 7 + #include <cstring> 8 + 9 + #include "CrossPointSettings.h" 10 + 11 + OpdsServerStore OpdsServerStore::instance; 12 + 13 + namespace { 14 + constexpr char OPDS_FILE_JSON[] = "/.crosspoint/opds.json"; 15 + } // namespace 16 + 17 + bool OpdsServerStore::saveToFile() const { 18 + Storage.mkdir("/.crosspoint"); 19 + return JsonSettingsIO::saveOpds(*this, OPDS_FILE_JSON); 20 + } 21 + 22 + bool OpdsServerStore::loadFromFile() { 23 + if (Storage.exists(OPDS_FILE_JSON)) { 24 + String json = Storage.readFile(OPDS_FILE_JSON); 25 + if (!json.isEmpty()) { 26 + // resave flag is set when passwords were stored in plaintext and need re-obfuscation 27 + bool resave = false; 28 + bool result = JsonSettingsIO::loadOpds(*this, json.c_str(), &resave); 29 + if (result && resave) { 30 + LOG_DBG("OPS", "Resaving JSON with obfuscated passwords"); 31 + saveToFile(); 32 + } 33 + return result; 34 + } 35 + } 36 + 37 + // No opds.json found — attempt one-time migration from the legacy single-server 38 + // fields in CrossPointSettings (opdsServerUrl/opdsUsername/opdsPassword). 39 + if (migrateFromSettings()) { 40 + LOG_DBG("OPS", "Migrated legacy OPDS settings"); 41 + return true; 42 + } 43 + 44 + return false; 45 + } 46 + 47 + bool OpdsServerStore::migrateFromSettings() { 48 + if (strlen(SETTINGS.opdsServerUrl) == 0) { 49 + return false; 50 + } 51 + 52 + OpdsServer server; 53 + server.name = "OPDS Server"; 54 + server.url = SETTINGS.opdsServerUrl; 55 + server.username = SETTINGS.opdsUsername; 56 + server.password = SETTINGS.opdsPassword; 57 + servers.push_back(std::move(server)); 58 + 59 + if (saveToFile()) { 60 + // Clear legacy fields so migration won't run again on next boot 61 + SETTINGS.opdsServerUrl[0] = '\0'; 62 + SETTINGS.opdsUsername[0] = '\0'; 63 + SETTINGS.opdsPassword[0] = '\0'; 64 + SETTINGS.saveToFile(); 65 + LOG_DBG("OPS", "Migrated single-server OPDS config to opds.json"); 66 + return true; 67 + } 68 + 69 + // Save failed — roll back in-memory state so we don't have a partial migration 70 + servers.clear(); 71 + return false; 72 + } 73 + 74 + bool OpdsServerStore::addServer(const OpdsServer& server) { 75 + if (servers.size() >= MAX_SERVERS) { 76 + LOG_DBG("OPS", "Cannot add more servers, limit of %zu reached", MAX_SERVERS); 77 + return false; 78 + } 79 + 80 + servers.push_back(server); 81 + LOG_DBG("OPS", "Added server: %s", server.name.c_str()); 82 + return saveToFile(); 83 + } 84 + 85 + bool OpdsServerStore::updateServer(size_t index, const OpdsServer& server) { 86 + if (index >= servers.size()) { 87 + return false; 88 + } 89 + 90 + servers[index] = server; 91 + LOG_DBG("OPS", "Updated server: %s", server.name.c_str()); 92 + return saveToFile(); 93 + } 94 + 95 + bool OpdsServerStore::removeServer(size_t index) { 96 + if (index >= servers.size()) { 97 + return false; 98 + } 99 + 100 + LOG_DBG("OPS", "Removed server: %s", servers[index].name.c_str()); 101 + servers.erase(servers.begin() + static_cast<ptrdiff_t>(index)); 102 + return saveToFile(); 103 + } 104 + 105 + const OpdsServer* OpdsServerStore::getServer(size_t index) const { 106 + if (index >= servers.size()) { 107 + return nullptr; 108 + } 109 + return &servers[index]; 110 + }
+60
src/OpdsServerStore.h
··· 1 + #pragma once 2 + #include <string> 3 + #include <vector> 4 + 5 + struct OpdsServer { 6 + std::string name; 7 + std::string url; 8 + std::string username; 9 + std::string password; // Plaintext in memory; obfuscated with hardware key on disk 10 + }; 11 + 12 + class OpdsServerStore; 13 + namespace JsonSettingsIO { 14 + bool saveOpds(const OpdsServerStore& store, const char* path); 15 + bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave); 16 + } // namespace JsonSettingsIO 17 + 18 + /** 19 + * Singleton class for storing OPDS server configurations on the SD card. 20 + * Passwords are XOR-obfuscated with the device's unique hardware MAC address 21 + * and base64-encoded before writing to JSON. 22 + */ 23 + class OpdsServerStore { 24 + private: 25 + static OpdsServerStore instance; 26 + std::vector<OpdsServer> servers; 27 + 28 + static constexpr size_t MAX_SERVERS = 8; 29 + 30 + OpdsServerStore() = default; 31 + 32 + friend bool JsonSettingsIO::saveOpds(const OpdsServerStore&, const char*); 33 + friend bool JsonSettingsIO::loadOpds(OpdsServerStore&, const char*, bool*); 34 + 35 + public: 36 + OpdsServerStore(const OpdsServerStore&) = delete; 37 + OpdsServerStore& operator=(const OpdsServerStore&) = delete; 38 + 39 + static OpdsServerStore& getInstance() { return instance; } 40 + 41 + bool saveToFile() const; 42 + bool loadFromFile(); 43 + 44 + bool addServer(const OpdsServer& server); 45 + bool updateServer(size_t index, const OpdsServer& server); 46 + bool removeServer(size_t index); 47 + 48 + const std::vector<OpdsServer>& getServers() const { return servers; } 49 + const OpdsServer* getServer(size_t index) const; 50 + size_t getCount() const { return servers.size(); } 51 + bool hasServers() const { return !servers.empty(); } 52 + 53 + /** 54 + * Migrate from legacy single-server settings in CrossPointSettings. 55 + * Called once during first load if no opds.json exists. 56 + */ 57 + bool migrateFromSettings(); 58 + }; 59 + 60 + #define OPDS_STORE OpdsServerStore::getInstance()
-9
src/SettingsList.h
··· 111 111 KOREADER_STORE.saveToFile(); 112 112 }, 113 113 "koMatchMethod", StrId::STR_KOREADER_SYNC), 114 - 115 - // --- OPDS Browser (web-only, uses CrossPointSettings char arrays) --- 116 - SettingInfo::String(StrId::STR_OPDS_SERVER_URL, SETTINGS.opdsServerUrl, sizeof(SETTINGS.opdsServerUrl), 117 - "opdsServerUrl", StrId::STR_OPDS_BROWSER), 118 - SettingInfo::String(StrId::STR_USERNAME, SETTINGS.opdsUsername, sizeof(SETTINGS.opdsUsername), "opdsUsername", 119 - StrId::STR_OPDS_BROWSER), 120 - SettingInfo::String(StrId::STR_PASSWORD, SETTINGS.opdsPassword, sizeof(SETTINGS.opdsPassword), "opdsPassword", 121 - StrId::STR_OPDS_BROWSER) 122 - .withObfuscated(), 123 114 // --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) --- 124 115 SettingInfo::Toggle(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarChapterPageCount, 125 116 "statusBarChapterPageCount", StrId::STR_CUSTOMISE_STATUS_BAR),
+9 -1
src/activities/ActivityManager.cpp
··· 2 2 3 3 #include <HalPowerManager.h> 4 4 5 + #include "OpdsServerStore.h" 5 6 #include "boot_sleep/BootActivity.h" 6 7 #include "boot_sleep/SleepActivity.h" 7 8 #include "browser/OpdsBookBrowserActivity.h" ··· 11 12 #include "home/RecentBooksActivity.h" 12 13 #include "network/CrossPointWebServerActivity.h" 13 14 #include "reader/ReaderActivity.h" 15 + #include "settings/OpdsServerListActivity.h" 14 16 #include "settings/SettingsActivity.h" 15 17 #include "util/FullScreenMessageActivity.h" 16 18 ··· 179 181 } 180 182 181 183 void ActivityManager::goToBrowser() { 182 - replaceActivity(std::make_unique<OpdsBookBrowserActivity>(renderer, mappedInput)); 184 + const auto& servers = OPDS_STORE.getServers(); 185 + // Skip the server picker when there's only one server configured 186 + if (servers.size() == 1) { 187 + replaceActivity(std::make_unique<OpdsBookBrowserActivity>(renderer, mappedInput, servers[0])); 188 + } else { 189 + replaceActivity(std::make_unique<OpdsServerListActivity>(renderer, mappedInput, true)); 190 + } 183 191 } 184 192 185 193 void ActivityManager::goToReader(std::string path) {
+14 -11
src/activities/browser/OpdsBookBrowserActivity.cpp
··· 7 7 #include <OpdsStream.h> 8 8 #include <WiFi.h> 9 9 10 - #include "CrossPointSettings.h" 11 10 #include "MappedInputManager.h" 12 11 #include "activities/network/WifiSelectionActivity.h" 13 12 #include "activities/util/KeyboardEntryActivity.h" ··· 123 122 const auto pageWidth = renderer.getScreenWidth(); 124 123 const auto pageHeight = renderer.getScreenHeight(); 125 124 126 - renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_OPDS_BROWSER), true, EpdFontFamily::BOLD); 125 + // Show server name in header if available, otherwise generic title 126 + const char* headerTitle = server.name.empty() ? tr(STR_OPDS_BROWSER) : server.name.c_str(); 127 + renderer.drawCenteredText(UI_12_FONT_ID, 15, headerTitle, true, EpdFontFamily::BOLD); 127 128 128 129 if (state == BrowserState::CHECK_WIFI || state == BrowserState::LOADING) { 129 130 renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); ··· 179 180 } 180 181 181 182 void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { 182 - if (strlen(SETTINGS.opdsServerUrl) == 0) { 183 + if (server.url.empty()) { 183 184 state = BrowserState::ERROR; 184 185 errorMessage = tr(STR_NO_SERVER_URL); 185 186 requestUpdate(); 186 187 return; 187 188 } 188 189 189 - std::string url = (path.find("http") == 0) ? path : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, path); 190 + std::string url = (path.find("http") == 0) ? path : UrlUtils::buildUrl(server.url, path); 191 + LOG_DBG("OPDS", "Fetching: %s", url.c_str()); 190 192 OpdsParser parser; 191 193 { 192 194 OpdsParserStream stream{parser}; 193 - if (!HttpDownloader::fetchUrl(url, stream)) { 195 + if (!HttpDownloader::fetchUrl(url, stream, server.username, server.password)) { 194 196 state = BrowserState::ERROR; 195 197 errorMessage = tr(STR_FETCH_FEED_FAILED); 196 198 requestUpdate(); ··· 226 228 void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) { 227 229 navigationHistory.push_back(currentPath); 228 230 // Resolve to a full URL so sub-sub-navigation retains parent path context 229 - const std::string feedUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, currentPath); 231 + const std::string feedUrl = UrlUtils::buildUrl(server.url, currentPath); 230 232 currentPath = UrlUtils::buildUrl(feedUrl, entry.href); 231 233 232 234 state = BrowserState::LOADING; ··· 259 261 requestUpdate(true); 260 262 261 263 // Build full download URL relative to the current feed, not the root server URL 262 - const std::string feedUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, currentPath); 264 + const std::string feedUrl = UrlUtils::buildUrl(server.url, currentPath); 263 265 std::string downloadUrl = UrlUtils::buildUrl(feedUrl, book.href); 264 266 std::string filename = 265 267 "/" + StringUtils::sanitizeFilename(book.title + (book.author.empty() ? "" : " - " + book.author)) + ".epub"; 266 268 LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str()); 267 269 268 - const auto result = 269 - HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) { 270 + const auto result = HttpDownloader::downloadToFile( 271 + downloadUrl, filename, 272 + [this](const size_t downloaded, const size_t total) { 270 273 downloadProgress = downloaded; 271 274 downloadTotal = total; 272 275 requestUpdate(true); 273 - }); 276 + }, 277 + server.username, server.password); 274 278 275 279 if (result == HttpDownloader::OK) { 276 280 Epub(filename, "/.crosspoint").clearCache(); ··· 346 350 } 347 351 348 352 void OpdsBookBrowserActivity::launchWifiSelection() { 349 - consumeBack = consumeConfirm = true; 350 353 state = BrowserState::WIFI_SELECTION; 351 354 requestUpdate(); 352 355
+6 -3
src/activities/browser/OpdsBookBrowserActivity.h
··· 1 1 #pragma once 2 2 #include <OpdsParser.h> 3 3 4 - #include <functional> 5 4 #include <string> 5 + #include <utility> 6 6 #include <vector> 7 7 8 8 #include "../Activity.h" 9 + #include "OpdsServerStore.h" 9 10 #include "util/ButtonNavigator.h" 10 11 11 12 /** ··· 16 17 public: 17 18 enum class BrowserState { CHECK_WIFI, WIFI_SELECTION, LOADING, BROWSING, DOWNLOADING, ERROR, SEARCH_INPUT }; 18 19 19 - explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) 20 - : Activity("OpdsBookBrowser", renderer, mappedInput), buttonNavigator() {} 20 + explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, OpdsServer server) 21 + : Activity("OpdsBookBrowser", renderer, mappedInput), buttonNavigator(), server(std::move(server)) {} 21 22 22 23 void onEnter() override; 23 24 void onExit() override; ··· 38 39 std::string statusMessage; 39 40 size_t downloadProgress = 0; 40 41 size_t downloadTotal = 0; 42 + 43 + OpdsServer server; // Copied at construction — safe even if the store changes during browsing 41 44 42 45 void checkAndConnectWifi(); 43 46 void launchWifiSelection();
+5 -6
src/activities/home/HomeActivity.cpp
··· 15 15 #include "CrossPointSettings.h" 16 16 #include "CrossPointState.h" 17 17 #include "MappedInputManager.h" 18 + #include "OpdsServerStore.h" 18 19 #include "RecentBooksStore.h" 19 20 #include "components/UITheme.h" 20 21 #include "fontIds.h" ··· 24 25 if (!recentBooks.empty()) { 25 26 count += recentBooks.size(); 26 27 } 27 - if (hasOpdsUrl) { 28 + if (hasOpdsServers) { 28 29 count++; 29 30 } 30 31 return count; ··· 110 111 void HomeActivity::onEnter() { 111 112 Activity::onEnter(); 112 113 113 - // Check if OPDS browser URL is configured 114 - hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0; 114 + hasOpdsServers = OPDS_STORE.hasServers(); 115 115 116 116 selectorIndex = 0; 117 117 ··· 190 190 int menuSelectedIndex = selectorIndex - static_cast<int>(recentBooks.size()); 191 191 const int fileBrowserIdx = idx++; 192 192 const int recentsIdx = idx++; 193 - const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; 193 + const int opdsLibraryIdx = hasOpdsServers ? idx++ : -1; 194 194 const int fileTransferIdx = idx++; 195 195 const int settingsIdx = idx; 196 196 ··· 229 229 tr(STR_SETTINGS_TITLE)}; 230 230 std::vector<UIIcon> menuIcons = {Folder, Recent, Transfer, Settings}; 231 231 232 - if (hasOpdsUrl) { 233 - // Insert OPDS Browser after File Browser 232 + if (hasOpdsServers) { 234 233 menuItems.insert(menuItems.begin() + 2, tr(STR_OPDS_BROWSER)); 235 234 menuIcons.insert(menuIcons.begin() + 2, Library); 236 235 }
+1 -1
src/activities/home/HomeActivity.h
··· 15 15 bool recentsLoading = false; 16 16 bool recentsLoaded = false; 17 17 bool firstRenderDone = false; 18 - bool hasOpdsUrl = false; 18 + bool hasOpdsServers = false; 19 19 bool coverRendered = false; // Track if cover has been rendered once 20 20 bool coverBufferStored = false; // Track if cover buffer is stored 21 21 uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image
-131
src/activities/settings/CalibreSettingsActivity.cpp
··· 1 - #include "CalibreSettingsActivity.h" 2 - 3 - #include <GfxRenderer.h> 4 - #include <I18n.h> 5 - 6 - #include <cstring> 7 - 8 - #include "CrossPointSettings.h" 9 - #include "MappedInputManager.h" 10 - #include "activities/util/KeyboardEntryActivity.h" 11 - #include "components/UITheme.h" 12 - #include "fontIds.h" 13 - 14 - namespace { 15 - constexpr int MENU_ITEMS = 3; 16 - const StrId menuNames[MENU_ITEMS] = {StrId::STR_CALIBRE_WEB_URL, StrId::STR_USERNAME, StrId::STR_PASSWORD}; 17 - } // namespace 18 - 19 - void CalibreSettingsActivity::onEnter() { 20 - Activity::onEnter(); 21 - 22 - selectedIndex = 0; 23 - requestUpdate(); 24 - } 25 - 26 - void CalibreSettingsActivity::onExit() { Activity::onExit(); } 27 - 28 - void CalibreSettingsActivity::loop() { 29 - if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { 30 - finish(); 31 - return; 32 - } 33 - 34 - if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { 35 - handleSelection(); 36 - return; 37 - } 38 - 39 - // Handle navigation 40 - buttonNavigator.onNext([this] { 41 - selectedIndex = (selectedIndex + 1) % MENU_ITEMS; 42 - requestUpdate(); 43 - }); 44 - 45 - buttonNavigator.onPrevious([this] { 46 - selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS; 47 - requestUpdate(); 48 - }); 49 - } 50 - 51 - void CalibreSettingsActivity::handleSelection() { 52 - if (selectedIndex == 0) { 53 - // OPDS Server URL - prefill with https:// if empty to save typing 54 - const std::string currentUrl = SETTINGS.opdsServerUrl; 55 - const std::string prefillUrl = currentUrl.empty() ? "https://" : currentUrl; 56 - startActivityForResult(std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_CALIBRE_WEB_URL), 57 - prefillUrl, 127, InputType::Url), 58 - [this](const ActivityResult& result) { 59 - if (!result.isCancelled) { 60 - const auto& kb = std::get<KeyboardResult>(result.data); 61 - const std::string urlToSave = 62 - (kb.text == "https://" || kb.text == "http://") ? "" : kb.text; 63 - strncpy(SETTINGS.opdsServerUrl, urlToSave.c_str(), sizeof(SETTINGS.opdsServerUrl) - 1); 64 - SETTINGS.opdsServerUrl[sizeof(SETTINGS.opdsServerUrl) - 1] = '\0'; 65 - SETTINGS.saveToFile(); 66 - } 67 - }); 68 - } else if (selectedIndex == 1) { 69 - // Username 70 - startActivityForResult(std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_USERNAME), 71 - SETTINGS.opdsUsername, 63, InputType::Text), 72 - [this](const ActivityResult& result) { 73 - if (!result.isCancelled) { 74 - const auto& kb = std::get<KeyboardResult>(result.data); 75 - strncpy(SETTINGS.opdsUsername, kb.text.c_str(), sizeof(SETTINGS.opdsUsername) - 1); 76 - SETTINGS.opdsUsername[sizeof(SETTINGS.opdsUsername) - 1] = '\0'; 77 - SETTINGS.saveToFile(); 78 - } 79 - }); 80 - } else if (selectedIndex == 2) { 81 - // Password 82 - startActivityForResult(std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_PASSWORD), 83 - SETTINGS.opdsPassword, 63, InputType::Password), 84 - [this](const ActivityResult& result) { 85 - if (!result.isCancelled) { 86 - const auto& kb = std::get<KeyboardResult>(result.data); 87 - strncpy(SETTINGS.opdsPassword, kb.text.c_str(), sizeof(SETTINGS.opdsPassword) - 1); 88 - SETTINGS.opdsPassword[sizeof(SETTINGS.opdsPassword) - 1] = '\0'; 89 - SETTINGS.saveToFile(); 90 - } 91 - }); 92 - } 93 - } 94 - 95 - void CalibreSettingsActivity::render(RenderLock&&) { 96 - renderer.clearScreen(); 97 - 98 - const auto& metrics = UITheme::getInstance().getMetrics(); 99 - const auto pageWidth = renderer.getScreenWidth(); 100 - const auto pageHeight = renderer.getScreenHeight(); 101 - GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_OPDS_BROWSER)); 102 - GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, 103 - tr(STR_CALIBRE_URL_HINT)); 104 - 105 - const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.tabBarHeight; 106 - const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; 107 - GUI.drawList( 108 - renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast<int>(MENU_ITEMS), 109 - static_cast<int>(selectedIndex), [](int index) { return std::string(I18N.get(menuNames[index])); }, nullptr, 110 - nullptr, 111 - [this](int index) { 112 - // Draw status for each setting 113 - if (index == 0) { 114 - return (strlen(SETTINGS.opdsServerUrl) > 0) ? std::string(SETTINGS.opdsServerUrl) 115 - : std::string(tr(STR_NOT_SET)); 116 - } else if (index == 1) { 117 - return (strlen(SETTINGS.opdsUsername) > 0) ? std::string(SETTINGS.opdsUsername) 118 - : std::string(tr(STR_NOT_SET)); 119 - } else if (index == 2) { 120 - return (strlen(SETTINGS.opdsPassword) > 0) ? std::string("******") : std::string(tr(STR_NOT_SET)); 121 - } 122 - return std::string(tr(STR_NOT_SET)); 123 - }, 124 - true); 125 - 126 - // Draw help text at bottom 127 - const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); 128 - GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 129 - 130 - renderer.displayBuffer(); 131 - }
-25
src/activities/settings/CalibreSettingsActivity.h
··· 1 - #pragma once 2 - 3 - #include "activities/Activity.h" 4 - #include "util/ButtonNavigator.h" 5 - 6 - /** 7 - * Submenu for OPDS Browser settings. 8 - * Shows OPDS Server URL and HTTP authentication options. 9 - */ 10 - class CalibreSettingsActivity final : public Activity { 11 - public: 12 - explicit CalibreSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) 13 - : Activity("CalibreSettings", renderer, mappedInput) {} 14 - 15 - void onEnter() override; 16 - void onExit() override; 17 - void loop() override; 18 - void render(RenderLock&&) override; 19 - 20 - private: 21 - ButtonNavigator buttonNavigator; 22 - 23 - size_t selectedIndex = 0; 24 - void handleSelection(); 25 - };
+133
src/activities/settings/OpdsServerListActivity.cpp
··· 1 + #include "OpdsServerListActivity.h" 2 + 3 + #include <GfxRenderer.h> 4 + #include <I18n.h> 5 + 6 + #include "MappedInputManager.h" 7 + #include "OpdsServerStore.h" 8 + #include "OpdsSettingsActivity.h" 9 + #include "activities/ActivityManager.h" 10 + #include "activities/browser/OpdsBookBrowserActivity.h" 11 + #include "components/UITheme.h" 12 + #include "fontIds.h" 13 + 14 + int OpdsServerListActivity::getItemCount() const { 15 + int count = static_cast<int>(OPDS_STORE.getCount()); 16 + // In settings mode, append a virtual "Add Server" item; in picker mode, only show real servers 17 + if (!pickerMode) { 18 + count++; 19 + } 20 + return count; 21 + } 22 + 23 + void OpdsServerListActivity::onEnter() { 24 + Activity::onEnter(); 25 + 26 + // Reload from disk in case servers were added/removed by a subactivity or the web UI 27 + OPDS_STORE.loadFromFile(); 28 + selectedIndex = 0; 29 + requestUpdate(); 30 + } 31 + 32 + void OpdsServerListActivity::onExit() { Activity::onExit(); } 33 + 34 + void OpdsServerListActivity::loop() { 35 + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { 36 + if (pickerMode) { 37 + activityManager.goHome(); 38 + } else { 39 + finish(); 40 + } 41 + return; 42 + } 43 + 44 + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { 45 + handleSelection(); 46 + return; 47 + } 48 + 49 + const int itemCount = getItemCount(); 50 + if (itemCount > 0) { 51 + buttonNavigator.onNext([this, itemCount] { 52 + selectedIndex = ButtonNavigator::nextIndex(selectedIndex, itemCount); 53 + requestUpdate(); 54 + }); 55 + 56 + buttonNavigator.onPrevious([this, itemCount] { 57 + selectedIndex = ButtonNavigator::previousIndex(selectedIndex, itemCount); 58 + requestUpdate(); 59 + }); 60 + } 61 + } 62 + 63 + void OpdsServerListActivity::handleSelection() { 64 + const auto serverCount = static_cast<int>(OPDS_STORE.getCount()); 65 + 66 + if (pickerMode) { 67 + // Picker mode: selecting a server navigates to the OPDS browser 68 + if (selectedIndex < serverCount) { 69 + const auto* server = OPDS_STORE.getServer(static_cast<size_t>(selectedIndex)); 70 + if (server) { 71 + activityManager.replaceActivity(std::make_unique<OpdsBookBrowserActivity>(renderer, mappedInput, *server)); 72 + } 73 + } 74 + return; 75 + } 76 + 77 + // Settings mode: open editor for selected server, or create a new one 78 + auto resultHandler = [this](const ActivityResult&) { 79 + // Reload server list when returning from editor 80 + OPDS_STORE.loadFromFile(); 81 + selectedIndex = 0; 82 + }; 83 + 84 + if (selectedIndex < serverCount) { 85 + startActivityForResult(std::make_unique<OpdsSettingsActivity>(renderer, mappedInput, selectedIndex), resultHandler); 86 + } else { 87 + startActivityForResult(std::make_unique<OpdsSettingsActivity>(renderer, mappedInput, -1), resultHandler); 88 + } 89 + } 90 + 91 + void OpdsServerListActivity::render(RenderLock&&) { 92 + renderer.clearScreen(); 93 + 94 + const auto& metrics = UITheme::getInstance().getMetrics(); 95 + const auto pageWidth = renderer.getScreenWidth(); 96 + const auto pageHeight = renderer.getScreenHeight(); 97 + 98 + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_OPDS_SERVERS)); 99 + 100 + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; 101 + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; 102 + const int itemCount = getItemCount(); 103 + 104 + if (itemCount == 0) { 105 + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_NO_SERVERS)); 106 + } else { 107 + const auto& servers = OPDS_STORE.getServers(); 108 + const auto serverCount = static_cast<int>(servers.size()); 109 + 110 + // Primary label: server name (falling back to URL if unnamed). 111 + // Secondary label: server URL (shown as subtitle when name is set). 112 + GUI.drawList( 113 + renderer, Rect{0, contentTop, pageWidth, contentHeight}, itemCount, selectedIndex, 114 + [&servers, serverCount](int index) { 115 + if (index < serverCount) { 116 + const auto& server = servers[index]; 117 + return server.name.empty() ? server.url : server.name; 118 + } 119 + return std::string(I18n::getInstance().get(StrId::STR_ADD_SERVER)); 120 + }, 121 + [&servers, serverCount](int index) { 122 + if (index < serverCount && !servers[index].name.empty()) { 123 + return servers[index].url; 124 + } 125 + return std::string(""); 126 + }); 127 + } 128 + 129 + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); 130 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 131 + 132 + renderer.displayBuffer(); 133 + }
+29
src/activities/settings/OpdsServerListActivity.h
··· 1 + #pragma once 2 + 3 + #include "activities/Activity.h" 4 + #include "util/ButtonNavigator.h" 5 + 6 + /** 7 + * Activity showing the list of configured OPDS servers. 8 + * Allows adding new servers and editing/deleting existing ones. 9 + * When pickerMode is true, selecting a server navigates to the OPDS browser 10 + * instead of opening the editor (used from the home screen). 11 + */ 12 + class OpdsServerListActivity final : public Activity { 13 + public: 14 + explicit OpdsServerListActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, bool pickerMode = false) 15 + : Activity("OpdsServerList", renderer, mappedInput), pickerMode(pickerMode) {} 16 + 17 + void onEnter() override; 18 + void onExit() override; 19 + void loop() override; 20 + void render(RenderLock&&) override; 21 + 22 + private: 23 + ButtonNavigator buttonNavigator; 24 + int selectedIndex = 0; 25 + bool pickerMode = false; 26 + 27 + int getItemCount() const; 28 + void handleSelection(); 29 + };
+222
src/activities/settings/OpdsSettingsActivity.cpp
··· 1 + #include "OpdsSettingsActivity.h" 2 + 3 + #include <GfxRenderer.h> 4 + #include <I18n.h> 5 + #include <Logging.h> 6 + 7 + #include <cstring> 8 + 9 + #include "MappedInputManager.h" 10 + #include "OpdsServerStore.h" 11 + #include "activities/util/KeyboardEntryActivity.h" 12 + #include "components/UITheme.h" 13 + #include "fontIds.h" 14 + 15 + namespace { 16 + // Editable fields: Name, URL, Username, Password. 17 + // Existing servers also show a Delete option (BASE_ITEMS + 1). 18 + constexpr int BASE_ITEMS = 4; 19 + } // namespace 20 + 21 + int OpdsSettingsActivity::getMenuItemCount() const { 22 + return isNewServer ? BASE_ITEMS : BASE_ITEMS + 1; // +1 for Delete 23 + } 24 + 25 + void OpdsSettingsActivity::onEnter() { 26 + Activity::onEnter(); 27 + 28 + selectedIndex = 0; 29 + isNewServer = (serverIndex < 0); 30 + showSaveError = false; 31 + 32 + if (!isNewServer) { 33 + // Edit flow: copy the selected server into local editable state. 34 + // Changes are persisted field-by-field through saveServer(). 35 + const auto* server = OPDS_STORE.getServer(static_cast<size_t>(serverIndex)); 36 + if (server) { 37 + editServer = *server; 38 + } else { 39 + // Server was deleted between navigation and entering this screen — treat as new 40 + isNewServer = true; 41 + serverIndex = -1; 42 + } 43 + } 44 + 45 + requestUpdate(); 46 + } 47 + 48 + void OpdsSettingsActivity::onExit() { Activity::onExit(); } 49 + 50 + void OpdsSettingsActivity::loop() { 51 + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { 52 + finish(); 53 + return; 54 + } 55 + 56 + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { 57 + handleSelection(); 58 + return; 59 + } 60 + 61 + const int menuItems = getMenuItemCount(); 62 + buttonNavigator.onNext([this, menuItems] { 63 + selectedIndex = (selectedIndex + 1) % menuItems; 64 + requestUpdate(); 65 + }); 66 + 67 + buttonNavigator.onPrevious([this, menuItems] { 68 + selectedIndex = (selectedIndex + menuItems - 1) % menuItems; 69 + requestUpdate(); 70 + }); 71 + } 72 + 73 + bool OpdsSettingsActivity::saveServer() { 74 + bool success = false; 75 + 76 + if (isNewServer) { 77 + // Create flow: first save inserts a new server record into the multi-server store. 78 + success = OPDS_STORE.addServer(editServer); 79 + if (success) { 80 + // After the first successful save, promote to an existing server so 81 + // subsequent field edits update in-place rather than creating duplicates. 82 + isNewServer = false; 83 + serverIndex = static_cast<int>(OPDS_STORE.getCount()) - 1; 84 + } else { 85 + LOG_ERR("OPS", "Failed to add OPDS server"); 86 + } 87 + } else { 88 + // Edit flow: update the same server entry in-place. 89 + success = OPDS_STORE.updateServer(static_cast<size_t>(serverIndex), editServer); 90 + if (!success) { 91 + LOG_ERR("OPS", "Failed to update OPDS server at index %d", serverIndex); 92 + } 93 + } 94 + 95 + showSaveError = !success; 96 + if (showSaveError) { 97 + requestUpdate(); 98 + } 99 + 100 + return success; 101 + } 102 + 103 + void OpdsSettingsActivity::handleSelection() { 104 + // Each field edit is saved immediately so partially configured servers 105 + // survive navigation and power-loss scenarios. 106 + if (selectedIndex == 0) { 107 + // Server Name 108 + auto handler = [this](const ActivityResult& result) { 109 + if (!result.isCancelled) { 110 + const auto& kb = std::get<KeyboardResult>(result.data); 111 + editServer.name = kb.text; 112 + saveServer(); 113 + requestUpdate(); 114 + } 115 + }; 116 + startActivityForResult(std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_SERVER_NAME), 117 + editServer.name, 63, InputType::Text), 118 + handler); 119 + } else if (selectedIndex == 1) { 120 + // Server URL 121 + const std::string prefillUrl = editServer.url.empty() ? "https://" : editServer.url; 122 + auto handler = [this](const ActivityResult& result) { 123 + if (!result.isCancelled) { 124 + const auto& kb = std::get<KeyboardResult>(result.data); 125 + editServer.url = (kb.text == "https://" || kb.text == "http://") ? "" : kb.text; 126 + saveServer(); 127 + requestUpdate(); 128 + } 129 + }; 130 + startActivityForResult(std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_OPDS_SERVER_URL), 131 + prefillUrl, 127, InputType::Url), 132 + handler); 133 + } else if (selectedIndex == 2) { 134 + // Username 135 + auto handler = [this](const ActivityResult& result) { 136 + if (!result.isCancelled) { 137 + const auto& kb = std::get<KeyboardResult>(result.data); 138 + editServer.username = kb.text; 139 + saveServer(); 140 + requestUpdate(); 141 + } 142 + }; 143 + startActivityForResult(std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_USERNAME), 144 + editServer.username, 63, InputType::Text), 145 + handler); 146 + } else if (selectedIndex == 3) { 147 + // Password 148 + auto handler = [this](const ActivityResult& result) { 149 + if (!result.isCancelled) { 150 + const auto& kb = std::get<KeyboardResult>(result.data); 151 + editServer.password = kb.text; 152 + saveServer(); 153 + requestUpdate(); 154 + } 155 + }; 156 + startActivityForResult(std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_PASSWORD), 157 + editServer.password, 63, InputType::Password), 158 + handler); 159 + } else if (selectedIndex == 4 && !isNewServer) { 160 + // Delete flow is only available for existing servers. 161 + if (!OPDS_STORE.removeServer(static_cast<size_t>(serverIndex))) { 162 + LOG_ERR("OPS", "Failed to remove OPDS server at index %d", serverIndex); 163 + showSaveError = true; 164 + requestUpdate(); 165 + return; 166 + } 167 + finish(); 168 + } 169 + } 170 + 171 + void OpdsSettingsActivity::render(RenderLock&&) { 172 + renderer.clearScreen(); 173 + 174 + const auto& metrics = UITheme::getInstance().getMetrics(); 175 + const auto pageWidth = renderer.getScreenWidth(); 176 + const auto pageHeight = renderer.getScreenHeight(); 177 + // Reuse STR_OPDS_BROWSER as the "edit existing server" title. 178 + // New server creation uses STR_ADD_SERVER. 179 + const char* header = isNewServer ? tr(STR_ADD_SERVER) : tr(STR_OPDS_BROWSER); 180 + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, header); 181 + GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, 182 + tr(STR_CALIBRE_URL_HINT)); 183 + 184 + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.tabBarHeight; 185 + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; 186 + const int menuItems = getMenuItemCount(); 187 + 188 + const StrId fieldNames[] = {StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL, StrId::STR_USERNAME, 189 + StrId::STR_PASSWORD}; 190 + 191 + GUI.drawList( 192 + renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast<int>(selectedIndex), 193 + [this, &fieldNames](int index) { 194 + if (index < BASE_ITEMS) { 195 + return std::string(I18N.get(fieldNames[index])); 196 + } 197 + return std::string(tr(STR_DELETE_SERVER)); 198 + }, 199 + nullptr, nullptr, 200 + [this](int index) { 201 + if (index == 0) { 202 + return editServer.name.empty() ? std::string(tr(STR_NOT_SET)) : editServer.name; 203 + } else if (index == 1) { 204 + return editServer.url.empty() ? std::string(tr(STR_NOT_SET)) : editServer.url; 205 + } else if (index == 2) { 206 + return editServer.username.empty() ? std::string(tr(STR_NOT_SET)) : editServer.username; 207 + } else if (index == 3) { 208 + return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******"); 209 + } 210 + return std::string(""); 211 + }, 212 + true); 213 + 214 + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); 215 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 216 + 217 + if (showSaveError) { 218 + GUI.drawPopup(renderer, tr(STR_ERROR_GENERAL_FAILURE)); 219 + } 220 + 221 + renderer.displayBuffer(); 222 + }
+37
src/activities/settings/OpdsSettingsActivity.h
··· 1 + #pragma once 2 + 3 + #include "OpdsServerStore.h" 4 + #include "activities/Activity.h" 5 + #include "util/ButtonNavigator.h" 6 + 7 + /** 8 + * Edit screen for a single OPDS server. 9 + * Shows Name, URL, Username, Password fields and a Delete option. 10 + * Used for both adding new servers and editing existing ones. 11 + */ 12 + class OpdsSettingsActivity final : public Activity { 13 + public: 14 + /** 15 + * @param serverIndex Index into OpdsServerStore, or -1 for a new server 16 + */ 17 + explicit OpdsSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, int serverIndex = -1) 18 + : Activity("OpdsSettings", renderer, mappedInput), serverIndex(serverIndex) {} 19 + 20 + void onEnter() override; 21 + void onExit() override; 22 + void loop() override; 23 + void render(RenderLock&&) override; 24 + 25 + private: 26 + ButtonNavigator buttonNavigator; 27 + 28 + size_t selectedIndex = 0; 29 + int serverIndex; 30 + OpdsServer editServer; 31 + bool isNewServer = false; 32 + bool showSaveError = false; 33 + 34 + int getMenuItemCount() const; 35 + void handleSelection(); 36 + bool saveServer(); 37 + };
+3 -3
src/activities/settings/SettingsActivity.cpp
··· 4 4 #include <Logging.h> 5 5 6 6 #include "ButtonRemapActivity.h" 7 - #include "CalibreSettingsActivity.h" 8 7 #include "ClearCacheActivity.h" 9 8 #include "CrossPointSettings.h" 10 9 #include "KOReaderSettingsActivity.h" 11 10 #include "LanguageSelectActivity.h" 12 11 #include "MappedInputManager.h" 12 + #include "OpdsServerListActivity.h" 13 13 #include "OtaUpdateActivity.h" 14 14 #include "SettingsList.h" 15 15 #include "StatusBarSettingsActivity.h" ··· 48 48 SettingInfo::Action(StrId::STR_REMAP_FRONT_BUTTONS, SettingAction::RemapFrontButtons)); 49 49 systemSettings.push_back(SettingInfo::Action(StrId::STR_WIFI_NETWORKS, SettingAction::Network)); 50 50 systemSettings.push_back(SettingInfo::Action(StrId::STR_KOREADER_SYNC, SettingAction::KOReaderSync)); 51 - systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_BROWSER, SettingAction::OPDSBrowser)); 51 + systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser)); 52 52 systemSettings.push_back(SettingInfo::Action(StrId::STR_CLEAR_READING_CACHE, SettingAction::ClearCache)); 53 53 systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates)); 54 54 systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language)); ··· 178 178 startActivityForResult(std::make_unique<KOReaderSettingsActivity>(renderer, mappedInput), resultHandler); 179 179 break; 180 180 case SettingAction::OPDSBrowser: 181 - startActivityForResult(std::make_unique<CalibreSettingsActivity>(renderer, mappedInput), resultHandler); 181 + startActivityForResult(std::make_unique<OpdsServerListActivity>(renderer, mappedInput), resultHandler); 182 182 break; 183 183 case SettingAction::Network: 184 184 startActivityForResult(std::make_unique<WifiSelectionActivity>(renderer, mappedInput, false), resultHandler);
+2
src/main.cpp
··· 19 19 #include "CrossPointState.h" 20 20 #include "KOReaderCredentialStore.h" 21 21 #include "MappedInputManager.h" 22 + #include "OpdsServerStore.h" 22 23 #include "RecentBooksStore.h" 23 24 #include "activities/Activity.h" 24 25 #include "activities/ActivityManager.h" ··· 257 258 SETTINGS.loadFromFile(); 258 259 I18N.loadSettings(); 259 260 KOREADER_STORE.loadFromFile(); 261 + OPDS_STORE.loadFromFile(); 260 262 UITheme::getInstance().reload(); 261 263 ButtonNavigator::setMappedInputManager(mappedInputManager); 262 264
+122
src/network/CrossPointWebServer.cpp
··· 11 11 #include <algorithm> 12 12 13 13 #include "CrossPointSettings.h" 14 + #include "OpdsServerStore.h" 14 15 #include "SettingsList.h" 15 16 #include "WebDAVHandler.h" 16 17 #include "html/FilesPageHtml.generated.h" ··· 159 160 server->on("/settings", HTTP_GET, [this] { handleSettingsPage(); }); 160 161 server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); }); 161 162 server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); }); 163 + 164 + // OPDS server endpoints 165 + server->on("/api/opds", HTTP_GET, [this] { handleGetOpdsServers(); }); 166 + server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); }); 167 + server->on("/api/opds/delete", HTTP_POST, [this] { handleDeleteOpdsServer(); }); 162 168 163 169 server->onNotFound([this] { handleNotFound(); }); 164 170 LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap()); ··· 1242 1248 1243 1249 LOG_DBG("WEB", "Applied %d setting(s)", applied); 1244 1250 server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s)"); 1251 + } 1252 + 1253 + // ---- OPDS Server API ---- 1254 + 1255 + void CrossPointWebServer::handleGetOpdsServers() const { 1256 + const auto& servers = OPDS_STORE.getServers(); 1257 + 1258 + // Stream JSON array incrementally to avoid allocating the full response in memory 1259 + server->setContentLength(CONTENT_LENGTH_UNKNOWN); 1260 + server->send(200, "application/json", ""); 1261 + server->sendContent("["); 1262 + 1263 + char output[512]; 1264 + constexpr size_t outputSize = sizeof(output); 1265 + JsonDocument doc; 1266 + 1267 + for (size_t i = 0; i < servers.size(); i++) { 1268 + doc.clear(); 1269 + doc["index"] = i; 1270 + doc["name"] = servers[i].name; 1271 + doc["url"] = servers[i].url; 1272 + doc["username"] = servers[i].username; 1273 + // Never expose passwords over the API — only indicate whether one is set 1274 + doc["hasPassword"] = !servers[i].password.empty(); 1275 + 1276 + const size_t written = serializeJson(doc, output, outputSize); 1277 + if (written >= outputSize) continue; 1278 + 1279 + if (i > 0) server->sendContent(","); 1280 + server->sendContent(output); 1281 + } 1282 + 1283 + server->sendContent("]"); 1284 + server->sendContent(""); 1285 + LOG_DBG("WEB", "Served OPDS servers API (%zu servers)", servers.size()); 1286 + } 1287 + 1288 + void CrossPointWebServer::handlePostOpdsServer() { 1289 + if (!server->hasArg("plain")) { 1290 + server->send(400, "text/plain", "Missing JSON body"); 1291 + return; 1292 + } 1293 + 1294 + const String body = server->arg("plain"); 1295 + JsonDocument doc; 1296 + const DeserializationError err = deserializeJson(doc, body); 1297 + if (err) { 1298 + server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str()); 1299 + return; 1300 + } 1301 + 1302 + OpdsServer opdsServer; 1303 + opdsServer.name = doc["name"] | std::string(""); 1304 + opdsServer.url = doc["url"] | std::string(""); 1305 + opdsServer.username = doc["username"] | std::string(""); 1306 + 1307 + // The password field is optional in the JSON payload. When absent (vs. present but empty), 1308 + // we preserve the existing password — the web UI omits it when the user hasn't changed it. 1309 + bool hasPasswordField = doc["password"].is<const char*>() || doc["password"].is<std::string>(); 1310 + std::string password = doc["password"] | std::string(""); 1311 + 1312 + if (doc["index"].is<int>()) { 1313 + int idx = doc["index"].as<int>(); 1314 + if (idx < 0 || idx >= static_cast<int>(OPDS_STORE.getCount())) { 1315 + server->send(400, "text/plain", "Invalid server index"); 1316 + return; 1317 + } 1318 + // Preserve existing password if not explicitly provided 1319 + if (!hasPasswordField) { 1320 + const auto* existing = OPDS_STORE.getServer(static_cast<size_t>(idx)); 1321 + if (existing) password = existing->password; 1322 + } 1323 + opdsServer.password = password; 1324 + OPDS_STORE.updateServer(static_cast<size_t>(idx), opdsServer); 1325 + LOG_DBG("WEB", "Updated OPDS server at index %d", idx); 1326 + } else { 1327 + opdsServer.password = password; 1328 + if (!OPDS_STORE.addServer(opdsServer)) { 1329 + server->send(400, "text/plain", "Cannot add server (limit reached)"); 1330 + return; 1331 + } 1332 + LOG_DBG("WEB", "Added new OPDS server: %s", opdsServer.name.c_str()); 1333 + } 1334 + 1335 + server->send(200, "text/plain", "OK"); 1336 + } 1337 + 1338 + // Uses POST (not HTTP DELETE) because ESP32 WebServer doesn't support DELETE with body. 1339 + void CrossPointWebServer::handleDeleteOpdsServer() { 1340 + if (!server->hasArg("plain")) { 1341 + server->send(400, "text/plain", "Missing JSON body"); 1342 + return; 1343 + } 1344 + 1345 + const String body = server->arg("plain"); 1346 + JsonDocument doc; 1347 + const DeserializationError err = deserializeJson(doc, body); 1348 + if (err) { 1349 + server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str()); 1350 + return; 1351 + } 1352 + 1353 + if (!doc["index"].is<int>()) { 1354 + server->send(400, "text/plain", "Missing index"); 1355 + return; 1356 + } 1357 + 1358 + int idx = doc["index"].as<int>(); 1359 + if (idx < 0 || idx >= static_cast<int>(OPDS_STORE.getCount())) { 1360 + server->send(400, "text/plain", "Invalid server index"); 1361 + return; 1362 + } 1363 + 1364 + OPDS_STORE.removeServer(static_cast<size_t>(idx)); 1365 + LOG_DBG("WEB", "Deleted OPDS server at index %d", idx); 1366 + server->send(200, "text/plain", "OK"); 1245 1367 } 1246 1368 1247 1369 // WebSocket callback trampoline
+5
src/network/CrossPointWebServer.h
··· 107 107 void handleSettingsPage() const; 108 108 void handleGetSettings() const; 109 109 void handlePostSettings(); 110 + 111 + // OPDS server handlers 112 + void handleGetOpdsServers() const; 113 + void handlePostOpdsServer(); 114 + void handleDeleteOpdsServer(); 110 115 };
+11 -13
src/network/HttpDownloader.cpp
··· 11 11 #include <memory> 12 12 #include <utility> 13 13 14 - #include "CrossPointSettings.h" 15 14 #include "util/UrlUtils.h" 16 15 17 16 namespace { ··· 52 51 }; 53 52 } // namespace 54 53 55 - bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) { 56 - // Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP 54 + bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent, const std::string& username, 55 + const std::string& password) { 57 56 std::unique_ptr<NetworkClient> client; 58 57 if (UrlUtils::isHttpsUrl(url)) { 59 58 auto* secureClient = new NetworkClientSecure(); ··· 70 69 http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); 71 70 http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); 72 71 73 - // Add Basic HTTP auth if credentials are configured 74 - if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { 75 - std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword; 72 + if (!username.empty() && !password.empty()) { 73 + std::string credentials = username + ":" + password; 76 74 String encoded = base64::encode(credentials.c_str()); 77 75 http.addHeader("Authorization", "Basic " + encoded); 78 76 } ··· 92 90 return true; 93 91 } 94 92 95 - bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) { 93 + bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent, const std::string& username, 94 + const std::string& password) { 96 95 StreamString stream; 97 - if (!fetchUrl(url, stream)) { 96 + if (!fetchUrl(url, stream, username, password)) { 98 97 return false; 99 98 } 100 99 outContent = stream.c_str(); ··· 102 101 } 103 102 104 103 HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath, 105 - ProgressCallback progress) { 106 - // Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP 104 + ProgressCallback progress, const std::string& username, 105 + const std::string& password) { 107 106 std::unique_ptr<NetworkClient> client; 108 107 if (UrlUtils::isHttpsUrl(url)) { 109 108 auto* secureClient = new NetworkClientSecure(); ··· 121 120 http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); 122 121 http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); 123 122 124 - // Add Basic HTTP auth if credentials are configured 125 - if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { 126 - std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword; 123 + if (!username.empty() && !password.empty()) { 124 + std::string credentials = username + ":" + password; 127 125 String encoded = base64::encode(credentials.c_str()); 128 126 http.addHeader("Authorization", "Basic " + encoded); 129 127 }
+8 -12
src/network/HttpDownloader.h
··· 20 20 }; 21 21 22 22 /** 23 - * Fetch text content from a URL. 24 - * @param url The URL to fetch 25 - * @param outContent The fetched content (output) 26 - * @return true if fetch succeeded, false on error 23 + * Fetch text content from a URL with optional credentials. 27 24 */ 28 - static bool fetchUrl(const std::string& url, std::string& outContent); 25 + static bool fetchUrl(const std::string& url, std::string& outContent, const std::string& username = "", 26 + const std::string& password = ""); 29 27 30 - static bool fetchUrl(const std::string& url, Stream& stream); 28 + static bool fetchUrl(const std::string& url, Stream& stream, const std::string& username = "", 29 + const std::string& password = ""); 31 30 32 31 /** 33 - * Download a file to the SD card. 34 - * @param url The URL to download 35 - * @param destPath The destination path on SD card 36 - * @param progress Optional progress callback 37 - * @return DownloadError indicating success or failure type 32 + * Download a file to the SD card with optional credentials. 38 33 */ 39 34 static DownloadError downloadToFile(const std::string& url, const std::string& destPath, 40 - ProgressCallback progress = nullptr); 35 + ProgressCallback progress = nullptr, const std::string& username = "", 36 + const std::string& password = ""); 41 37 };
+160
src/network/html/SettingsPage.html
··· 207 207 from { transform: rotate(0deg); } 208 208 to { transform: rotate(360deg); } 209 209 } 210 + .opds-server { 211 + border: 1px solid var(--border-color); 212 + border-radius: 6px; 213 + padding: 12px; 214 + margin: 10px 0; 215 + } 216 + .opds-server .setting-row:last-child { 217 + border-bottom: none; 218 + } 219 + .opds-actions { 220 + display: flex; 221 + gap: 8px; 222 + margin-top: 8px; 223 + } 224 + .btn-small { 225 + padding: 6px 14px; 226 + border: none; 227 + border-radius: 4px; 228 + cursor: pointer; 229 + font-size: 0.9em; 230 + } 231 + .btn-add { 232 + background-color: var(--accent-color); 233 + color: white; 234 + } 235 + .btn-add:hover { 236 + background-color: var(--accent-hover-color); 237 + } 238 + .btn-delete { 239 + background-color: #e74c3c; 240 + color: white; 241 + } 242 + .btn-delete:hover { 243 + background-color: #c0392b; 244 + } 245 + .btn-save-server { 246 + background-color: #27ae60; 247 + color: white; 248 + } 249 + .btn-save-server:hover { 250 + background-color: #219a52; 251 + } 210 252 @media (max-width: 600px) { 211 253 body { 212 254 padding: 10px; ··· 256 298 <div class="save-container" id="save-container" style="display:none;"> 257 299 <button class="save-btn" id="saveBtn" onclick="saveSettings()">Save Settings</button> 258 300 </div> 301 + 302 + <div id="opds-container"></div> 259 303 260 304 <div class="card"> 261 305 <p style="text-align: center; color: #95a5a6; margin: 0;"> ··· 435 479 } 436 480 437 481 loadSettings(); 482 + 483 + // --- OPDS Server Management --- 484 + // Dynamically renders an editable list of OPDS servers, communicating with the 485 + // /api/opds REST endpoints. Password fields are never pre-filled for security; 486 + // the "(unchanged)" placeholder indicates an existing password is preserved on save. 487 + let opdsServers = []; 488 + 489 + function renderOpdsServer(srv, idx) { 490 + const isNew = idx === -1; 491 + const id = isNew ? 'new' : idx; 492 + return '<div class="opds-server" id="opds-' + id + '">' + 493 + '<div class="setting-row">' + 494 + '<span class="setting-name">Server Name</span>' + 495 + '<span class="setting-control"><input type="text" id="opds-name-' + id + '" value="' + escapeHtml(srv.name || '') + '"></span>' + 496 + '</div>' + 497 + '<div class="setting-row">' + 498 + '<span class="setting-name">URL</span>' + 499 + '<span class="setting-control"><input type="text" id="opds-url-' + id + '" value="' + escapeHtml(srv.url || '') + '"></span>' + 500 + '</div>' + 501 + '<div class="setting-row">' + 502 + '<span class="setting-name">Username</span>' + 503 + '<span class="setting-control"><input type="text" id="opds-user-' + id + '" value="' + escapeHtml(srv.username || '') + '"></span>' + 504 + '</div>' + 505 + '<div class="setting-row">' + 506 + '<span class="setting-name">Password</span>' + 507 + '<span class="setting-control"><input type="password" id="opds-pass-' + id + '" placeholder="' + (srv.hasPassword ? '(unchanged)' : '') + '"></span>' + 508 + '</div>' + 509 + '<div class="opds-actions">' + 510 + '<button class="btn-small btn-save-server" onclick="saveOpdsServer(' + idx + ')">Save</button>' + 511 + (isNew ? '' : '<button class="btn-small btn-delete" onclick="deleteOpdsServer(' + idx + ')">Delete</button>') + 512 + '</div>' + 513 + '</div>'; 514 + } 515 + 516 + function renderOpdsSection() { 517 + const container = document.getElementById('opds-container'); 518 + let html = '<div class="card"><h2>OPDS Servers</h2>'; 519 + 520 + if (opdsServers.length === 0) { 521 + html += '<p style="color:var(--label-color);text-align:center;">No OPDS servers configured</p>'; 522 + } else { 523 + opdsServers.forEach(function(srv, idx) { 524 + html += renderOpdsServer(srv, idx); 525 + }); 526 + } 527 + 528 + html += '<div style="margin-top:12px;text-align:center;">' + 529 + '<button class="btn-small btn-add" onclick="addOpdsServer()">+ Add Server</button>' + 530 + '</div></div>'; 531 + container.innerHTML = html; 532 + } 533 + 534 + async function loadOpdsServers() { 535 + try { 536 + const resp = await fetch('/api/opds'); 537 + if (!resp.ok) throw new Error('Failed to load'); 538 + opdsServers = await resp.json(); 539 + renderOpdsSection(); 540 + } catch (e) { 541 + console.error('OPDS load error:', e); 542 + } 543 + } 544 + 545 + function addOpdsServer() { 546 + const container = document.getElementById('opds-container'); 547 + const card = container.querySelector('.card'); 548 + const addBtn = card.querySelector('.btn-add').parentElement; 549 + // Prevent multiple unsaved new-server forms at once (idx -1 → id "new") 550 + if (document.getElementById('opds-new')) return; 551 + addBtn.insertAdjacentHTML('beforebegin', renderOpdsServer({name:'',url:'',username:'',hasPassword:false}, -1)); 552 + } 553 + 554 + async function saveOpdsServer(idx) { 555 + const id = idx === -1 ? 'new' : idx; 556 + const data = { 557 + name: document.getElementById('opds-name-' + id).value, 558 + url: document.getElementById('opds-url-' + id).value, 559 + username: document.getElementById('opds-user-' + id).value, 560 + }; 561 + // Only include password in payload when the user actually typed something; 562 + // omitting it tells the server to keep the existing password. 563 + const pass = document.getElementById('opds-pass-' + id).value; 564 + if (pass) data.password = pass; 565 + if (idx >= 0) data.index = idx; 566 + 567 + try { 568 + const resp = await fetch('/api/opds', { 569 + method: 'POST', 570 + headers: {'Content-Type': 'application/json'}, 571 + body: JSON.stringify(data) 572 + }); 573 + if (!resp.ok) throw new Error(await resp.text()); 574 + showMessage('OPDS server saved!', false); 575 + await loadOpdsServers(); 576 + } catch (e) { 577 + showMessage('Error: ' + e.message, true); 578 + } 579 + } 580 + 581 + async function deleteOpdsServer(idx) { 582 + if (!confirm('Delete this OPDS server?')) return; 583 + try { 584 + const resp = await fetch('/api/opds/delete', { 585 + method: 'POST', 586 + headers: {'Content-Type': 'application/json'}, 587 + body: JSON.stringify({index: idx}) 588 + }); 589 + if (!resp.ok) throw new Error(await resp.text()); 590 + showMessage('OPDS server deleted', false); 591 + await loadOpdsServers(); 592 + } catch (e) { 593 + showMessage('Error: ' + e.message, true); 594 + } 595 + } 596 + 597 + loadOpdsServers(); 438 598 </script> 439 599 </body> 440 600 </html>