feat: add OPDS server reordering with sortOrder field and numeric stepper

Servers are sorted by a persistent sortOrder field (ties broken
alphabetically). On-device editing uses a new NumericStepperActivity
with side buttons for ±1 and face buttons for ±10. The web UI gets
up/down arrow buttons and a POST /api/opds/reorder endpoint.

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-02 14:35:36 -05:00
parent 3628d8eb37
commit f955cf2fb4
18 changed files with 328 additions and 25 deletions

View File

@@ -426,6 +426,7 @@ enum class StrId : uint16_t {
STR_SAVE_HERE,
STR_SELECT_FOLDER,
STR_DOWNLOAD_PATH,
STR_POSITION,
// Sentinel - must be last
_COUNT
};

View File

@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "OPDS servery"
STR_SAVE_HERE: "Uložit zde"
STR_SELECT_FOLDER: "Vybrat složku"
STR_DOWNLOAD_PATH: "Cesta ke stažení"
STR_POSITION: "Pozice"

View File

@@ -390,3 +390,4 @@ STR_OPDS_SERVERS: "OPDS Servers"
STR_SAVE_HERE: "Save Here"
STR_SELECT_FOLDER: "Select Folder"
STR_DOWNLOAD_PATH: "Download Path"
STR_POSITION: "Position"

View File

@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "Serveurs OPDS"
STR_SAVE_HERE: "Enregistrer ici"
STR_SELECT_FOLDER: "Sélectionner un dossier"
STR_DOWNLOAD_PATH: "Chemin de téléchargement"
STR_POSITION: "Position"

View File

@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "OPDS-Server"
STR_SAVE_HERE: "Hier speichern"
STR_SELECT_FOLDER: "Ordner auswählen"
STR_DOWNLOAD_PATH: "Download-Pfad"
STR_POSITION: "Position"

View File

@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "Servidores OPDS"
STR_SAVE_HERE: "Salvar aqui"
STR_SELECT_FOLDER: "Selecionar pasta"
STR_DOWNLOAD_PATH: "Caminho de download"
STR_POSITION: "Posição"

View File

@@ -328,3 +328,4 @@ STR_OPDS_SERVERS: "Servere OPDS"
STR_SAVE_HERE: "Salvează aici"
STR_SELECT_FOLDER: "Selectează dosar"
STR_DOWNLOAD_PATH: "Cale descărcare"
STR_POSITION: "Poziție"

View File

@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "Серверы OPDS"
STR_SAVE_HERE: "Сохранить здесь"
STR_SELECT_FOLDER: "Выбрать папку"
STR_DOWNLOAD_PATH: "Путь загрузки"
STR_POSITION: "Позиция"

View File

@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "Servidores OPDS"
STR_SAVE_HERE: "Guardar aquí"
STR_SELECT_FOLDER: "Seleccionar carpeta"
STR_DOWNLOAD_PATH: "Ruta de descarga"
STR_POSITION: "Posición"

View File

@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "OPDS-servrar"
STR_SAVE_HERE: "Spara här"
STR_SELECT_FOLDER: "Välj mapp"
STR_DOWNLOAD_PATH: "Nedladdningssökväg"
STR_POSITION: "Position"

View File

@@ -7,6 +7,7 @@
#include <esp_mac.h>
#include <mbedtls/base64.h>
#include <algorithm>
#include <cstring>
#include "CrossPointSettings.h"
@@ -81,6 +82,7 @@ bool OpdsServerStore::saveToFile() const {
obj["username"] = server.username;
obj["password_obf"] = obfuscateToBase64(server.password);
obj["download_path"] = server.downloadPath;
obj["sort_order"] = server.sortOrder;
}
String json;
@@ -116,13 +118,32 @@ bool OpdsServerStore::loadFromFile() {
if (!server.password.empty()) needsResave = true;
}
server.downloadPath = obj["download_path"] | std::string("/");
server.sortOrder = obj["sort_order"] | 0;
if (server.sortOrder == 0) needsResave = true;
servers.push_back(std::move(server));
}
// Assign sequential sort orders to servers loaded without one
bool anyZero = false;
for (const auto& s : servers) {
if (s.sortOrder == 0) {
anyZero = true;
break;
}
}
if (anyZero) {
for (size_t i = 0; i < servers.size(); i++) {
if (servers[i].sortOrder == 0) {
servers[i].sortOrder = static_cast<int>(i) + 1;
}
}
}
sortServers();
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", servers.size());
if (needsResave) {
LOG_DBG("OPS", "Resaving JSON with obfuscated passwords");
LOG_DBG("OPS", "Resaving JSON with sort_order / obfuscated passwords");
saveToFile();
}
return true;
@@ -149,6 +170,7 @@ bool OpdsServerStore::migrateFromSettings() {
server.url = SETTINGS.opdsServerUrl;
server.username = SETTINGS.opdsUsername;
server.password = SETTINGS.opdsPassword;
server.sortOrder = 1;
servers.push_back(std::move(server));
if (saveToFile()) {
@@ -171,7 +193,15 @@ bool OpdsServerStore::addServer(const OpdsServer& server) {
}
servers.push_back(server);
LOG_DBG("OPS", "Added server: %s", server.name.c_str());
if (servers.back().sortOrder == 0) {
int maxOrder = 0;
for (size_t i = 0; i + 1 < servers.size(); i++) {
maxOrder = std::max(maxOrder, servers[i].sortOrder);
}
servers.back().sortOrder = maxOrder + 1;
}
LOG_DBG("OPS", "Added server: %s (order=%d)", server.name.c_str(), servers.back().sortOrder);
sortServers();
return saveToFile();
}
@@ -181,7 +211,8 @@ bool OpdsServerStore::updateServer(size_t index, const OpdsServer& server) {
}
servers[index] = server;
LOG_DBG("OPS", "Updated server: %s", server.name.c_str());
sortServers();
LOG_DBG("OPS", "Updated server: %s (order=%d)", server.name.c_str(), server.sortOrder);
return saveToFile();
}
@@ -201,3 +232,30 @@ const OpdsServer* OpdsServerStore::getServer(size_t index) const {
}
return &servers[index];
}
bool OpdsServerStore::moveServer(size_t index, int direction) {
if (index >= servers.size()) return false;
size_t target;
if (direction < 0) {
if (index == 0) return false;
target = index - 1;
} else {
if (index + 1 >= servers.size()) return false;
target = index + 1;
}
std::swap(servers[index].sortOrder, servers[target].sortOrder);
sortServers();
return saveToFile();
}
void OpdsServerStore::sortServers() {
std::sort(servers.begin(), servers.end(), [](const OpdsServer& a, const OpdsServer& b) {
if (a.sortOrder != b.sortOrder) return a.sortOrder < b.sortOrder;
const auto& nameA = a.name.empty() ? a.url : a.name;
const auto& nameB = b.name.empty() ? b.url : b.name;
return std::lexicographical_compare(nameA.begin(), nameA.end(), nameB.begin(), nameB.end(),
[](char ca, char cb) { return tolower(ca) < tolower(cb); });
});
}

View File

@@ -8,6 +8,7 @@ struct OpdsServer {
std::string username;
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
std::string downloadPath = "/";
int sortOrder = 0; // Lower values appear first; ties broken alphabetically by name
};
/**
@@ -36,6 +37,7 @@ class OpdsServerStore {
bool addServer(const OpdsServer& server);
bool updateServer(size_t index, const OpdsServer& server);
bool removeServer(size_t index);
bool moveServer(size_t index, int direction);
const std::vector<OpdsServer>& getServers() const { return servers; }
const OpdsServer* getServer(size_t index) const;
@@ -47,6 +49,9 @@ class OpdsServerStore {
* Called once during first load if no opds.json exists.
*/
bool migrateFromSettings();
private:
void sortServers();
};
#define OPDS_STORE OpdsServerStore::getInstance()

View File

@@ -4,18 +4,20 @@
#include <I18n.h>
#include <cstring>
#include <string>
#include "MappedInputManager.h"
#include "OpdsServerStore.h"
#include "activities/util/DirectoryPickerActivity.h"
#include "activities/util/KeyboardEntryActivity.h"
#include "activities/util/NumericStepperActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
// Editable fields: Name, URL, Username, Password, Download Path.
// Editable fields: Position, Name, URL, Username, Password, Download Path.
// Existing servers also show a Delete option (BASE_ITEMS + 1).
constexpr int BASE_ITEMS = 5;
constexpr int BASE_ITEMS = 6;
} // namespace
int OpdsSettingsActivity::getMenuItemCount() const {
@@ -75,17 +77,38 @@ void OpdsSettingsActivity::loop() {
void OpdsSettingsActivity::saveServer() {
if (isNewServer) {
OPDS_STORE.addServer(editServer);
// After the first field is saved, promote to an existing server so
// subsequent field edits update in-place rather than creating duplicates.
isNewServer = false;
serverIndex = static_cast<int>(OPDS_STORE.getCount()) - 1;
} else {
OPDS_STORE.updateServer(static_cast<size_t>(serverIndex), editServer);
}
// Re-locate our server after add/update may have re-sorted the vector
const auto& servers = OPDS_STORE.getServers();
for (size_t i = 0; i < servers.size(); i++) {
if (servers[i].url == editServer.url && servers[i].name == editServer.name) {
serverIndex = static_cast<int>(i);
break;
}
}
}
void OpdsSettingsActivity::handleSelection() {
if (selectedIndex == 0) {
// Position (sort order)
exitActivity();
enterNewActivity(new NumericStepperActivity(
renderer, mappedInput, tr(STR_POSITION), editServer.sortOrder, 1, 99,
[this](int value) {
editServer.sortOrder = value;
saveServer();
exitActivity();
requestUpdate();
},
[this]() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 1) {
// Server Name
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
@@ -100,7 +123,7 @@ void OpdsSettingsActivity::handleSelection() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 1) {
} else if (selectedIndex == 2) {
// Server URL
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
@@ -115,7 +138,7 @@ void OpdsSettingsActivity::handleSelection() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 2) {
} else if (selectedIndex == 3) {
// Username
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
@@ -130,7 +153,7 @@ void OpdsSettingsActivity::handleSelection() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 3) {
} else if (selectedIndex == 4) {
// Password
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
@@ -145,7 +168,7 @@ void OpdsSettingsActivity::handleSelection() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 4) {
} else if (selectedIndex == 5) {
// Download Path
exitActivity();
enterNewActivity(new DirectoryPickerActivity(
@@ -162,7 +185,7 @@ void OpdsSettingsActivity::handleSelection() {
requestUpdate();
},
editServer.downloadPath));
} else if (selectedIndex == 5 && !isNewServer) {
} else if (selectedIndex == 6 && !isNewServer) {
// Delete server
OPDS_STORE.removeServer(static_cast<size_t>(serverIndex));
onBack();
@@ -184,8 +207,8 @@ void OpdsSettingsActivity::render(Activity::RenderLock&&) {
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, StrId::STR_DOWNLOAD_PATH};
const StrId fieldNames[] = {StrId::STR_POSITION, StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL,
StrId::STR_USERNAME, StrId::STR_PASSWORD, StrId::STR_DOWNLOAD_PATH};
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast<int>(selectedIndex),
@@ -198,14 +221,16 @@ void OpdsSettingsActivity::render(Activity::RenderLock&&) {
nullptr, nullptr,
[this](int index) {
if (index == 0) {
return editServer.name.empty() ? std::string(tr(STR_NOT_SET)) : editServer.name;
return std::to_string(editServer.sortOrder);
} else if (index == 1) {
return editServer.url.empty() ? std::string(tr(STR_NOT_SET)) : editServer.url;
return editServer.name.empty() ? std::string(tr(STR_NOT_SET)) : editServer.name;
} else if (index == 2) {
return editServer.username.empty() ? std::string(tr(STR_NOT_SET)) : editServer.username;
return editServer.url.empty() ? std::string(tr(STR_NOT_SET)) : editServer.url;
} else if (index == 3) {
return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
return editServer.username.empty() ? std::string(tr(STR_NOT_SET)) : editServer.username;
} else if (index == 4) {
return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
} else if (index == 5) {
return editServer.downloadPath;
}
return std::string("");

View File

@@ -0,0 +1,99 @@
#include "NumericStepperActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include <algorithm>
#include <cstdio>
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
void NumericStepperActivity::onEnter() {
Activity::onEnter();
requestUpdate();
}
void NumericStepperActivity::onExit() { Activity::onExit(); }
void NumericStepperActivity::loop() {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
if (onCancel) onCancel();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (onComplete) onComplete(value);
return;
}
// Side buttons: ±1
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
if (value < maxValue) {
value++;
requestUpdate();
}
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
if (value > minValue) {
value--;
requestUpdate();
}
return;
}
// Front face buttons: ±10
if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
value = std::min(value + 10, maxValue);
requestUpdate();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Left)) {
value = std::max(value - 10, minValue);
requestUpdate();
return;
}
}
void NumericStepperActivity::render(Activity::RenderLock&&) {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const int lineHeight12 = renderer.getLineHeight(UI_12_FONT_ID);
const auto& metrics = UITheme::getInstance().getMetrics();
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, title.c_str());
char valueStr[16];
snprintf(valueStr, sizeof(valueStr), "%d", value);
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, valueStr);
const int startX = (pageWidth - textWidth) / 2;
const int valueY = metrics.topPadding + metrics.headerHeight + 60;
constexpr int highlightPad = 10;
renderer.fillRoundedRect(startX - highlightPad, valueY - 4, textWidth + highlightPad * 2, lineHeight12 + 8, 6,
Color::LightGray);
renderer.drawText(UI_12_FONT_ID, startX, valueY, valueStr, true);
const int arrowX = pageWidth / 2;
const int arrowUpY = valueY - 20;
const int arrowDownY = valueY + lineHeight12 + 12;
constexpr int arrowSize = 6;
for (int row = 0; row < arrowSize; row++) {
renderer.drawLine(arrowX - row, arrowUpY + row, arrowX + row, arrowUpY + row);
}
for (int row = 0; row < arrowSize; row++) {
renderer.drawLine(arrowX - row, arrowDownY + arrowSize - 1 - row, arrowX + row, arrowDownY + arrowSize - 1 - row);
}
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SAVE), "-10", "+10");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
GUI.drawSideButtonHints(renderer, "+1", "-1");
renderer.displayBuffer();
}

View File

@@ -0,0 +1,42 @@
#pragma once
#include <functional>
#include <string>
#include <utility>
#include "activities/Activity.h"
/**
* Reusable numeric stepper for integer value entry.
* Side buttons (Up/Down) step by 1, face buttons (Left/Right) step by 10.
* Value is clamped within [min, max].
*/
class NumericStepperActivity final : public Activity {
public:
using OnCompleteCallback = std::function<void(int)>;
using OnCancelCallback = std::function<void()>;
explicit NumericStepperActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string title,
int initialValue, int minValue, int maxValue, OnCompleteCallback onComplete,
OnCancelCallback onCancel)
: Activity("NumericStepper", renderer, mappedInput),
title(std::move(title)),
value(initialValue),
minValue(minValue),
maxValue(maxValue),
onComplete(std::move(onComplete)),
onCancel(std::move(onCancel)) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(Activity::RenderLock&&) override;
private:
std::string title;
int value;
int minValue;
int maxValue;
OnCompleteCallback onComplete;
OnCancelCallback onCancel;
};

View File

@@ -161,6 +161,7 @@ void CrossPointWebServer::begin() {
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->on("/api/opds/reorder", HTTP_POST, [this] { handleReorderOpdsServer(); });
server->onNotFound([this] { handleNotFound(); });
LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap());
@@ -1182,6 +1183,7 @@ void CrossPointWebServer::handleGetOpdsServers() const {
doc["url"] = servers[i].url;
doc["username"] = servers[i].username;
doc["hasPassword"] = !servers[i].password.empty();
doc["sortOrder"] = servers[i].sortOrder;
const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) continue;
@@ -1223,9 +1225,11 @@ void CrossPointWebServer::handlePostOpdsServer() {
server->send(400, "text/plain", "Invalid server index");
return;
}
if (!hasPasswordField) {
const auto* existing = OPDS_STORE.getServer(static_cast<size_t>(idx));
if (existing) password = existing->password;
const auto* existing = OPDS_STORE.getServer(static_cast<size_t>(idx));
if (existing) {
if (!hasPasswordField) password = existing->password;
opdsServer.downloadPath = existing->downloadPath;
opdsServer.sortOrder = existing->sortOrder;
}
opdsServer.password = password;
OPDS_STORE.updateServer(static_cast<size_t>(idx), opdsServer);
@@ -1273,6 +1277,46 @@ void CrossPointWebServer::handleDeleteOpdsServer() {
server->send(200, "text/plain", "OK");
}
void CrossPointWebServer::handleReorderOpdsServer() {
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;
}
const int idx = doc["index"].as<int>();
const String direction = doc["direction"] | "";
int dir = 0;
if (direction == "up") {
dir = -1;
} else if (direction == "down") {
dir = 1;
} else {
server->send(400, "text/plain", "Invalid direction (use \"up\" or \"down\")");
return;
}
if (!OPDS_STORE.moveServer(static_cast<size_t>(idx), dir)) {
server->send(400, "text/plain", "Cannot move server");
return;
}
LOG_DBG("WEB", "Reordered OPDS server at index %d (%s)", idx, direction.c_str());
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) {

View File

@@ -110,4 +110,5 @@ class CrossPointWebServer {
void handleGetOpdsServers() const;
void handlePostOpdsServer();
void handleDeleteOpdsServer();
void handleReorderOpdsServer();
};

View File

@@ -457,9 +457,11 @@
// --- OPDS Server Management ---
let opdsServers = [];
function renderOpdsServer(srv, idx) {
function renderOpdsServer(srv, idx, total) {
const isNew = idx === -1;
const id = isNew ? 'new' : idx;
const isFirst = idx === 0;
const isLast = idx === total - 1;
return '<div class="opds-server" id="opds-' + id + '">' +
'<div class="setting-row">' +
'<span class="setting-name">Server Name</span>' +
@@ -480,6 +482,8 @@
'<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>') +
(!isNew && !isFirst ? '<button class="btn-small" onclick="reorderOpdsServer(' + idx + ',\'up\')" title="Move up">\u25B2</button>' : '') +
(!isNew && !isLast ? '<button class="btn-small" onclick="reorderOpdsServer(' + idx + ',\'down\')" title="Move down">\u25BC</button>' : '') +
'</div>' +
'</div>';
}
@@ -491,8 +495,9 @@
if (opdsServers.length === 0) {
html += '<p style="color:var(--label-color);text-align:center;">No OPDS servers configured</p>';
} else {
var total = opdsServers.length;
opdsServers.forEach(function(srv, idx) {
html += renderOpdsServer(srv, idx);
html += renderOpdsServer(srv, idx, total);
});
}
@@ -518,7 +523,7 @@
const card = container.querySelector('.card');
const addBtn = card.querySelector('.btn-add').parentElement;
if (document.getElementById('opds-new')) return;
addBtn.insertAdjacentHTML('beforebegin', renderOpdsServer({name:'',url:'',username:'',hasPassword:false}, -1));
addBtn.insertAdjacentHTML('beforebegin', renderOpdsServer({name:'',url:'',username:'',hasPassword:false}, -1, 0));
}
async function saveOpdsServer(idx) {
@@ -562,6 +567,20 @@
}
}
async function reorderOpdsServer(idx, direction) {
try {
const resp = await fetch('/api/opds/reorder', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({index: idx, direction: direction})
});
if (!resp.ok) throw new Error(await resp.text());
await loadOpdsServers();
} catch (e) {
showMessage('Error: ' + e.message, true);
}
}
loadOpdsServers();
</script>
</body>