Merge pull request #122 from jpirnay/integrate-pr-1209
feat: multiple opds server support (upstream 1209 by osteotek)
This commit is contained in:
+27
-3
@@ -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://<device-ip>/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.
|
||||
|
||||
@@ -380,6 +380,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_WEATHER: "Weather"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "SettingsList.h"
|
||||
#include "WifiCredentialStore.h"
|
||||
@@ -433,3 +434,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<JsonArray>();
|
||||
for (const auto& server : store.getServers()) {
|
||||
JsonObject obj = arr.add<JsonObject>();
|
||||
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<JsonArray>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
#include "OpdsServerStore.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <JsonSettingsIO.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "util/UrlUtils.h"
|
||||
|
||||
OpdsServerStore OpdsServerStore::instance;
|
||||
|
||||
namespace {
|
||||
constexpr char OPDS_FILE_JSON[] = "/.crosspoint/opds.json";
|
||||
|
||||
bool containsWhitespace(const std::string& value) {
|
||||
return std::any_of(value.begin(), value.end(), [](unsigned char ch) { return std::isspace(ch); });
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace OpdsServerValidation {
|
||||
|
||||
std::optional<std::string> normalizeUrl(const std::string& url) {
|
||||
if (url.empty() || containsWhitespace(url)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string normalized = url;
|
||||
if (normalized.find("://") == std::string::npos) {
|
||||
normalized = "https://" + normalized;
|
||||
}
|
||||
|
||||
const bool hasHttpScheme = normalized.rfind("http://", 0) == 0 || normalized.rfind("https://", 0) == 0;
|
||||
if (!hasHttpScheme || UrlUtils::extractHostname(normalized).empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
} // namespace OpdsServerValidation
|
||||
|
||||
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()) {
|
||||
LOG_ERR("OPS", "Failed to parse %s", OPDS_FILE_JSON);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
LOG_ERR("OPS", "Failed to parse %s", OPDS_FILE_JSON);
|
||||
return false;
|
||||
}
|
||||
if (resave) {
|
||||
LOG_DBG("OPS", "Resaving JSON with obfuscated passwords");
|
||||
if (!saveToFile()) {
|
||||
LOG_ERR("OPS", "Failed to resave %s after password migration", OPDS_FILE_JSON);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
std::optional<size_t> OpdsServerStore::addServer(const OpdsServer& server) {
|
||||
if (servers.size() >= MAX_SERVERS) {
|
||||
LOG_DBG("OPS", "Cannot add more servers, limit of %zu reached", MAX_SERVERS);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const auto originalServers = servers;
|
||||
servers.push_back(server);
|
||||
if (!saveToFile()) {
|
||||
servers = originalServers;
|
||||
LOG_ERR("OPS", "Failed to persist added server, rolled back in-memory state");
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const size_t insertedIndex = servers.size() - 1;
|
||||
LOG_DBG("OPS", "Added server at index %zu: %s", insertedIndex, server.name.c_str());
|
||||
return insertedIndex;
|
||||
}
|
||||
|
||||
bool OpdsServerStore::updateServer(size_t index, const OpdsServer& server) {
|
||||
if (index >= servers.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto originalServers = servers;
|
||||
servers[index] = server;
|
||||
if (!saveToFile()) {
|
||||
servers = originalServers;
|
||||
LOG_ERR("OPS", "Failed to persist updated server at index %zu, rolled back in-memory state", index);
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DBG("OPS", "Updated server at index %zu: %s", index, server.name.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OpdsServerStore::removeServer(size_t index) {
|
||||
if (index >= servers.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto originalServers = servers;
|
||||
const std::string removedName = servers[index].name;
|
||||
servers.erase(servers.begin() + static_cast<ptrdiff_t>(index));
|
||||
if (!saveToFile()) {
|
||||
servers = originalServers;
|
||||
LOG_ERR("OPS", "Failed to persist removed server at index %zu, rolled back in-memory state", index);
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DBG("OPS", "Removed server at index %zu: %s", index, removedName.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
const OpdsServer* OpdsServerStore::getServer(size_t index) const {
|
||||
if (index >= servers.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &servers[index];
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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
|
||||
|
||||
namespace OpdsServerValidation {
|
||||
std::optional<std::string> normalizeUrl(const std::string& url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<OpdsServer> servers;
|
||||
|
||||
OpdsServerStore() = default;
|
||||
|
||||
friend bool JsonSettingsIO::saveOpds(const OpdsServerStore&, const char*);
|
||||
friend bool JsonSettingsIO::loadOpds(OpdsServerStore&, const char*, bool*);
|
||||
|
||||
public:
|
||||
static constexpr size_t MAX_SERVERS = 8;
|
||||
static constexpr size_t MAX_NAME_LENGTH = 63;
|
||||
static constexpr size_t MAX_URL_LENGTH = 127;
|
||||
static constexpr size_t MAX_USERNAME_LENGTH = 63;
|
||||
static constexpr size_t MAX_PASSWORD_LENGTH = 63;
|
||||
|
||||
OpdsServerStore(const OpdsServerStore&) = delete;
|
||||
OpdsServerStore& operator=(const OpdsServerStore&) = delete;
|
||||
|
||||
static OpdsServerStore& getInstance() { return instance; }
|
||||
|
||||
bool saveToFile() const;
|
||||
bool loadFromFile();
|
||||
|
||||
std::optional<size_t> addServer(const OpdsServer& server);
|
||||
bool updateServer(size_t index, const OpdsServer& server);
|
||||
bool removeServer(size_t index);
|
||||
|
||||
const std::vector<OpdsServer>& 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()
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <esp_system.h>
|
||||
|
||||
#include "CrossPointState.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "boot_sleep/BootActivity.h"
|
||||
#include "boot_sleep/SleepActivity.h"
|
||||
#include "browser/OpdsBookBrowserActivity.h"
|
||||
@@ -18,6 +19,7 @@
|
||||
#include "network/CrossPointWebServerActivity.h"
|
||||
#include "reader/KOReaderSyncActivity.h"
|
||||
#include "reader/ReaderActivity.h"
|
||||
#include "settings/OpdsServerListActivity.h"
|
||||
#include "settings/SettingsActivity.h"
|
||||
#include "util/FullScreenMessageActivity.h"
|
||||
#include "weather/WeatherActivity.h"
|
||||
@@ -268,7 +270,13 @@ void ActivityManager::goToGlobalBookmarks(ReturnHint hint) {
|
||||
}
|
||||
|
||||
void ActivityManager::goToBrowser() {
|
||||
replaceActivity(std::make_unique<OpdsBookBrowserActivity>(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<OpdsBookBrowserActivity>(renderer, mappedInput, servers[0]));
|
||||
} else {
|
||||
replaceActivity(std::make_unique<OpdsServerListActivity>(renderer, mappedInput, true));
|
||||
}
|
||||
}
|
||||
|
||||
void ActivityManager::goToReader(std::string path) {
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
#include <cstdio>
|
||||
#include <memory>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsFormatLabel.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
@@ -191,7 +190,9 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
|
||||
const Rect contentRect = UITheme::getContentRect(renderer, true, false);
|
||||
const int midY = contentRect.y + contentRect.height / 2;
|
||||
|
||||
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) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, midY, statusMessage.c_str());
|
||||
@@ -303,21 +304,21 @@ 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.rfind("http", 0) == 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();
|
||||
@@ -419,12 +420,14 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book, const OpdsAcqu
|
||||
|
||||
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) {
|
||||
FsFile downloadedFile;
|
||||
@@ -564,7 +567,6 @@ void OpdsBookBrowserActivity::checkAndConnectWifi() {
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::launchWifiSelection() {
|
||||
consumeBack = consumeConfirm = true;
|
||||
state = BrowserState::WIFI_SELECTION;
|
||||
requestUpdate();
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
#include <OpdsParser.h>
|
||||
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
/**
|
||||
@@ -24,8 +26,8 @@ class OpdsBookBrowserActivity final : public Activity {
|
||||
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;
|
||||
@@ -50,6 +52,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);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "CrossPointState.h"
|
||||
#include "GlobalBookmarkIndex.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
@@ -111,7 +112,7 @@ void HomeActivity::rebuildMenuEntries() {
|
||||
if (!GLOBAL_BOOKMARKS.isEmpty()) {
|
||||
menuEntries.push_back({MenuAction::GlobalBookmarks, StrId::STR_GLOBAL_BOOKMARKS, Book});
|
||||
}
|
||||
if (hasOpdsUrl) {
|
||||
if (hasOpdsServers) {
|
||||
menuEntries.push_back({MenuAction::OpdsBrowser, StrId::STR_OPDS_BROWSER, Library});
|
||||
}
|
||||
menuEntries.push_back({MenuAction::FileTransfer, StrId::STR_FILE_TRANSFER, Transfer});
|
||||
@@ -195,8 +196,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;
|
||||
recentsLoading = false;
|
||||
|
||||
@@ -35,7 +35,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
|
||||
size_t nextRecentCoverIndex = 0;
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
#include "CalibreSettingsActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#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
|
||||
startActivityForResult(std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_CALIBRE_WEB_URL),
|
||||
SETTINGS.opdsServerUrl, 127, InputType::Url),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kb = std::get<KeyboardResult>(result.data);
|
||||
strncpy(SETTINGS.opdsServerUrl, kb.text.c_str(), sizeof(SETTINGS.opdsServerUrl) - 1);
|
||||
SETTINGS.opdsServerUrl[sizeof(SETTINGS.opdsServerUrl) - 1] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
});
|
||||
} else if (selectedIndex == 1) {
|
||||
// Username
|
||||
startActivityForResult(std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_USERNAME),
|
||||
SETTINGS.opdsUsername, 63, InputType::Text),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kb = std::get<KeyboardResult>(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<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_PASSWORD),
|
||||
SETTINGS.opdsPassword, 63, InputType::Password),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kb = std::get<KeyboardResult>(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 Rect contentRect = UITheme::getContentRect(renderer, true, false);
|
||||
GUI.drawHeader(renderer, Rect{contentRect.x, metrics.topPadding, contentRect.width, metrics.headerHeight},
|
||||
tr(STR_OPDS_BROWSER));
|
||||
GUI.drawSubHeader(
|
||||
renderer, Rect{contentRect.x, metrics.topPadding + metrics.headerHeight, contentRect.width, metrics.tabBarHeight},
|
||||
tr(STR_CALIBRE_URL_HINT));
|
||||
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.tabBarHeight;
|
||||
const int contentHeight = contentRect.height - contentTop - metrics.verticalSpacing * 2;
|
||||
GUI.drawList(
|
||||
renderer, Rect{contentRect.x, contentTop, contentRect.width, contentHeight}, static_cast<int>(MENU_ITEMS),
|
||||
static_cast<int>(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();
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
#include "OpdsServerListActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#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<int>(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<int>(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<size_t>(selectedIndex));
|
||||
if (server) {
|
||||
activityManager.replaceActivity(std::make_unique<OpdsBookBrowserActivity>(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();
|
||||
const int itemCount = getItemCount();
|
||||
selectedIndex = itemCount > 0 ? std::min(selectedIndex, itemCount - 1) : 0;
|
||||
};
|
||||
|
||||
if (selectedIndex < serverCount) {
|
||||
startActivityForResult(std::make_unique<OpdsSettingsActivity>(renderer, mappedInput, selectedIndex), resultHandler);
|
||||
} else {
|
||||
startActivityForResult(std::make_unique<OpdsSettingsActivity>(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<int>(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();
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
#include "OpdsSettingsActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#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;
|
||||
constexpr char INVALID_OPDS_URL_MESSAGE[] = "Enter a valid OPDS URL";
|
||||
} // 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;
|
||||
popupMessage.clear();
|
||||
|
||||
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<size_t>(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.
|
||||
const auto insertedIndex = OPDS_STORE.addServer(editServer);
|
||||
success = insertedIndex.has_value();
|
||||
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<int>(*insertedIndex);
|
||||
} 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<size_t>(serverIndex), editServer);
|
||||
if (!success) {
|
||||
LOG_ERR("OPS", "Failed to update OPDS server at index %d", serverIndex);
|
||||
}
|
||||
}
|
||||
|
||||
showSaveError = !success;
|
||||
if (success) {
|
||||
popupMessage.clear();
|
||||
}
|
||||
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<KeyboardResult>(result.data);
|
||||
editServer.name = kb.text;
|
||||
saveServer();
|
||||
requestUpdate();
|
||||
}
|
||||
};
|
||||
startActivityForResult(
|
||||
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_SERVER_NAME), editServer.name,
|
||||
OpdsServerStore::MAX_NAME_LENGTH, 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<KeyboardResult>(result.data);
|
||||
const auto normalizedUrl = OpdsServerValidation::normalizeUrl(kb.text);
|
||||
if (!normalizedUrl) {
|
||||
popupMessage = INVALID_OPDS_URL_MESSAGE;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
popupMessage.clear();
|
||||
editServer.url = *normalizedUrl;
|
||||
saveServer();
|
||||
requestUpdate();
|
||||
}
|
||||
};
|
||||
startActivityForResult(
|
||||
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_OPDS_SERVER_URL), prefillUrl,
|
||||
OpdsServerStore::MAX_URL_LENGTH, InputType::Url),
|
||||
handler);
|
||||
} else if (selectedIndex == 2) {
|
||||
// Username
|
||||
auto handler = [this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kb = std::get<KeyboardResult>(result.data);
|
||||
editServer.username = kb.text;
|
||||
saveServer();
|
||||
requestUpdate();
|
||||
}
|
||||
};
|
||||
startActivityForResult(
|
||||
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_USERNAME), editServer.username,
|
||||
OpdsServerStore::MAX_USERNAME_LENGTH, InputType::Text),
|
||||
handler);
|
||||
} else if (selectedIndex == 3) {
|
||||
// Password
|
||||
auto handler = [this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kb = std::get<KeyboardResult>(result.data);
|
||||
editServer.password = kb.text;
|
||||
saveServer();
|
||||
requestUpdate();
|
||||
}
|
||||
};
|
||||
startActivityForResult(
|
||||
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_PASSWORD), editServer.password,
|
||||
OpdsServerStore::MAX_PASSWORD_LENGTH, InputType::Password),
|
||||
handler);
|
||||
} else if (selectedIndex == 4 && !isNewServer) {
|
||||
// Delete flow is only available for existing servers.
|
||||
if (!OPDS_STORE.removeServer(static_cast<size_t>(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<int>(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 (!popupMessage.empty()) {
|
||||
GUI.drawPopup(renderer, popupMessage.c_str());
|
||||
} else if (showSaveError) {
|
||||
GUI.drawPopup(renderer, tr(STR_ERROR_GENERAL_FAILURE));
|
||||
}
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#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;
|
||||
std::string popupMessage;
|
||||
|
||||
int getMenuItemCount() const;
|
||||
void handleSelection();
|
||||
bool saveServer();
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
#include "SettingActionDispatch.h"
|
||||
|
||||
#include "ButtonRemapActivity.h"
|
||||
#include "CalibreSettingsActivity.h"
|
||||
#include "ClearCacheActivity.h"
|
||||
#include "ClockSettingsActivity.h"
|
||||
#include "DetectTimezoneActivity.h"
|
||||
#include "KOReaderSettingsActivity.h"
|
||||
#include "LanguageSelectActivity.h"
|
||||
#include "OpdsServerListActivity.h"
|
||||
#include "OtaUpdateActivity.h"
|
||||
#include "StatusBarSettingsActivity.h"
|
||||
#include "SyncTimeActivity.h"
|
||||
@@ -26,7 +26,7 @@ std::unique_ptr<Activity> createActivityForAction(SettingAction action, GfxRende
|
||||
case SettingAction::KOReaderSync:
|
||||
return std::make_unique<KOReaderSettingsActivity>(renderer, mappedInput);
|
||||
case SettingAction::OPDSBrowser:
|
||||
return std::make_unique<CalibreSettingsActivity>(renderer, mappedInput);
|
||||
return std::make_unique<OpdsServerListActivity>(renderer, mappedInput);
|
||||
case SettingAction::Network:
|
||||
return std::make_unique<WifiSelectionActivity>(renderer, mappedInput, false);
|
||||
case SettingAction::ClearCache:
|
||||
|
||||
+22
-20
@@ -22,6 +22,7 @@
|
||||
#include "GlobalBookmarkIndex.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "WeatherSettingsStore.h"
|
||||
#include "activities/Activity.h"
|
||||
@@ -205,6 +206,26 @@ void setup() {
|
||||
|
||||
LOG_INF("MAIN", "Hardware detect: %s", gpio.deviceIsX3() ? "X3" : "X4");
|
||||
|
||||
// SD Card Initialization
|
||||
// We need 6 open files concurrently when parsing a new chapter
|
||||
if (!Storage.begin()) {
|
||||
LOG_ERR("MAIN", "SD card initialization failed");
|
||||
setupDisplayAndFonts();
|
||||
activityManager.goToFullScreenMessage("SD card error", EpdFontFamily::BOLD);
|
||||
return;
|
||||
}
|
||||
|
||||
HalSystem::checkPanic();
|
||||
SETTINGS.loadFromFile();
|
||||
HalSystem::clearPanic(); // TODO: move this to an activity when we have one to display the panic info
|
||||
HalClock::applyTimezone(SETTINGS.timeZone);
|
||||
I18N.loadSettings();
|
||||
KOREADER_STORE.loadFromFile();
|
||||
OPDS_STORE.loadFromFile();
|
||||
WEATHER_SETTINGS.loadFromFile();
|
||||
UITheme::getInstance().reload();
|
||||
ButtonNavigator::setMappedInputManager(mappedInputManager);
|
||||
|
||||
const auto wakeupReason = gpio.getWakeupReason();
|
||||
LOG_DBG("MAIN", "Wakeup reason: %d, millis=%lu, rawPowerPin=%d", static_cast<int>(wakeupReason), millis(),
|
||||
digitalRead(InputManager::POWER_BUTTON_PIN) == LOW);
|
||||
@@ -233,25 +254,6 @@ void setup() {
|
||||
// First serial output only here to avoid timing inconsistencies for power button press duration verification
|
||||
LOG_DBG("MAIN", "Starting CrossPoint version " CROSSPOINT_VERSION);
|
||||
|
||||
// SD Card Initialization
|
||||
// We need 6 open files concurrently when parsing a new chapter
|
||||
if (!Storage.begin()) {
|
||||
LOG_ERR("MAIN", "SD card initialization failed");
|
||||
setupDisplayAndFonts();
|
||||
activityManager.goToFullScreenMessage("SD card error", EpdFontFamily::BOLD);
|
||||
return;
|
||||
}
|
||||
|
||||
HalSystem::checkPanic();
|
||||
HalSystem::clearPanic(); // TODO: move this to an activity when we have one to display the panic info
|
||||
SETTINGS.loadFromFile();
|
||||
HalClock::applyTimezone(SETTINGS.timeZone);
|
||||
I18N.loadSettings();
|
||||
KOREADER_STORE.loadFromFile();
|
||||
WEATHER_SETTINGS.loadFromFile();
|
||||
UITheme::getInstance().reload();
|
||||
ButtonNavigator::setMappedInputManager(mappedInputManager);
|
||||
|
||||
setupDisplayAndFonts();
|
||||
|
||||
activityManager.goToBoot();
|
||||
@@ -410,4 +412,4 @@ void loop() {
|
||||
delay(10);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <algorithm>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "SettingsList.h"
|
||||
#include "SystemStatus.h"
|
||||
#include "WebDAVHandler.h"
|
||||
@@ -193,6 +194,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());
|
||||
|
||||
@@ -1369,6 +1375,167 @@ 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;
|
||||
bool seenFirst = false;
|
||||
|
||||
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 (seenFirst) {
|
||||
server->sendContent(",");
|
||||
}
|
||||
seenFirst = true;
|
||||
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;
|
||||
}
|
||||
|
||||
const std::string name = doc["name"] | std::string("");
|
||||
const std::string rawUrl = doc["url"] | std::string("");
|
||||
const std::string 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<const char*>() || doc["password"].is<std::string>();
|
||||
std::string password = doc["password"] | std::string("");
|
||||
|
||||
const auto normalizedUrl = OpdsServerValidation::normalizeUrl(rawUrl);
|
||||
if (!normalizedUrl) {
|
||||
server->send(400, "text/plain", "Invalid URL");
|
||||
return;
|
||||
}
|
||||
if (name.size() > OpdsServerStore::MAX_NAME_LENGTH) {
|
||||
server->send(400, "text/plain", "Server name too long");
|
||||
return;
|
||||
}
|
||||
if (normalizedUrl->size() > OpdsServerStore::MAX_URL_LENGTH) {
|
||||
server->send(400, "text/plain", "URL too long");
|
||||
return;
|
||||
}
|
||||
if (username.size() > OpdsServerStore::MAX_USERNAME_LENGTH) {
|
||||
server->send(400, "text/plain", "Username too long");
|
||||
return;
|
||||
}
|
||||
|
||||
OpdsServer opdsServer;
|
||||
opdsServer.name = name;
|
||||
opdsServer.url = *normalizedUrl;
|
||||
opdsServer.username = username;
|
||||
|
||||
if (doc["index"].is<int>()) {
|
||||
int idx = doc["index"].as<int>();
|
||||
if (idx < 0 || idx >= static_cast<int>(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<size_t>(idx));
|
||||
if (existing) password = existing->password;
|
||||
}
|
||||
if (password.size() > OpdsServerStore::MAX_PASSWORD_LENGTH) {
|
||||
server->send(400, "text/plain", "Password too long");
|
||||
return;
|
||||
}
|
||||
opdsServer.password = password;
|
||||
if (!OPDS_STORE.updateServer(static_cast<size_t>(idx), opdsServer)) {
|
||||
server->send(500, "text/plain", "Failed to save server");
|
||||
return;
|
||||
}
|
||||
LOG_DBG("WEB", "Updated OPDS server at index %d", idx);
|
||||
} else {
|
||||
if (OPDS_STORE.getCount() >= OpdsServerStore::MAX_SERVERS) {
|
||||
server->send(400, "text/plain", "Cannot add server (limit reached)");
|
||||
return;
|
||||
}
|
||||
if (password.size() > OpdsServerStore::MAX_PASSWORD_LENGTH) {
|
||||
server->send(400, "text/plain", "Password too long");
|
||||
return;
|
||||
}
|
||||
opdsServer.password = password;
|
||||
const auto insertedIndex = OPDS_STORE.addServer(opdsServer);
|
||||
if (!insertedIndex) {
|
||||
server->send(500, "text/plain", "Failed to save server");
|
||||
return;
|
||||
}
|
||||
LOG_DBG("WEB", "Added new OPDS server at index %zu: %s", *insertedIndex, 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<int>()) {
|
||||
server->send(400, "text/plain", "Missing index");
|
||||
return;
|
||||
}
|
||||
|
||||
int idx = doc["index"].as<int>();
|
||||
if (idx < 0 || idx >= static_cast<int>(OPDS_STORE.getCount())) {
|
||||
server->send(400, "text/plain", "Invalid server index");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!OPDS_STORE.removeServer(static_cast<size_t>(idx))) {
|
||||
server->send(500, "text/plain", "Failed to delete server");
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -109,4 +109,9 @@ class CrossPointWebServer {
|
||||
void handleSettingsPage() const;
|
||||
void handleGetSettings() const;
|
||||
void handlePostSettings();
|
||||
|
||||
// OPDS server handlers
|
||||
void handleGetOpdsServers() const;
|
||||
void handlePostOpdsServer();
|
||||
void handleDeleteOpdsServer();
|
||||
};
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
#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<NetworkClient> client;
|
||||
if (UrlUtils::isHttpsUrl(url)) {
|
||||
auto* secureClient = new NetworkClientSecure();
|
||||
@@ -71,9 +70,8 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
|
||||
http.setTimeout(30000);
|
||||
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);
|
||||
}
|
||||
@@ -93,9 +91,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();
|
||||
@@ -103,8 +102,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<NetworkClient> client;
|
||||
if (UrlUtils::isHttpsUrl(url)) {
|
||||
auto* secureClient = new NetworkClientSecure();
|
||||
@@ -123,9 +122,8 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
|
||||
http.setTimeout(65535); // max uint16_t (~65s) — HTTPClient::setTimeout takes uint16_t ms
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 = "");
|
||||
};
|
||||
|
||||
@@ -223,6 +223,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;
|
||||
@@ -273,6 +315,8 @@
|
||||
<button class="save-btn" id="saveBtn" onclick="saveSettings()">Save Settings</button>
|
||||
</div>
|
||||
|
||||
<div id="opds-container"></div>
|
||||
|
||||
<div class="card">
|
||||
<p style="text-align: center; color: #95a5a6; margin: 0;">
|
||||
CrossPoint E-Reader • Open Source
|
||||
@@ -459,6 +503,127 @@
|
||||
}
|
||||
|
||||
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 = [];
|
||||
const MAX_OPDS_SERVERS = 8;
|
||||
|
||||
function renderOpdsServer(srv, idx) {
|
||||
const isNew = idx === -1;
|
||||
const id = isNew ? 'new' : idx;
|
||||
return '<div class="opds-server" id="opds-' + id + '">' +
|
||||
'<div class="setting-row">' +
|
||||
'<span class="setting-name">Server Name</span>' +
|
||||
'<span class="setting-control"><input type="text" id="opds-name-' + id + '" value="' + escapeHtml(srv.name || '') + '"></span>' +
|
||||
'</div>' +
|
||||
'<div class="setting-row">' +
|
||||
'<span class="setting-name">URL</span>' +
|
||||
'<span class="setting-control"><input type="text" id="opds-url-' + id + '" value="' + escapeHtml(srv.url || '') + '"></span>' +
|
||||
'</div>' +
|
||||
'<div class="setting-row">' +
|
||||
'<span class="setting-name">Username</span>' +
|
||||
'<span class="setting-control"><input type="text" id="opds-user-' + id + '" value="' + escapeHtml(srv.username || '') + '"></span>' +
|
||||
'</div>' +
|
||||
'<div class="setting-row">' +
|
||||
'<span class="setting-name">Password</span>' +
|
||||
'<span class="setting-control"><input type="password" id="opds-pass-' + id + '" placeholder="' + (srv.hasPassword ? '(unchanged)' : '') + '"></span>' +
|
||||
'</div>' +
|
||||
'<div class="opds-actions">' +
|
||||
'<button class="btn-small btn-save-server" onclick="saveOpdsServer(' + idx + ')">Save</button>' +
|
||||
(isNew ? '' : '<button class="btn-small btn-delete" onclick="deleteOpdsServer(' + idx + ')">Delete</button>') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderOpdsSection() {
|
||||
const container = document.getElementById('opds-container');
|
||||
let html = '<div class="card"><h2>OPDS Servers</h2>';
|
||||
const canAddOpdsServer = opdsServers.length < MAX_OPDS_SERVERS;
|
||||
|
||||
if (opdsServers.length === 0) {
|
||||
html += '<p style="color:var(--label-color);text-align:center;">No OPDS servers configured</p>';
|
||||
} else {
|
||||
opdsServers.forEach(function(srv, idx) {
|
||||
html += renderOpdsServer(srv, idx);
|
||||
});
|
||||
}
|
||||
|
||||
html += '<div style="margin-top:12px;text-align:center;">' +
|
||||
(canAddOpdsServer
|
||||
? '<button class="btn-small btn-add" onclick="addOpdsServer()">+ Add Server</button>'
|
||||
: '<span style="color:var(--label-color);">Maximum of 8 servers reached</span>') +
|
||||
'</div></div>';
|
||||
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() {
|
||||
if (opdsServers.length >= MAX_OPDS_SERVERS) return;
|
||||
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();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user