From e96514dc0b513e3e057ad62c52fb771337508df2 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Thu, 14 Aug 2025 20:47:19 -0700 Subject: [PATCH 1/6] WebUI: replace callback with promise chaining --- src/webui/www/private/scripts/cache.js | 77 +++++++------------ .../www/private/views/confirmdeletion.html | 7 +- src/webui/www/private/views/preferences.html | 22 +++--- 3 files changed, 37 insertions(+), 69 deletions(-) diff --git a/src/webui/www/private/scripts/cache.js b/src/webui/www/private/scripts/cache.js index 999a5db2d..25abb4c33 100644 --- a/src/webui/www/private/scripts/cache.js +++ b/src/webui/www/private/scripts/cache.js @@ -76,77 +76,52 @@ window.qBittorrent.Cache ??= (() => { class PreferencesCache { #m_store = {}; - // obj: { - // onFailure: () => {}, - // onSuccess: () => {} - // } - init(obj = {}) { - return fetch("api/v2/app/preferences", { + async init() { + return await fetch("api/v2/app/preferences", { method: "GET", cache: "no-store" }) .then(async (response) => { - if (!response.ok) - return; + if (!response.ok) + return; - const responseText = await response.text(); - const responseJSON = JSON.parse(responseText); - deepFreeze(responseJSON); - this.#m_store = responseJSON; + const responseText = await response.text(); + const responseJSON = JSON.parse(responseText); + deepFreeze(responseJSON); + this.#m_store = responseJSON; - if (typeof obj.onSuccess === "function") - obj.onSuccess(responseJSON, responseText); - }, - (error) => { - if (typeof obj.onFailure === "function") - obj.onFailure(error); - }); + return responseJSON; + }); } get() { return this.#m_store; } - // obj: { - // data: {}, - // onFailure: () => {}, - // onSuccess: () => {} - // } - set(obj) { - if (typeof obj !== "object") - throw new Error("`obj` is not an object."); - if (typeof obj.data !== "object") + async set(data) { + if (typeof data !== "object") throw new Error("`data` is not an object."); - fetch("api/v2/app/setPreferences", { + return await fetch("api/v2/app/setPreferences", { method: "POST", body: new URLSearchParams({ - json: JSON.stringify(obj.data) + json: JSON.stringify(data) }) }) - .then(async (response) => { - if (!response.ok) - return; + .then((response) => { + if (!response.ok) + return; - this.#m_store = structuredClone(this.#m_store); - for (const key in obj.data) { - if (!Object.hasOwn(obj.data, key)) - continue; + this.#m_store = structuredClone(this.#m_store); + for (const key in data) { + if (!Object.hasOwn(data, key)) + continue; - const value = obj.data[key]; - this.#m_store[key] = value; - } - deepFreeze(this.#m_store); - - if (typeof obj.onSuccess === "function") { - const responseText = await response.text(); - obj.onSuccess(responseText); - } - }, - (error) => { - if (typeof obj.onFailure === "function") - obj.onFailure(error); - }); + const value = data[key]; + this.#m_store[key] = value; + } + deepFreeze(this.#m_store); + }); } } diff --git a/src/webui/www/private/views/confirmdeletion.html b/src/webui/www/private/views/confirmdeletion.html index 80f976682..215e80fef 100644 --- a/src/webui/www/private/views/confirmdeletion.html +++ b/src/webui/www/private/views/confirmdeletion.html @@ -58,14 +58,11 @@ // Set current "Delete files" choice as the default rememberButton.addEventListener("click", (e) => { window.qBittorrent.Cache.preferences.set({ - data: { delete_torrent_content_files: deleteCB.checked - }, - onSuccess: () => { + }).then(() => { prefDeleteContentFiles = deleteCB.checked; setRememberBtnEnabled(false); - } - }); + }); }); cancelButton.focus(); diff --git a/src/webui/www/private/views/preferences.html b/src/webui/www/private/views/preferences.html index b2e81856f..36d6fb321 100644 --- a/src/webui/www/private/views/preferences.html +++ b/src/webui/www/private/views/preferences.html @@ -2228,8 +2228,8 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD }; const loadPreferences = () => { - window.parent.qBittorrent.Cache.preferences.init({ - onSuccess: (pref) => { + window.parent.qBittorrent.Cache.preferences.init() + .then((pref) => { // Behavior tab // Language updateWebuiLocaleSelect(pref.locale); @@ -2650,8 +2650,7 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD document.getElementById("i2pOutboundQuantity").value = pref.i2p_outbound_quantity; document.getElementById("i2pInboundLength").value = pref.i2p_inbound_length; document.getElementById("i2pOutboundLength").value = pref.i2p_outbound_length; - } - }); + }); }; const applyPreferences = () => { @@ -3169,18 +3168,15 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD settings["i2p_outbound_length"] = Number(document.getElementById("i2pOutboundLength").value); // Send it to qBT - window.parent.qBittorrent.Cache.preferences.set({ - data: settings, - onFailure: () => { - alert("QBT_TR(Unable to save program preferences, qBittorrent is probably unreachable.)QBT_TR[CONTEXT=HttpServer]"); - window.parent.qBittorrent.Client.closeWindow(document.getElementById("preferencesPage")); - }, - onSuccess: () => { + window.parent.qBittorrent.Cache.preferences.set(settings) + .then(() => { // Close window window.parent.location.reload(); window.parent.qBittorrent.Client.closeWindow(document.getElementById("preferencesPage")); - } - }); + }).catch((error) => { + alert("QBT_TR(Unable to save program preferences, qBittorrent is probably unreachable.)QBT_TR[CONTEXT=HttpServer]"); + window.parent.qBittorrent.Client.closeWindow(document.getElementById("preferencesPage")); + }); }; const setup = () => { From c894ceccdbd2e416e06b44e5d96910e1b40c6b5f Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Thu, 14 Aug 2025 20:56:55 -0700 Subject: [PATCH 2/6] WebUI: display alert when unable to load preferences --- src/webui/www/private/views/preferences.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/webui/www/private/views/preferences.html b/src/webui/www/private/views/preferences.html index 36d6fb321..80b089459 100644 --- a/src/webui/www/private/views/preferences.html +++ b/src/webui/www/private/views/preferences.html @@ -2650,6 +2650,9 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD document.getElementById("i2pOutboundQuantity").value = pref.i2p_outbound_quantity; document.getElementById("i2pInboundLength").value = pref.i2p_inbound_length; document.getElementById("i2pOutboundLength").value = pref.i2p_outbound_length; + }).catch((error) => { + console.error(error); + alert("QBT_TR(Unable to load program preferences, qBittorrent is probably unreachable.)QBT_TR[CONTEXT=HttpServer]"); }); }; From ee5d0896b0d290b4a34c3c8f7c87ad03509bf606 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Sun, 10 Aug 2025 21:36:23 -0700 Subject: [PATCH 3/6] WebAPI: Support persisting WebUI client preferences This provides a mechanism for persisting WebUI client preferences that are distinct from the broader qBittorrent preferences. These preferences apply exclusively to the WebUI. --- src/webui/api/appcontroller.cpp | 122 ++++++++++++++++++++++++++++++++ src/webui/api/appcontroller.h | 11 ++- src/webui/webapplication.h | 1 + 3 files changed, 133 insertions(+), 1 deletion(-) diff --git a/src/webui/api/appcontroller.cpp b/src/webui/api/appcontroller.cpp index e2e465acd..0aa059636 100644 --- a/src/webui/api/appcontroller.cpp +++ b/src/webui/api/appcontroller.cpp @@ -51,17 +51,20 @@ #include "base/bittorrent/session.h" #include "base/global.h" #include "base/interfaces/iapplication.h" +#include "base/logger.h" #include "base/net/downloadmanager.h" #include "base/net/portforwarder.h" #include "base/net/proxyconfigurationmanager.h" #include "base/path.h" #include "base/preferences.h" +#include "base/profile.h" #include "base/rss/rss_autodownloader.h" #include "base/rss/rss_session.h" #include "base/torrentfileguard.h" #include "base/torrentfileswatcher.h" #include "base/utils/datetime.h" #include "base/utils/fs.h" +#include "base/utils/io.h" #include "base/utils/misc.h" #include "base/utils/net.h" #include "base/utils/password.h" @@ -84,6 +87,42 @@ const QString KEY_FILE_METADATA_CREATION_DATE = u"creation_date"_s; const QString KEY_FILE_METADATA_LAST_ACCESS_DATE = u"last_access_date"_s; const QString KEY_FILE_METADATA_LAST_MODIFICATION_DATE = u"last_modification_date"_s; +const int CLIENT_DATA_FILE_MAX_SIZE = 1024 * 1024; +const QString CLIENT_DATA_FILE_NAME = u"web_data.json"_s; + +AppController::AppController(IApplication *app, QObject *parent) + : APIController(app, parent) +{ + m_clientDataFilePath = specialFolderLocation(SpecialFolder::Data) / Path(CLIENT_DATA_FILE_NAME); + if (m_clientDataFilePath.exists()) + { + const auto readResult = Utils::IO::readFile(m_clientDataFilePath, CLIENT_DATA_FILE_MAX_SIZE); + if (!readResult) + { + LogMsg(tr("Failed to load web client data. %1").arg(readResult.error().message), Log::WARNING); + return; + } + + QJsonParseError jsonError; + const QJsonDocument jsonDoc = QJsonDocument::fromJson(readResult.value(), &jsonError); + if (jsonError.error != QJsonParseError::NoError) + { + LogMsg(tr("Failed to parse web client data. File: \"%1\". Error: \"%2\"") + .arg(m_clientDataFilePath.toString(), jsonError.errorString()), Log::WARNING); + return; + } + + if (!jsonDoc.isObject()) + { + LogMsg(tr("Failed to load web client data. File: \"%1\". Error: \"Invalid data format\"") + .arg(m_clientDataFilePath.toString()), Log::WARNING); + return; + } + + m_clientData = jsonDoc.object(); + } +} + void AppController::webapiVersionAction() { setResult(API_VERSION.toString()); @@ -1312,6 +1351,89 @@ void AppController::setCookiesAction() setResult(QString()); } +void AppController::clientDataAction() +{ + const QString keysParam {params()[u"keys"_s]}; + if (keysParam.isEmpty()) + { + setResult(m_clientData); + return; + } + + QJsonParseError jsonError; + const auto keysJsonDocument = QJsonDocument::fromJson(keysParam.toUtf8(), &jsonError); + if (jsonError.error != QJsonParseError::NoError) + throw APIError(APIErrorType::BadParams, jsonError.errorString()); + if (!keysJsonDocument.isArray()) + throw APIError(APIErrorType::BadParams, tr("keys must be an array")); + + QJsonObject clientData; + for (const QJsonValue &keysJsonVal : asConst(keysJsonDocument.array())) + { + if (!keysJsonVal.isString()) + throw APIError(APIErrorType::BadParams, tr("key must be a string")); + + const QString &key = keysJsonVal.toString(); + if (const auto iter = m_clientData.constFind(key); iter != m_clientData.constEnd()) + clientData.insert(key, iter.value()); + } + + setResult(clientData); +} + +void AppController::setClientDataAction() +{ + requireParams({u"data"_s}); + QJsonParseError jsonError; + const auto dataJsonDocument = QJsonDocument::fromJson(params()[u"data"_s].toUtf8(), &jsonError); + if (jsonError.error != QJsonParseError::NoError) + throw APIError(APIErrorType::BadParams, jsonError.errorString()); + if (!dataJsonDocument.isObject()) + throw APIError(APIErrorType::BadParams, tr("data must be an object")); + + QJsonObject clientData = m_clientData; + bool dataModified = false; + const QJsonObject dataJsonObject = dataJsonDocument.object(); + for (auto it = dataJsonObject.constBegin(), end = dataJsonObject.constEnd(); it != end; ++it) + { + const QString &key = it.key(); + const QJsonValue &value = it.value(); + + if (value.isNull()) + { + if (auto it = clientData.find(key); it != clientData.end()) + { + clientData.erase(it); + dataModified = true; + } + } + else + { + const auto &existingValue = clientData.constFind(key); + if ((existingValue == clientData.constEnd()) || (existingValue.value() != value)) + { + clientData.insert(key, value); + dataModified = true; + } + } + } + + if (!dataModified) + return; + + const QByteArray json = QJsonDocument(clientData).toJson(QJsonDocument::Compact); + if (json.size() > CLIENT_DATA_FILE_MAX_SIZE) + throw APIError(APIErrorType::BadParams, tr("data must not be larger than %1 bytes").arg(CLIENT_DATA_FILE_MAX_SIZE)); + const nonstd::expected result = Utils::IO::saveToFile(m_clientDataFilePath, json); + if (!result) + { + throw APIError(APIErrorType::Conflict, tr("Failed to save web client data. Error: \"%1\"") + .arg(result.error())); + } + + m_clientData = clientData; +} + void AppController::networkInterfaceListAction() { QJsonArray ifaceList; diff --git a/src/webui/api/appcontroller.h b/src/webui/api/appcontroller.h index dea33eada..ffe71d2f4 100644 --- a/src/webui/api/appcontroller.h +++ b/src/webui/api/appcontroller.h @@ -30,7 +30,10 @@ #pragma once +#include + #include "apicontroller.h" +#include "base/path.h" class AppController : public APIController { @@ -38,7 +41,7 @@ class AppController : public APIController Q_DISABLE_COPY_MOVE(AppController) public: - using APIController::APIController; + explicit AppController(IApplication *app, QObject *parent = nullptr); private slots: void webapiVersionAction(); @@ -52,7 +55,13 @@ private slots: void getDirectoryContentAction(); void cookiesAction(); void setCookiesAction(); + void clientDataAction(); + void setClientDataAction(); void networkInterfaceListAction(); void networkInterfaceAddressListAction(); + +private: + Path m_clientDataFilePath; + QJsonObject m_clientData; }; diff --git a/src/webui/webapplication.h b/src/webui/webapplication.h index 889da879b..f5b2858fb 100644 --- a/src/webui/webapplication.h +++ b/src/webui/webapplication.h @@ -148,6 +148,7 @@ private: { // <, HTTP method> {{u"app"_s, u"sendTestEmail"_s}, Http::METHOD_POST}, + {{u"app"_s, u"setClientData"_s}, Http::METHOD_POST}, {{u"app"_s, u"setCookies"_s}, Http::METHOD_POST}, {{u"app"_s, u"setPreferences"_s}, Http::METHOD_POST}, {{u"app"_s, u"shutdown"_s}, Http::METHOD_POST}, From cd03dc57aae4c5ec6a432024ef0fa48eaf704594 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Thu, 14 Aug 2025 16:45:40 -0700 Subject: [PATCH 4/6] fix: make appcontroller member variables static --- src/webui/api/appcontroller.cpp | 50 ++++++++++++++++++--------------- src/webui/api/appcontroller.h | 4 +-- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src/webui/api/appcontroller.cpp b/src/webui/api/appcontroller.cpp index 0aa059636..c10b4664a 100644 --- a/src/webui/api/appcontroller.cpp +++ b/src/webui/api/appcontroller.cpp @@ -90,36 +90,42 @@ const QString KEY_FILE_METADATA_LAST_MODIFICATION_DATE = u"last_modification_dat const int CLIENT_DATA_FILE_MAX_SIZE = 1024 * 1024; const QString CLIENT_DATA_FILE_NAME = u"web_data.json"_s; +Path AppController::m_clientDataFilePath; +QJsonObject AppController::m_clientData; + AppController::AppController(IApplication *app, QObject *parent) : APIController(app, parent) { - m_clientDataFilePath = specialFolderLocation(SpecialFolder::Data) / Path(CLIENT_DATA_FILE_NAME); - if (m_clientDataFilePath.exists()) + if (m_clientDataFilePath.isEmpty()) { - const auto readResult = Utils::IO::readFile(m_clientDataFilePath, CLIENT_DATA_FILE_MAX_SIZE); - if (!readResult) + m_clientDataFilePath = specialFolderLocation(SpecialFolder::Data) / Path(CLIENT_DATA_FILE_NAME); + if (m_clientDataFilePath.exists()) { - LogMsg(tr("Failed to load web client data. %1").arg(readResult.error().message), Log::WARNING); - return; - } + const auto readResult = Utils::IO::readFile(m_clientDataFilePath, CLIENT_DATA_FILE_MAX_SIZE); + if (!readResult) + { + LogMsg(tr("Failed to load web client data. %1").arg(readResult.error().message), Log::WARNING); + return; + } - QJsonParseError jsonError; - const QJsonDocument jsonDoc = QJsonDocument::fromJson(readResult.value(), &jsonError); - if (jsonError.error != QJsonParseError::NoError) - { - LogMsg(tr("Failed to parse web client data. File: \"%1\". Error: \"%2\"") - .arg(m_clientDataFilePath.toString(), jsonError.errorString()), Log::WARNING); - return; - } + QJsonParseError jsonError; + const QJsonDocument jsonDoc = QJsonDocument::fromJson(readResult.value(), &jsonError); + if (jsonError.error != QJsonParseError::NoError) + { + LogMsg(tr("Failed to parse web client data. File: \"%1\". Error: \"%2\"") + .arg(m_clientDataFilePath.toString(), jsonError.errorString()), Log::WARNING); + return; + } - if (!jsonDoc.isObject()) - { - LogMsg(tr("Failed to load web client data. File: \"%1\". Error: \"Invalid data format\"") - .arg(m_clientDataFilePath.toString()), Log::WARNING); - return; - } + if (!jsonDoc.isObject()) + { + LogMsg(tr("Failed to load web client data. File: \"%1\". Error: \"Invalid data format\"") + .arg(m_clientDataFilePath.toString()), Log::WARNING); + return; + } - m_clientData = jsonDoc.object(); + m_clientData = jsonDoc.object(); + } } } diff --git a/src/webui/api/appcontroller.h b/src/webui/api/appcontroller.h index ffe71d2f4..317270248 100644 --- a/src/webui/api/appcontroller.h +++ b/src/webui/api/appcontroller.h @@ -62,6 +62,6 @@ private slots: void networkInterfaceAddressListAction(); private: - Path m_clientDataFilePath; - QJsonObject m_clientData; + static Path m_clientDataFilePath; + static QJsonObject m_clientData; }; From afb1b515664b75d2e6a7ec4bef73d815cdb29251 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Thu, 14 Aug 2025 23:10:35 -0700 Subject: [PATCH 5/6] WebUI: Store durable settings in client data API --- src/webui/www/private/index.html | 1 + src/webui/www/private/scripts/addtorrent.js | 8 +- src/webui/www/private/scripts/client-data.js | 128 ++++++++++++++++++ src/webui/www/private/scripts/client.js | 93 +++++++++---- src/webui/www/private/scripts/color-scheme.js | 6 +- src/webui/www/private/scripts/dynamicTable.js | 7 +- src/webui/www/private/scripts/search.js | 6 +- .../www/private/views/confirmdeletion.html | 10 +- .../www/private/views/createtorrent.html | 12 +- src/webui/www/private/views/filters.html | 2 +- src/webui/www/private/views/log.html | 5 +- src/webui/www/private/views/preferences.html | 57 +++++--- src/webui/www/webui.qrc | 1 + 13 files changed, 261 insertions(+), 75 deletions(-) create mode 100644 src/webui/www/private/scripts/client-data.js diff --git a/src/webui/www/private/index.html b/src/webui/www/private/index.html index b5877abc4..fce081cf9 100644 --- a/src/webui/www/private/index.html +++ b/src/webui/www/private/index.html @@ -29,6 +29,7 @@ + diff --git a/src/webui/www/private/scripts/addtorrent.js b/src/webui/www/private/scripts/addtorrent.js index 3decbf73a..4660862e7 100644 --- a/src/webui/www/private/scripts/addtorrent.js +++ b/src/webui/www/private/scripts/addtorrent.js @@ -44,9 +44,10 @@ window.qBittorrent.AddTorrent ??= (() => { let source = ""; const LocalPreferences = new window.qBittorrent.LocalPreferences.LocalPreferences(); + const clientData = window.parent.qBittorrent.ClientData; const getCategories = () => { - const defaultCategory = LocalPreferences.get("add_torrent_default_category", ""); + const defaultCategory = clientData.getCached("add_torrent_default_category") ?? ""; const categorySelect = document.getElementById("categorySelect"); for (const name of window.parent.qBittorrent.Client.categoryMap.keys()) { const option = document.createElement("option"); @@ -311,10 +312,7 @@ window.qBittorrent.AddTorrent ??= (() => { if (document.getElementById("setDefaultCategory").checked) { const category = document.getElementById("category").value.trim(); - if (category.length === 0) - LocalPreferences.remove("add_torrent_default_category"); - else - LocalPreferences.set("add_torrent_default_category", category); + clientData.set({ add_torrent_default_category: category.length > 0 ? category : null }); } }; diff --git a/src/webui/www/private/scripts/client-data.js b/src/webui/www/private/scripts/client-data.js new file mode 100644 index 000000000..4857e2cf4 --- /dev/null +++ b/src/webui/www/private/scripts/client-data.js @@ -0,0 +1,128 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2025 Thomas Piccirello + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +"use strict"; + +window.qBittorrent ??= {}; +window.qBittorrent.ClientData ??= (() => { + const exports = () => { + return { + getCached: (...args) => instance.getCached(...args), + get: (...args) => instance.get(...args), + set: (...args) => instance.set(...args), + }; + }; + + // this is exposed as a singleton + class ClientData { + /** + * @type Map + */ + #cache = new Map(); + + /** + * @param {string[]} keys + * @returns {Record} + */ + async #fetch(keys) { + return await fetch("api/v2/app/clientData", { + method: "POST", + body: new URLSearchParams({ + keys: JSON.stringify(keys) + }) + }) + .then(async (response) => { + if (!response.ok) + return; + + return await response.json(); + }); + } + + /** + * @param {Record} data + */ + async #set(data) { + await fetch("api/v2/app/setClientData", { + method: "POST", + body: new URLSearchParams({ + data: JSON.stringify(data) + }) + }) + .then((response) => { + if (!response.ok) + throw new Error("Failed to store client data"); + }); + } + + /** + * @param {string} key + * @returns {any} + */ + getCached(key) { + return this.#cache.get(key); + } + + /** + * @param {string[]} keys + * @returns {Record} + */ + async get(keys = []) { + const keysToFetch = keys.filter((key) => !this.#cache.has(key)); + if (keysToFetch.length > 0) { + const fetchedData = await this.#fetch(keysToFetch); + for (const [key, value] of Object.entries(fetchedData)) + this.#cache.set(key, value); + } + + return Object.fromEntries(keys.map((key) => ([key, this.#cache.get(key)]))); + } + + /** + * @param {Record} data + */ + async set(data) { + try { + await this.#set(data); + } + catch (err) { + console.error(err); + return; + } + + // update cache + for (const [key, value] of Object.entries(data)) + this.#cache.set(key, value); + } + } + + const instance = new ClientData(); + + return exports(); +})(); +Object.freeze(window.qBittorrent.ClientData); diff --git a/src/webui/www/private/scripts/client.js b/src/webui/www/private/scripts/client.js index 4c17cb5b1..eb09b777b 100644 --- a/src/webui/www/private/scripts/client.js +++ b/src/webui/www/private/scripts/client.js @@ -31,6 +31,7 @@ window.qBittorrent.Client ??= (() => { return { setup: setup, initializeCaches: initializeCaches, + initializeClientData: initializeClientData, closeWindow: closeWindow, closeFrameWindow: closeFrameWindow, getSyncMainDataInterval: getSyncMainDataInterval, @@ -56,15 +57,47 @@ window.qBittorrent.Client ??= (() => { const tagMap = new Map(); let cacheAllSettled; + let clientDataPromise; const setup = () => { // fetch various data and store it in memory + clientDataPromise = window.qBittorrent.ClientData.get([ + "show_search_engine", + "show_rss_reader", + "show_log_viewer", + "speed_in_browser_title_bar", + "show_top_toolbar", + "show_status_bar", + "show_filters_sidebar", + "hide_zero_status_filters", + "color_scheme", + "full_url_tracker_column", + "use_alt_row_colors", + "use_virtual_list", + "dblclick_complete", + "dblclick_download", + "dblclick_filter", + "search_in_filter", + "qbt_selected_log_levels", + "add_torrent_default_category", + ]); + cacheAllSettled = Promise.allSettled([ window.qBittorrent.Cache.buildInfo.init(), window.qBittorrent.Cache.preferences.init(), - window.qBittorrent.Cache.qbtVersion.init() + window.qBittorrent.Cache.qbtVersion.init(), + clientDataPromise, ]); }; + const initializeClientData = async () => { + try { + await clientDataPromise; + } + catch (error) { + console.error(`Failed to initialize client data. Reason: "${error}".`); + } + }; + const initializeCaches = async () => { const results = await cacheAllSettled; for (const [idx, result] of results.entries()) { @@ -224,8 +257,8 @@ let queueing_enabled = true; let serverSyncMainDataInterval = 1500; let customSyncMainDataInterval = null; let useSubcategories = true; -const useAutoHideZeroStatusFilters = LocalPreferences.get("hide_zero_status_filters", "false") === "true"; -const displayFullURLTrackerColumn = LocalPreferences.get("full_url_tracker_column", "false") === "true"; +let useAutoHideZeroStatusFilters = false; +let displayFullURLTrackerColumn = false; /* Categories filter */ const CATEGORIES_ALL = "b4af0e4c-e76d-4bac-a392-46cbc18d9655"; @@ -251,6 +284,8 @@ const TRACKERS_WARNING = "82a702c5-210c-412b-829f-97632d7557e9"; // Map> const trackerMap = new Map(); +const clientData = window.qBittorrent.ClientData; + let selectedTracker = LocalPreferences.get("selected_tracker", TRACKERS_ALL); let setTrackerFilter = () => {}; @@ -259,7 +294,13 @@ let selectedStatus = LocalPreferences.get("selected_filter", "all"); let setStatusFilter = () => {}; let toggleFilterDisplay = () => {}; -window.addEventListener("DOMContentLoaded", (event) => { +window.addEventListener("DOMContentLoaded", async (event) => { + await window.qBittorrent.Client.initializeClientData(); + window.qBittorrent.ColorScheme.update(); + + useAutoHideZeroStatusFilters = clientData.getCached("hide_zero_status_filters") === true; + displayFullURLTrackerColumn = clientData.getCached("full_url_tracker_column") === true; + window.qBittorrent.LocalPreferences.upgrade(); let isSearchPanelLoaded = false; @@ -406,6 +447,13 @@ window.addEventListener("DOMContentLoaded", (event) => { LocalPreferences.set(`filter_${filterListID.replace("FilterList", "")}_collapsed`, filterList.classList.toggle("invisible").toString()); }; + const highlightSelectedStatus = () => { + const statusFilter = document.getElementById("statusFilterList"); + const filterID = `${selectedStatus}_filter`; + for (const status of statusFilter.children) + status.classList.toggle("selectedFilter", (status.id === filterID)); + }; + new MochaUI.Panel({ id: "Filters", title: "Panel", @@ -427,35 +475,35 @@ window.addEventListener("DOMContentLoaded", (event) => { initializeWindows(); // Show Top Toolbar is enabled by default - let showTopToolbar = LocalPreferences.get("show_top_toolbar", "true") === "true"; + let showTopToolbar = clientData.getCached("show_top_toolbar") !== false; if (!showTopToolbar) { document.getElementById("showTopToolbarLink").firstElementChild.style.opacity = "0"; document.getElementById("mochaToolbar").classList.add("invisible"); } // Show Status Bar is enabled by default - let showStatusBar = LocalPreferences.get("show_status_bar", "true") === "true"; + let showStatusBar = clientData.getCached("show_status_bar") !== false; if (!showStatusBar) { document.getElementById("showStatusBarLink").firstElementChild.style.opacity = "0"; document.getElementById("desktopFooterWrapper").classList.add("invisible"); } // Show Filters Sidebar is enabled by default - let showFiltersSidebar = LocalPreferences.get("show_filters_sidebar", "true") === "true"; + let showFiltersSidebar = clientData.getCached("show_filters_sidebar") !== false; if (!showFiltersSidebar) { document.getElementById("showFiltersSidebarLink").firstElementChild.style.opacity = "0"; document.getElementById("filtersColumn").classList.add("invisible"); document.getElementById("filtersColumn_handle").classList.add("invisible"); } - let speedInTitle = LocalPreferences.get("speed_in_browser_title_bar") === "true"; + let speedInTitle = clientData.getCached("speed_in_browser_title_bar") === true; if (!speedInTitle) document.getElementById("speedInBrowserTitleBarLink").firstElementChild.style.opacity = "0"; // After showing/hiding the toolbar + status bar - window.qBittorrent.Client.showSearchEngine(LocalPreferences.get("show_search_engine") !== "false"); - window.qBittorrent.Client.showRssReader(LocalPreferences.get("show_rss_reader") !== "false"); - window.qBittorrent.Client.showLogViewer(LocalPreferences.get("show_log_viewer") === "true"); + window.qBittorrent.Client.showSearchEngine(clientData.getCached("show_search_engine") !== false); + window.qBittorrent.Client.showRssReader(clientData.getCached("show_rss_reader") !== false); + window.qBittorrent.Client.showLogViewer(clientData.getCached("show_log_viewer") === true); // After Show Top Toolbar MochaUI.Desktop.setDesktopSize(); @@ -572,13 +620,6 @@ window.addEventListener("DOMContentLoaded", (event) => { window.qBittorrent.Filters.clearStatusFilter(); }; - const highlightSelectedStatus = () => { - const statusFilter = document.getElementById("statusFilterList"); - const filterID = `${selectedStatus}_filter`; - for (const status of statusFilter.children) - status.classList.toggle("selectedFilter", (status.id === filterID)); - }; - const updateCategoryList = () => { const categoryList = document.getElementById("categoryFilterList"); if (!categoryList) @@ -1221,7 +1262,7 @@ window.addEventListener("DOMContentLoaded", (event) => { document.getElementById("showTopToolbarLink").addEventListener("click", (e) => { showTopToolbar = !showTopToolbar; - LocalPreferences.set("show_top_toolbar", showTopToolbar.toString()); + clientData.set({ show_top_toolbar: showTopToolbar }); if (showTopToolbar) { document.getElementById("showTopToolbarLink").firstElementChild.style.opacity = "1"; document.getElementById("mochaToolbar").classList.remove("invisible"); @@ -1235,7 +1276,7 @@ window.addEventListener("DOMContentLoaded", (event) => { document.getElementById("showStatusBarLink").addEventListener("click", (e) => { showStatusBar = !showStatusBar; - LocalPreferences.set("show_status_bar", showStatusBar.toString()); + clientData.set({ show_status_bar: showStatusBar }); if (showStatusBar) { document.getElementById("showStatusBarLink").firstElementChild.style.opacity = "1"; document.getElementById("desktopFooterWrapper").classList.remove("invisible"); @@ -1272,7 +1313,7 @@ window.addEventListener("DOMContentLoaded", (event) => { document.getElementById("showFiltersSidebarLink").addEventListener("click", (e) => { showFiltersSidebar = !showFiltersSidebar; - LocalPreferences.set("show_filters_sidebar", showFiltersSidebar.toString()); + clientData.set({ show_filters_sidebar: showFiltersSidebar }); if (showFiltersSidebar) { document.getElementById("showFiltersSidebarLink").firstElementChild.style.opacity = "1"; document.getElementById("filtersColumn").classList.remove("invisible"); @@ -1288,7 +1329,7 @@ window.addEventListener("DOMContentLoaded", (event) => { document.getElementById("speedInBrowserTitleBarLink").addEventListener("click", (e) => { speedInTitle = !speedInTitle; - LocalPreferences.set("speed_in_browser_title_bar", speedInTitle.toString()); + LocalPreferences.set("speed_in_browser_title_bar", speedInTitle); if (speedInTitle) document.getElementById("speedInBrowserTitleBarLink").firstElementChild.style.opacity = "1"; else @@ -1298,19 +1339,19 @@ window.addEventListener("DOMContentLoaded", (event) => { document.getElementById("showSearchEngineLink").addEventListener("click", (e) => { window.qBittorrent.Client.showSearchEngine(!window.qBittorrent.Client.isShowSearchEngine()); - LocalPreferences.set("show_search_engine", window.qBittorrent.Client.isShowSearchEngine().toString()); + clientData.set({ show_search_engine: window.qBittorrent.Client.isShowSearchEngine() }); updateTabDisplay(); }); document.getElementById("showRssReaderLink").addEventListener("click", (e) => { window.qBittorrent.Client.showRssReader(!window.qBittorrent.Client.isShowRssReader()); - LocalPreferences.set("show_rss_reader", window.qBittorrent.Client.isShowRssReader().toString()); + clientData.set({ show_rss_reader: window.qBittorrent.Client.isShowRssReader() }); updateTabDisplay(); }); document.getElementById("showLogViewerLink").addEventListener("click", (e) => { window.qBittorrent.Client.showLogViewer(!window.qBittorrent.Client.isShowLogViewer()); - LocalPreferences.set("show_log_viewer", window.qBittorrent.Client.isShowLogViewer().toString()); + clientData.set({ show_log_viewer: window.qBittorrent.Client.isShowLogViewer() }); updateTabDisplay(); }); @@ -1367,7 +1408,7 @@ window.addEventListener("DOMContentLoaded", (event) => { // main window tabs const showTransfersTab = () => { - const showFiltersSidebar = LocalPreferences.get("show_filters_sidebar", "true") === "true"; + const showFiltersSidebar = clientData.getCached("show_filters_sidebar") !== false; if (showFiltersSidebar) { document.getElementById("filtersColumn").classList.remove("invisible"); document.getElementById("filtersColumn_handle").classList.remove("invisible"); diff --git a/src/webui/www/private/scripts/color-scheme.js b/src/webui/www/private/scripts/color-scheme.js index d02fce243..c35779a41 100644 --- a/src/webui/www/private/scripts/color-scheme.js +++ b/src/webui/www/private/scripts/color-scheme.js @@ -36,12 +36,12 @@ window.qBittorrent.ColorScheme ??= (() => { }; }; - const LocalPreferences = new window.qBittorrent.LocalPreferences.LocalPreferences(); const colorSchemeQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const clientData = window.parent.qBittorrent.ClientData; const update = () => { const root = document.documentElement; - const colorScheme = LocalPreferences.get("color_scheme"); + const colorScheme = clientData.getCached("color_scheme"); const validScheme = (colorScheme === "light") || (colorScheme === "dark"); const isDark = colorSchemeQuery.matches; root.classList.toggle("dark", ((!validScheme && isDark) || (colorScheme === "dark"))); @@ -52,5 +52,3 @@ window.qBittorrent.ColorScheme ??= (() => { return exports(); })(); Object.freeze(window.qBittorrent.ColorScheme); - -window.qBittorrent.ColorScheme.update(); diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index aacdb5f5d..826f87386 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -65,6 +65,7 @@ window.qBittorrent.DynamicTable ??= (() => { return 0; }; + const clientData = window.qBittorrent.ClientData ?? window.parent.qBittorrent.ClientData; let DynamicTableHeaderContextMenuClass = null; if (typeof LocalPreferences === "undefined") @@ -75,7 +76,7 @@ window.qBittorrent.DynamicTable ??= (() => { this.dynamicTableDivId = dynamicTableDivId; this.dynamicTableFixedHeaderDivId = dynamicTableFixedHeaderDivId; this.dynamicTableDiv = document.getElementById(dynamicTableDivId); - this.useVirtualList = useVirtualList && (LocalPreferences.get("use_virtual_list", "false") === "true"); + this.useVirtualList = useVirtualList && (clientData.getCached("use_virtual_list") === true); this.fixedTableHeader = document.querySelector(`#${dynamicTableFixedHeaderDivId} thead tr`); this.hiddenTableHeader = this.dynamicTableDiv.querySelector("thead tr"); this.table = this.dynamicTableDiv.querySelector("table"); @@ -730,7 +731,7 @@ window.qBittorrent.DynamicTable ??= (() => { } setupAltRow() { - const useAltRowColors = (LocalPreferences.get("use_alt_row_colors", "true") === "true"); + const useAltRowColors = clientData.getCached("use_alt_row_colors") !== false; if (useAltRowColors) document.getElementById(this.dynamicTableDivId).classList.add("altRowColors"); } @@ -1805,7 +1806,7 @@ window.qBittorrent.DynamicTable ??= (() => { ? "dblclick_download" : "dblclick_complete"; - if (LocalPreferences.get(prefKey, "1") !== "1") + if (clientData.getCached(prefKey) === "0") return true; if (state.includes("stopped")) diff --git a/src/webui/www/private/scripts/search.js b/src/webui/www/private/scripts/search.js index a8367255b..f13693694 100644 --- a/src/webui/www/private/scripts/search.js +++ b/src/webui/www/private/scripts/search.js @@ -107,8 +107,7 @@ window.qBittorrent.Search ??= (() => { }); const init = () => { - // load "Search in" preference from local storage - document.getElementById("searchInTorrentName").value = (LocalPreferences.get("search_in_filter") === "names") ? "names" : "everywhere"; + document.getElementById("searchInTorrentName").value = (window.qBittorrent.ClientData.getCached("search_in_filter") === "names") ? "names" : "everywhere"; const searchResultsTableContextMenu = new window.qBittorrent.ContextMenu.ContextMenu({ targets: "#searchResultsTableDiv tbody tr", menu: "searchResultsTableMenu", @@ -804,7 +803,8 @@ window.qBittorrent.Search ??= (() => { }; const searchInTorrentName = () => { - LocalPreferences.set("search_in_filter", getSearchInTorrentName()); + // don't await this + window.qBittorrent.ClientData.set({ search_in_filter: getSearchInTorrentName() }); searchFilterChanged(); }; diff --git a/src/webui/www/private/views/confirmdeletion.html b/src/webui/www/private/views/confirmdeletion.html index 215e80fef..4eff7f27d 100644 --- a/src/webui/www/private/views/confirmdeletion.html +++ b/src/webui/www/private/views/confirmdeletion.html @@ -58,11 +58,11 @@ // Set current "Delete files" choice as the default rememberButton.addEventListener("click", (e) => { window.qBittorrent.Cache.preferences.set({ - delete_torrent_content_files: deleteCB.checked - }).then(() => { - prefDeleteContentFiles = deleteCB.checked; - setRememberBtnEnabled(false); - }); + delete_torrent_content_files: deleteCB.checked + }).then(() => { + prefDeleteContentFiles = deleteCB.checked; + setRememberBtnEnabled(false); + }); }); cancelButton.focus(); diff --git a/src/webui/www/private/views/createtorrent.html b/src/webui/www/private/views/createtorrent.html index a85e5a57d..23ef59cf5 100644 --- a/src/webui/www/private/views/createtorrent.html +++ b/src/webui/www/private/views/createtorrent.html @@ -138,7 +138,7 @@ return option; }; - const init = () => { + const init = async () => { const pieceSizeSelect = document.getElementById("pieceSize"); pieceSizeSelect.appendChild(createSizeOption(0)); for (let i = 4; i <= 17; ++i) @@ -163,7 +163,7 @@ submit(); }); - loadPreference(); + await loadPreference(); window.qBittorrent.pathAutofill.attachPathAutofill(); }; @@ -207,11 +207,13 @@ comments: document.getElementById("comments").value, source: document.getElementById("source").value, }; - LocalPreferences.set("torrent_creator", JSON.stringify(preference)); + window.parent.qBittorrent.ClientData.set({ torrent_creator: preference }); }; - const loadPreference = () => { - const preference = JSON.parse(LocalPreferences.get("torrent_creator") ?? "{}"); + const loadPreference = async () => { + const clientData = await window.parent.qBittorrent.ClientData.get(["torrent_creator"]); + const preference = clientData.torrent_creator ?? {}; + document.getElementById("sourcePath").value = preference.sourcePath ?? ""; document.getElementById("torrentFormat").value = preference.torrentFormat ?? "hybrid"; document.getElementById("pieceSize").value = preference.pieceSize ?? 0; diff --git a/src/webui/www/private/views/filters.html b/src/webui/www/private/views/filters.html index 623990c1a..1c66100ca 100644 --- a/src/webui/www/private/views/filters.html +++ b/src/webui/www/private/views/filters.html @@ -261,7 +261,7 @@ }); document.getElementById("Filters_pad").addEventListener("dblclick", (event) => { - if (LocalPreferences.get("dblclick_filter", "1") !== "1") + if (window.qBittorrent.ClientData.getCached("dblclick_filter") === "0") return; const filterItem = event.target.closest("li"); diff --git a/src/webui/www/private/views/log.html b/src/webui/www/private/views/log.html index a1fc55472..cdbb6fe34 100644 --- a/src/webui/www/private/views/log.html +++ b/src/webui/www/private/views/log.html @@ -185,7 +185,7 @@ let logFilterTimer = -1; let inputtedFilterText = ""; let selectBox; - let selectedLogLevels = JSON.parse(LocalPreferences.get("qbt_selected_log_levels")) || ["1", "2", "4", "8"]; + let selectedLogLevels = window.qBittorrent.ClientData.getCached("qbt_selected_log_levels") ?? ["1", "2", "4", "8"]; const init = () => { for (const option of document.getElementById("logLevelSelect").options) @@ -276,7 +276,8 @@ if (selectedLogLevels !== value) { tableInfo[currentSelectedTab].last_id = -1; selectedLogLevels = value; - LocalPreferences.set("qbt_selected_log_levels", JSON.stringify(selectedLogLevels)); + // don't await this + window.qBittorrent.ClientData.set({ qbt_selected_log_levels: selectedLogLevels }); logFilterChanged(); } }; diff --git a/src/webui/www/private/views/preferences.html b/src/webui/www/private/views/preferences.html index 80b089459..f05fca02f 100644 --- a/src/webui/www/private/views/preferences.html +++ b/src/webui/www/private/views/preferences.html @@ -2215,10 +2215,8 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD document.getElementById("locale_select").value = selected; }; - const updateColoSchemeSelect = () => { + const updateColoSchemeSelect = (colorScheme) => { const combobox = document.getElementById("colorSchemeSelect"); - const colorScheme = LocalPreferences.get("color_scheme"); - if (colorScheme === "light") combobox.options[1].selected = true; else if (colorScheme === "dark") @@ -2230,20 +2228,31 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD const loadPreferences = () => { window.parent.qBittorrent.Cache.preferences.init() .then((pref) => { + const clientData = window.parent.qBittorrent.ClientData; + const colorScheme = clientData.getCached("color_scheme"); + const fullUrlTrackerColumn = clientData.getCached("full_url_tracker_column"); + const useVirtualList = clientData.getCached("use_virtual_list"); + const hideZeroStatusFilters = clientData.getCached("hide_zero_status_filters"); + const dblclickDownload = clientData.getCached("dblclick_download"); + const dblclickComplete = clientData.getCached("dblclick_complete"); + const dblclickFilter = clientData.getCached("dblclick_filter"); + const useAltRowColors = clientData.getCached("use_alt_row_colors"); + // Behavior tab // Language updateWebuiLocaleSelect(pref.locale); - updateColoSchemeSelect(); + updateColoSchemeSelect(colorScheme); + document.getElementById("statusBarExternalIP").checked = pref.status_bar_external_ip; document.getElementById("performanceWarning").checked = pref.performance_warning; - document.getElementById("displayFullURLTrackerColumn").checked = (LocalPreferences.get("full_url_tracker_column", "false") === "true"); - document.getElementById("useVirtualList").checked = (LocalPreferences.get("use_virtual_list", "false") === "true"); - document.getElementById("hideZeroFiltersCheckbox").checked = (LocalPreferences.get("hide_zero_status_filters", "false") === "true"); - document.getElementById("dblclickDownloadSelect").value = LocalPreferences.get("dblclick_download", "1"); - document.getElementById("dblclickCompleteSelect").value = LocalPreferences.get("dblclick_complete", "1"); - document.getElementById("dblclickFiltersSelect").value = LocalPreferences.get("dblclick_filter", "1"); + document.getElementById("displayFullURLTrackerColumn").checked = fullUrlTrackerColumn === true; + document.getElementById("useVirtualList").checked = useVirtualList === true; + document.getElementById("hideZeroFiltersCheckbox").checked = hideZeroStatusFilters === true; + document.getElementById("dblclickDownloadSelect").value = dblclickDownload ?? "1"; + document.getElementById("dblclickCompleteSelect").value = dblclickComplete ?? "1"; + document.getElementById("dblclickFiltersSelect").value = dblclickFilter ?? "1"; document.getElementById("confirmTorrentDeletion").checked = pref.confirm_torrent_deletion; - document.getElementById("useAltRowColorsInput").checked = (LocalPreferences.get("use_alt_row_colors", "true") === "true"); + document.getElementById("useAltRowColorsInput").checked = useAltRowColors !== false; document.getElementById("filelog_checkbox").checked = pref.file_log_enabled; document.getElementById("filelog_save_path_input").value = pref.file_log_path; document.getElementById("filelog_backup_checkbox").checked = pref.file_log_backup_enabled; @@ -2658,6 +2667,7 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD const applyPreferences = () => { const settings = {}; + const clientData = {}; // Validate form data // Behavior tab @@ -2665,21 +2675,21 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD settings["locale"] = document.getElementById("locale_select").value; const colorScheme = Number(document.getElementById("colorSchemeSelect").value); if (colorScheme === 0) - LocalPreferences.remove("color_scheme"); + clientData.color_scheme = null; else if (colorScheme === 1) - LocalPreferences.set("color_scheme", "light"); + clientData.color_scheme = "light"; else - LocalPreferences.set("color_scheme", "dark"); + clientData.color_scheme = "dark"; settings["status_bar_external_ip"] = document.getElementById("statusBarExternalIP").checked; settings["performance_warning"] = document.getElementById("performanceWarning").checked; - LocalPreferences.set("full_url_tracker_column", document.getElementById("displayFullURLTrackerColumn").checked.toString()); - LocalPreferences.set("use_virtual_list", document.getElementById("useVirtualList").checked.toString()); - LocalPreferences.set("hide_zero_status_filters", document.getElementById("hideZeroFiltersCheckbox").checked.toString()); - LocalPreferences.set("dblclick_download", document.getElementById("dblclickDownloadSelect").value); - LocalPreferences.set("dblclick_complete", document.getElementById("dblclickCompleteSelect").value); - LocalPreferences.set("dblclick_filter", document.getElementById("dblclickFiltersSelect").value); + clientData.full_url_tracker_column = document.getElementById("displayFullURLTrackerColumn").checked; + clientData.use_virtual_list = document.getElementById("useVirtualList").checked; + clientData.hide_zero_status_filters = document.getElementById("hideZeroFiltersCheckbox").checked; + clientData.dblclick_download = document.getElementById("dblclickDownloadSelect").value; + clientData.dblclick_complete = document.getElementById("dblclickCompleteSelect").value; + clientData.dblclick_filter = document.getElementById("dblclickFiltersSelect").value; settings["confirm_torrent_deletion"] = document.getElementById("confirmTorrentDeletion").checked; - LocalPreferences.set("use_alt_row_colors", document.getElementById("useAltRowColorsInput").checked.toString()); + clientData.use_alt_row_colors = document.getElementById("useAltRowColorsInput").checked; settings["file_log_enabled"] = document.getElementById("filelog_checkbox").checked; settings["file_log_path"] = document.getElementById("filelog_save_path_input").value; settings["file_log_backup_enabled"] = document.getElementById("filelog_backup_checkbox").checked; @@ -3170,6 +3180,11 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD settings["i2p_inbound_length"] = Number(document.getElementById("i2pInboundLength").value); settings["i2p_outbound_length"] = Number(document.getElementById("i2pOutboundLength").value); + window.parent.qBittorrent.ClientData.set(clientData) + .catch((error) => { + alert("QBT_TR(Unable to save web client preferences, qBittorrent is probably unreachable.)QBT_TR[CONTEXT=HttpServer]"); + }); + // Send it to qBT window.parent.qBittorrent.Cache.preferences.set(settings) .then(() => { diff --git a/src/webui/www/webui.qrc b/src/webui/www/webui.qrc index f17da4d50..cc0af3384 100644 --- a/src/webui/www/webui.qrc +++ b/src/webui/www/webui.qrc @@ -390,6 +390,7 @@ private/scripts/addtorrent.js private/scripts/cache.js private/scripts/client.js + private/scripts/client-data.js private/scripts/color-scheme.js private/scripts/contextmenu.js private/scripts/dynamicTable.js From 2b6f858307e1f06bdcf894fff9def75a9bb12846 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Fri, 15 Aug 2025 19:15:28 -0700 Subject: [PATCH 6/6] WebUI: Support modifying default date format --- src/webui/www/private/scripts/addtorrent.js | 2 +- src/webui/www/private/scripts/client.js | 1 + src/webui/www/private/scripts/dynamicTable.js | 12 +- src/webui/www/private/scripts/misc.js | 118 ++++++++++++++++++ src/webui/www/private/scripts/prop-general.js | 8 +- src/webui/www/private/views/preferences.html | 43 ++++++- src/webui/www/private/views/rss.html | 2 +- src/webui/www/test/private/misc.test.js | 56 ++++++++- 8 files changed, 223 insertions(+), 19 deletions(-) diff --git a/src/webui/www/private/scripts/addtorrent.js b/src/webui/www/private/scripts/addtorrent.js index 4660862e7..2ac888f8b 100644 --- a/src/webui/www/private/scripts/addtorrent.js +++ b/src/webui/www/private/scripts/addtorrent.js @@ -275,7 +275,7 @@ window.qBittorrent.AddTorrent ??= (() => { if (metadata.info?.length !== undefined) document.getElementById("size").textContent = window.qBittorrent.Misc.friendlyUnit(metadata.info.length, false); if ((metadata.creation_date !== undefined) && (metadata.creation_date > 1)) - document.getElementById("createdDate").textContent = new Date(metadata.creation_date * 1000).toLocaleString(); + document.getElementById("createdDate").textContent = window.qBittorrent.Misc.formatDate(new Date(metadata.creation_date * 1000)); if (metadata.comment !== undefined) document.getElementById("comment").textContent = metadata.comment; diff --git a/src/webui/www/private/scripts/client.js b/src/webui/www/private/scripts/client.js index eb09b777b..fea74838a 100644 --- a/src/webui/www/private/scripts/client.js +++ b/src/webui/www/private/scripts/client.js @@ -69,6 +69,7 @@ window.qBittorrent.Client ??= (() => { "show_status_bar", "show_filters_sidebar", "hide_zero_status_filters", + "date_format", "color_scheme", "full_url_tracker_column", "use_alt_row_colors", diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index 826f87386..b36b896fd 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -1426,7 +1426,7 @@ window.qBittorrent.DynamicTable ??= (() => { // added on this.columns["added_on"].updateTd = function(td, row) { - const date = new Date(this.getRowValue(row) * 1000).toLocaleString(); + const date = window.qBittorrent.Misc.formatDate(new Date(this.getRowValue(row) * 1000)); td.textContent = date; td.title = date; }; @@ -1439,7 +1439,7 @@ window.qBittorrent.DynamicTable ??= (() => { td.title = ""; } else { - const date = new Date(this.getRowValue(row) * 1000).toLocaleString(); + const date = window.qBittorrent.Misc.formatDate(new Date(this.getRowValue(row) * 1000)); td.textContent = date; td.title = date; } @@ -1964,7 +1964,7 @@ window.qBittorrent.DynamicTable ??= (() => { }; const displayDate = function(td, row) { const value = this.getRowValue(row) * 1000; - const formattedValue = (Number.isNaN(value) || (value <= 0)) ? "" : (new Date(value).toLocaleString()); + const formattedValue = (Number.isNaN(value) || (value <= 0)) ? "" : window.qBittorrent.Misc.formatDate(new Date(value)); td.textContent = formattedValue; td.title = formattedValue; }; @@ -3734,7 +3734,7 @@ window.qBittorrent.DynamicTable ??= (() => { initColumnsFunctions() { this.columns["timestamp"].updateTd = function(td, row) { - const date = new Date(this.getRowValue(row) * 1000).toLocaleString(); + const date = window.qBittorrent.Misc.formatDate(new Date(this.getRowValue(row) * 1000)); td.textContent = date; td.title = date; }; @@ -3812,7 +3812,7 @@ window.qBittorrent.DynamicTable ??= (() => { this.newColumn("reason", "", "QBT_TR(Reason)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true); this.columns["timestamp"].updateTd = function(td, row) { - const date = new Date(this.getRowValue(row) * 1000).toLocaleString(); + const date = window.qBittorrent.Misc.formatDate(new Date(this.getRowValue(row) * 1000)); td.textContent = date; td.title = date; }; @@ -4036,7 +4036,7 @@ window.qBittorrent.DynamicTable ??= (() => { td.title = ""; } else { - const date = new Date(val).toLocaleString(); + const date = window.qBittorrent.Misc.formatDate(new Date(val)); td.textContent = date; td.title = date; } diff --git a/src/webui/www/private/scripts/misc.js b/src/webui/www/private/scripts/misc.js index c1bad9e3e..364e51453 100644 --- a/src/webui/www/private/scripts/misc.js +++ b/src/webui/www/private/scripts/misc.js @@ -28,6 +28,108 @@ "use strict"; +/** + * @type Record + */ +const DateFormatOptions = { + "MM/dd/yyyy, h:mm:ss AM/PM": { + locale: "en-US", + options: { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + hour12: true + } + }, + "MM/dd/yyyy, HH:mm:ss": { + locale: "en-US", + options: { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false + } + }, + "dd/MM/yyyy, HH:mm:ss": { + locale: "en-GB", + options: { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false + } + }, + "yyyy-MM-dd HH:mm:ss": { + locale: "sv-SE", + options: { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false + } + }, + "yyyy/MM/dd HH:mm:ss": { + locale: "ja-JP", + options: { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false + } + }, + "dd.MM.yyyy, HH:mm:ss": { + locale: "en-GB", + options: { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false + } + }, + "MMM dd, yyyy, h:mm:ss AM/PM": { + locale: "en-US", + options: { + year: "numeric", + month: "short", + day: "2-digit", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + hour12: true + } + }, + "dd MMM yyyy, HH:mm:ss": { + locale: "en-GB", + options: { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false + } + }, +}; + window.qBittorrent ??= {}; window.qBittorrent.Misc ??= (() => { const exports = () => { @@ -46,6 +148,7 @@ window.qBittorrent.Misc ??= (() => { containsAllTerms: containsAllTerms, sleep: sleep, downloadFile: downloadFile, + formatDate: formatDate, // variables FILTER_INPUT_DELAY: 400, MAX_ETA: 8640000 @@ -302,6 +405,21 @@ window.qBittorrent.Misc ??= (() => { } }; + /** + * @param {Date} date + * @param {string} format + * @returns {string} + */ + const formatDate = (date, format = window.parent.qBittorrent.ClientData.getCached("date_format")) => { + if ((format === "default") || !Object.keys(DateFormatOptions).includes(format)) + return date.toLocaleString(); + + const { locale, options } = DateFormatOptions[format]; + const formatter = new Intl.DateTimeFormat(locale, options); + const formatted = formatter.format(date).replace(" at ", ", "); + return format.includes(".") ? formatted.replaceAll("/", ".") : formatted; + }; + return exports(); })(); Object.freeze(window.qBittorrent.Misc); diff --git a/src/webui/www/private/scripts/prop-general.js b/src/webui/www/private/scripts/prop-general.js index fce3c84a6..abfdf6c8b 100644 --- a/src/webui/www/private/scripts/prop-general.js +++ b/src/webui/www/private/scripts/prop-general.js @@ -177,7 +177,7 @@ window.qBittorrent.PropGeneral ??= (() => { document.getElementById("reannounce").textContent = window.qBittorrent.Misc.friendlyDuration(data.reannounce); const lastSeen = (data.last_seen >= 0) - ? new Date(data.last_seen * 1000).toLocaleString() + ? window.qBittorrent.Misc.formatDate(new Date(data.last_seen * 1000)) : "QBT_TR(Never)QBT_TR[CONTEXT=PropertiesWidget]"; document.getElementById("last_seen").textContent = lastSeen; @@ -195,17 +195,17 @@ window.qBittorrent.PropGeneral ??= (() => { document.getElementById("created_by").textContent = data.created_by; const additionDate = (data.addition_date >= 0) - ? new Date(data.addition_date * 1000).toLocaleString() + ? window.qBittorrent.Misc.formatDate(new Date(data.addition_date * 1000)) : "QBT_TR(Unknown)QBT_TR[CONTEXT=HttpServer]"; document.getElementById("addition_date").textContent = additionDate; const completionDate = (data.completion_date >= 0) - ? new Date(data.completion_date * 1000).toLocaleString() + ? window.qBittorrent.Misc.formatDate(new Date(data.completion_date * 1000)) : ""; document.getElementById("completion_date").textContent = completionDate; const creationDate = (data.creation_date >= 0) - ? new Date(data.creation_date * 1000).toLocaleString() + ? window.qBittorrent.Misc.formatDate(new Date(data.creation_date * 1000)) : ""; document.getElementById("creation_date").textContent = creationDate; diff --git a/src/webui/www/private/views/preferences.html b/src/webui/www/private/views/preferences.html index f05fca02f..a067affc5 100644 --- a/src/webui/www/private/views/preferences.html +++ b/src/webui/www/private/views/preferences.html @@ -1,10 +1,34 @@
- QBT_TR(Language)QBT_TR[CONTEXT=OptionsDialog] - - + QBT_TR(Localization)QBT_TR[CONTEXT=OptionsDialog] + + + + + + + + + + + +
+ +
+ +
@@ -2229,6 +2253,7 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD window.parent.qBittorrent.Cache.preferences.init() .then((pref) => { const clientData = window.parent.qBittorrent.ClientData; + const dateFormat = clientData.getCached("date_format"); const colorScheme = clientData.getCached("color_scheme"); const fullUrlTrackerColumn = clientData.getCached("full_url_tracker_column"); const useVirtualList = clientData.getCached("use_virtual_list"); @@ -2239,8 +2264,13 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD const useAltRowColors = clientData.getCached("use_alt_row_colors"); // Behavior tab - // Language + // Localization updateWebuiLocaleSelect(pref.locale); + const dateFormatSelect = document.getElementById("dateFormatSelect"); + if ((dateFormat?.length > 0) && ([...dateFormatSelect.options].find(o => o.value === dateFormat) !== undefined)) + dateFormatSelect.value = dateFormat; + + // Interface updateColoSchemeSelect(colorScheme); document.getElementById("statusBarExternalIP").checked = pref.status_bar_external_ip; @@ -2673,6 +2703,7 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD // Behavior tab // Language settings["locale"] = document.getElementById("locale_select").value; + clientData.date_format = document.getElementById("dateFormatSelect").value; const colorScheme = Number(document.getElementById("colorSchemeSelect").value); if (colorScheme === 0) clientData.color_scheme = null; diff --git a/src/webui/www/private/views/rss.html b/src/webui/www/private/views/rss.html index 57a92d3ed..40a5977e3 100644 --- a/src/webui/www/private/views/rss.html +++ b/src/webui/www/private/views/rss.html @@ -456,7 +456,7 @@ torrentDate.append(torrentDateDesc); const torrentDateData = document.createElement("span"); - torrentDateData.textContent = new Date(articleDate).toLocaleString(); + torrentDateData.textContent = window.qBittorrent.Misc.formatDate(new Date(articleDate)); torrentDate.append(torrentDateData); detailsView.append(torrentDate); diff --git a/src/webui/www/test/private/misc.test.js b/src/webui/www/test/private/misc.test.js index 30732d624..439e41abf 100644 --- a/src/webui/www/test/private/misc.test.js +++ b/src/webui/www/test/private/misc.test.js @@ -26,7 +26,7 @@ * exception statement from your version. */ -import { expect, test } from "vitest"; +import { vi, expect, test } from "vitest"; import "../../private/scripts/misc.js"; test("Test toFixedPointString()", () => { @@ -76,3 +76,57 @@ test("Test toFixedPointString()", () => { expect(toFixedPointString(-100.00, 1)).toBe("-100.0"); expect(toFixedPointString(-100.00, 2)).toBe("-100.00"); }); + +test("Test formatDate() - Format Coverage", () => { + const formatDate = window.qBittorrent.Misc.formatDate; + const testDate = new Date(2025, 7, 23, 22, 32, 46); // Aug 23, 2025 10:32:46 PM + + expect(formatDate(testDate, "MM/dd/yyyy, h:mm:ss AM/PM")).toBe("08/23/2025, 10:32:46 PM"); + expect(formatDate(testDate, "MM/dd/yyyy, HH:mm:ss")).toBe("08/23/2025, 22:32:46"); + expect(formatDate(testDate, "dd/MM/yyyy, HH:mm:ss")).toBe("23/08/2025, 22:32:46"); + expect(formatDate(testDate, "yyyy-MM-dd HH:mm:ss")).toBe("2025-08-23 22:32:46"); + expect(formatDate(testDate, "yyyy/MM/dd HH:mm:ss")).toBe("2025/08/23 22:32:46"); + expect(formatDate(testDate, "dd.MM.yyyy, HH:mm:ss")).toBe("23.08.2025, 22:32:46"); + expect(formatDate(testDate, "MMM dd, yyyy, h:mm:ss AM/PM")).toBe("Aug 23, 2025, 10:32:46 PM"); + expect(formatDate(testDate, "dd MMM yyyy, HH:mm:ss")).toBe("23 Aug 2025, 22:32:46"); +}); + +test("Test formatDate() - Fallback Behavior", () => { + // Mock ClientData.getCached + const mockGetCached = vi.fn().mockReturnValue("default"); + const originalParent = window.parent; + + window.parent = { + qBittorrent: { + ClientData: { + getCached: mockGetCached + } + } + }; + + const formatDate = window.qBittorrent.Misc.formatDate; + const testDate = new Date(2025, 7, 23, 22, 32, 46); // Aug 23, 2025 10:32:46 PM + const expectedDefault = testDate.toLocaleString(); + + // Test that "default" format uses toLocaleString() + expect(formatDate(testDate, "default")).toBe(expectedDefault); + + // Test default behavior when no format argument is provided + expect(mockGetCached).toHaveBeenCalledTimes(0); + expect(formatDate(testDate)).toBe(expectedDefault); + expect(mockGetCached).toHaveBeenCalledWith("date_format"); + expect(mockGetCached).toHaveBeenCalledTimes(1); + + // Test with unknown/invalid format strings + expect(formatDate(testDate, "invalid-format")).toBe(expectedDefault); + expect(formatDate(testDate, "")).toBe(expectedDefault); + expect(formatDate(testDate, null)).toBe(expectedDefault); + + expect(mockGetCached).toHaveBeenCalledTimes(1); + expect(formatDate(testDate, undefined)).toBe(expectedDefault); + expect(mockGetCached).toHaveBeenCalledWith("date_format"); + expect(mockGetCached).toHaveBeenCalledTimes(2); + + // Restore original window.parent + window.parent = originalParent; +});