WebUI: Filter list improvements
[qBittorrent.git] / src / webui / www / private / scripts / client.js
blob687097fcf1793435e7ef97b671fd00cfa2d4a791
1 /*
2 * MIT License
3 * Copyright (C) 2024 Mike Tzou (Chocobo1)
4 * Copyright (c) 2008 Ishan Arora <ishan@qbittorrent.org>,
5 * Christophe Dumez <chris@qbittorrent.org>
7 * Permission is hereby granted, free of charge, to any person obtaining a copy
8 * of this software and associated documentation files (the "Software"), to deal
9 * in the Software without restriction, including without limitation the rights
10 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11 * copies of the Software, and to permit persons to whom the Software is
12 * furnished to do so, subject to the following conditions:
14 * The above copyright notice and this permission notice shall be included in
15 * all copies or substantial portions of the Software.
17 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23 * THE SOFTWARE.
26 "use strict";
28 window.qBittorrent ??= {};
29 window.qBittorrent.Client ??= (() => {
30 const exports = () => {
31 return {
32 closeWindow: closeWindow,
33 closeWindows: closeWindows,
34 genHash: genHash,
35 getSyncMainDataInterval: getSyncMainDataInterval,
36 isStopped: isStopped,
37 stop: stop,
38 mainTitle: mainTitle,
39 showSearchEngine: showSearchEngine,
40 showRssReader: showRssReader,
41 showLogViewer: showLogViewer,
42 isShowSearchEngine: isShowSearchEngine,
43 isShowRssReader: isShowRssReader,
44 isShowLogViewer: isShowLogViewer
48 const closeWindow = function(windowID) {
49 const window = document.getElementById(windowID);
50 if (!window)
51 return;
52 MochaUI.closeWindow(window);
55 const closeWindows = function() {
56 MochaUI.closeAll();
59 const genHash = function(string) {
60 // origins:
61 // https://stackoverflow.com/a/8831937
62 // https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0
63 let hash = 0;
64 for (let i = 0; i < string.length; ++i)
65 hash = ((Math.imul(hash, 31) + string.charCodeAt(i)) | 0);
66 return hash;
69 const getSyncMainDataInterval = function() {
70 return customSyncMainDataInterval ? customSyncMainDataInterval : serverSyncMainDataInterval;
73 let stopped = false;
74 const isStopped = () => {
75 return stopped;
78 const stop = () => {
79 stopped = true;
82 const mainTitle = () => {
83 const emDash = "\u2014";
84 const qbtVersion = window.qBittorrent.Cache.qbtVersion.get();
85 const suffix = window.qBittorrent.Cache.preferences.get()["app_instance_name"] || "";
86 const title = `qBittorrent ${qbtVersion} QBT_TR(WebUI)QBT_TR[CONTEXT=OptionsDialog]`
87 + ((suffix.length > 0) ? ` ${emDash} ${suffix}` : "");
88 return title;
91 let showingSearchEngine = false;
92 let showingRssReader = false;
93 let showingLogViewer = false;
95 const showSearchEngine = function(bool) {
96 showingSearchEngine = bool;
98 const showRssReader = function(bool) {
99 showingRssReader = bool;
101 const showLogViewer = function(bool) {
102 showingLogViewer = bool;
104 const isShowSearchEngine = function() {
105 return showingSearchEngine;
107 const isShowRssReader = function() {
108 return showingRssReader;
110 const isShowLogViewer = function() {
111 return showingLogViewer;
114 return exports();
115 })();
116 Object.freeze(window.qBittorrent.Client);
118 // TODO: move global functions/variables into some namespace/scope
120 this.torrentsTable = new window.qBittorrent.DynamicTable.TorrentsTable();
122 let updatePropertiesPanel = function() {};
124 this.updateMainData = function() {};
125 let alternativeSpeedLimits = false;
126 let queueing_enabled = true;
127 let serverSyncMainDataInterval = 1500;
128 let customSyncMainDataInterval = null;
129 let useSubcategories = true;
130 const useAutoHideZeroStatusFilters = LocalPreferences.get("hide_zero_status_filters", "false") === "true";
132 /* Categories filter */
133 const CATEGORIES_ALL = 1;
134 const CATEGORIES_UNCATEGORIZED = 2;
136 const category_list = new Map();
138 let selectedCategory = Number(LocalPreferences.get("selected_category", CATEGORIES_ALL));
139 let setCategoryFilter = function() {};
141 /* Tags filter */
142 const TAGS_ALL = 1;
143 const TAGS_UNTAGGED = 2;
145 const tagList = new Map();
147 let selectedTag = Number(LocalPreferences.get("selected_tag", TAGS_ALL));
148 let setTagFilter = function() {};
150 /* Trackers filter */
151 const TRACKERS_ALL = 1;
152 const TRACKERS_TRACKERLESS = 2;
154 /** @type Map<number, {host: string, trackerTorrentMap: Map<string, string[]>}> **/
155 const trackerList = new Map();
157 let selectedTracker = Number(LocalPreferences.get("selected_tracker", TRACKERS_ALL));
158 let setTrackerFilter = function() {};
160 /* All filters */
161 let selectedStatus = LocalPreferences.get("selected_filter", "all");
162 let setStatusFilter = function() {};
163 let toggleFilterDisplay = function() {};
165 window.addEventListener("DOMContentLoaded", () => {
166 let isSearchPanelLoaded = false;
167 let isLogPanelLoaded = false;
169 const saveColumnSizes = function() {
170 const filters_width = $("Filters").getSize().x;
171 LocalPreferences.set("filters_width", filters_width);
172 const properties_height_rel = $("propertiesPanel").getSize().y / Window.getSize().y;
173 LocalPreferences.set("properties_height_rel", properties_height_rel);
176 window.addEventListener("resize", window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
177 // only save sizes if the columns are visible
178 if (!$("mainColumn").hasClass("invisible"))
179 saveColumnSizes();
180 }));
182 /* MochaUI.Desktop = new MochaUI.Desktop();
183 MochaUI.Desktop.desktop.style.background = "#fff";
184 MochaUI.Desktop.desktop.style.visibility = "visible"; */
185 MochaUI.Desktop.initialize();
187 const buildTransfersTab = function() {
188 const filt_w = Number(LocalPreferences.get("filters_width", 120));
189 new MochaUI.Column({
190 id: "filtersColumn",
191 placement: "left",
192 onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
193 saveColumnSizes();
195 width: filt_w,
196 resizeLimit: [1, 300]
198 new MochaUI.Column({
199 id: "mainColumn",
200 placement: "main"
204 const buildSearchTab = function() {
205 new MochaUI.Column({
206 id: "searchTabColumn",
207 placement: "main",
208 width: null
211 // start off hidden
212 $("searchTabColumn").addClass("invisible");
215 const buildRssTab = function() {
216 new MochaUI.Column({
217 id: "rssTabColumn",
218 placement: "main",
219 width: null
222 // start off hidden
223 $("rssTabColumn").addClass("invisible");
226 const buildLogTab = function() {
227 new MochaUI.Column({
228 id: "logTabColumn",
229 placement: "main",
230 width: null
233 // start off hidden
234 $("logTabColumn").addClass("invisible");
237 buildTransfersTab();
238 buildSearchTab();
239 buildRssTab();
240 buildLogTab();
241 MochaUI.initializeTabs("mainWindowTabsList");
243 setStatusFilter = function(name) {
244 LocalPreferences.set("selected_filter", name);
245 selectedStatus = name;
246 highlightSelectedStatus();
247 updateMainData();
250 setCategoryFilter = function(hash) {
251 LocalPreferences.set("selected_category", hash);
252 selectedCategory = Number(hash);
253 highlightSelectedCategory();
254 updateMainData();
257 setTagFilter = function(hash) {
258 LocalPreferences.set("selected_tag", hash);
259 selectedTag = Number(hash);
260 highlightSelectedTag();
261 updateMainData();
264 setTrackerFilter = function(hash) {
265 LocalPreferences.set("selected_tracker", hash);
266 selectedTracker = Number(hash);
267 highlightSelectedTracker();
268 updateMainData();
271 toggleFilterDisplay = function(filterListID) {
272 const filterList = document.getElementById(filterListID);
273 const filterTitle = filterList.previousElementSibling;
274 const toggleIcon = filterTitle.firstElementChild;
275 toggleIcon.classList.toggle("rotate");
276 LocalPreferences.set(`filter_${filterListID.replace("FilterList", "")}_collapsed`, filterList.classList.toggle("invisible").toString());
279 new MochaUI.Panel({
280 id: "Filters",
281 title: "Panel",
282 header: false,
283 padding: {
284 top: 0,
285 right: 0,
286 bottom: 0,
287 left: 0
289 loadMethod: "xhr",
290 contentURL: "views/filters.html",
291 onContentLoaded: function() {
292 highlightSelectedStatus();
294 column: "filtersColumn",
295 height: 300
297 initializeWindows();
299 // Show Top Toolbar is enabled by default
300 let showTopToolbar = LocalPreferences.get("show_top_toolbar", "true") === "true";
301 if (!showTopToolbar) {
302 $("showTopToolbarLink").firstChild.style.opacity = "0";
303 $("mochaToolbar").addClass("invisible");
306 // Show Status Bar is enabled by default
307 let showStatusBar = LocalPreferences.get("show_status_bar", "true") === "true";
308 if (!showStatusBar) {
309 $("showStatusBarLink").firstChild.style.opacity = "0";
310 $("desktopFooterWrapper").addClass("invisible");
313 // Show Filters Sidebar is enabled by default
314 let showFiltersSidebar = LocalPreferences.get("show_filters_sidebar", "true") === "true";
315 if (!showFiltersSidebar) {
316 $("showFiltersSidebarLink").firstChild.style.opacity = "0";
317 $("filtersColumn").addClass("invisible");
318 $("filtersColumn_handle").addClass("invisible");
321 let speedInTitle = LocalPreferences.get("speed_in_browser_title_bar") === "true";
322 if (!speedInTitle)
323 $("speedInBrowserTitleBarLink").firstChild.style.opacity = "0";
325 // After showing/hiding the toolbar + status bar
326 window.qBittorrent.Client.showSearchEngine(LocalPreferences.get("show_search_engine") !== "false");
327 window.qBittorrent.Client.showRssReader(LocalPreferences.get("show_rss_reader") !== "false");
328 window.qBittorrent.Client.showLogViewer(LocalPreferences.get("show_log_viewer") === "true");
330 // After Show Top Toolbar
331 MochaUI.Desktop.setDesktopSize();
333 let syncMainDataLastResponseId = 0;
334 const serverState = {};
336 const removeTorrentFromCategoryList = function(hash) {
337 if (!hash)
338 return false;
340 let removed = false;
341 category_list.forEach((category) => {
342 const deleteResult = category.torrents.delete(hash);
343 removed ||= deleteResult;
346 return removed;
349 const addTorrentToCategoryList = function(torrent) {
350 const category = torrent["category"];
351 if (typeof category === "undefined")
352 return false;
354 const hash = torrent["hash"];
355 if (category.length === 0) { // Empty category
356 removeTorrentFromCategoryList(hash);
357 return true;
360 const categoryHash = window.qBittorrent.Client.genHash(category);
361 if (!category_list.has(categoryHash)) { // This should not happen
362 category_list.set(categoryHash, {
363 name: category,
364 torrents: new Set()
368 const torrents = category_list.get(categoryHash).torrents;
369 if (!torrents.has(hash)) {
370 removeTorrentFromCategoryList(hash);
371 torrents.add(hash);
372 return true;
374 return false;
377 const removeTorrentFromTagList = function(hash) {
378 if (!hash)
379 return false;
381 let removed = false;
382 tagList.forEach((tag) => {
383 const deleteResult = tag.torrents.delete(hash);
384 removed ||= deleteResult;
387 return removed;
390 const addTorrentToTagList = function(torrent) {
391 if (torrent["tags"] === undefined) // Tags haven't changed
392 return false;
394 const hash = torrent["hash"];
395 removeTorrentFromTagList(hash);
397 if (torrent["tags"].length === 0) // No tags
398 return true;
400 const tags = torrent["tags"].split(",");
401 let added = false;
402 for (let i = 0; i < tags.length; ++i) {
403 const tagHash = window.qBittorrent.Client.genHash(tags[i].trim());
404 if (!tagList.has(tagHash)) { // This should not happen
405 tagList.set(tagHash, {
406 name: tags,
407 torrents: new Set()
411 const torrents = tagList.get(tagHash).torrents;
412 if (!torrents.has(hash)) {
413 torrents.add(hash);
414 added = true;
417 return added;
420 const updateFilter = function(filter, filterTitle) {
421 const filterEl = document.getElementById(`${filter}_filter`);
422 const filterTorrentCount = torrentsTable.getFilteredTorrentsNumber(filter, CATEGORIES_ALL, TAGS_ALL, TRACKERS_ALL);
423 if (useAutoHideZeroStatusFilters) {
424 const hideFilter = (filterTorrentCount === 0) && (filter !== "all");
425 if (filterEl.classList.toggle("invisible", hideFilter))
426 return;
428 filterEl.firstElementChild.lastChild.nodeValue = filterTitle.replace("%1", filterTorrentCount);
431 const updateFiltersList = function() {
432 updateFilter("all", "QBT_TR(All (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
433 updateFilter("downloading", "QBT_TR(Downloading (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
434 updateFilter("seeding", "QBT_TR(Seeding (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
435 updateFilter("completed", "QBT_TR(Completed (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
436 updateFilter("running", "QBT_TR(Running (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
437 updateFilter("stopped", "QBT_TR(Stopped (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
438 updateFilter("active", "QBT_TR(Active (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
439 updateFilter("inactive", "QBT_TR(Inactive (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
440 updateFilter("stalled", "QBT_TR(Stalled (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
441 updateFilter("stalled_uploading", "QBT_TR(Stalled Uploading (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
442 updateFilter("stalled_downloading", "QBT_TR(Stalled Downloading (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
443 updateFilter("checking", "QBT_TR(Checking (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
444 updateFilter("moving", "QBT_TR(Moving (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
445 updateFilter("errored", "QBT_TR(Errored (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
448 const highlightSelectedStatus = function() {
449 const statusFilter = document.getElementById("statusFilterList");
450 const filterID = `${selectedStatus}_filter`;
451 for (const status of statusFilter.children)
452 status.classList.toggle("selectedFilter", (status.id === filterID));
455 const updateCategoryList = function() {
456 const categoryList = $("categoryFilterList");
457 if (!categoryList)
458 return;
459 categoryList.getChildren().each(c => c.destroy());
461 const categoryItemTemplate = document.getElementById("categoryFilterItem");
463 const create_link = function(hash, text, count) {
464 let display_name = text;
465 let margin_left = 0;
466 if (useSubcategories) {
467 const category_path = text.split("/");
468 display_name = category_path[category_path.length - 1];
469 margin_left = (category_path.length - 1) * 20;
472 const categoryFilterItem = categoryItemTemplate.content.cloneNode(true).firstElementChild;
473 categoryFilterItem.id = hash;
474 categoryFilterItem.classList.toggle("selectedFilter", hash === selectedCategory);
476 const span = categoryFilterItem.firstElementChild;
477 span.style.marginLeft = `${margin_left}px`;
478 span.lastChild.textContent = `${display_name} (${count})`;
480 return categoryFilterItem;
483 const all = torrentsTable.getRowIds().length;
484 let uncategorized = 0;
485 for (const key in torrentsTable.rows) {
486 if (!Object.hasOwn(torrentsTable.rows, key))
487 continue;
489 const row = torrentsTable.rows[key];
490 if (row["full_data"].category.length === 0)
491 uncategorized += 1;
493 categoryList.appendChild(create_link(CATEGORIES_ALL, "QBT_TR(All)QBT_TR[CONTEXT=CategoryFilterModel]", all));
494 categoryList.appendChild(create_link(CATEGORIES_UNCATEGORIZED, "QBT_TR(Uncategorized)QBT_TR[CONTEXT=CategoryFilterModel]", uncategorized));
496 const sortedCategories = [];
497 category_list.forEach((category, hash) => sortedCategories.push({
498 categoryName: category.name,
499 categoryHash: hash,
500 categoryCount: category.torrents.size
501 }));
502 sortedCategories.sort((left, right) => {
503 const leftSegments = left.categoryName.split("/");
504 const rightSegments = right.categoryName.split("/");
506 for (let i = 0, iMax = Math.min(leftSegments.length, rightSegments.length); i < iMax; ++i) {
507 const compareResult = window.qBittorrent.Misc.naturalSortCollator.compare(
508 leftSegments[i], rightSegments[i]);
509 if (compareResult !== 0)
510 return compareResult;
513 return leftSegments.length - rightSegments.length;
516 for (let i = 0; i < sortedCategories.length; ++i) {
517 const { categoryName, categoryHash } = sortedCategories[i];
518 let { categoryCount } = sortedCategories[i];
520 if (useSubcategories) {
521 for (let j = (i + 1);
522 ((j < sortedCategories.length) && sortedCategories[j].categoryName.startsWith(categoryName + "/")); ++j)
523 categoryCount += sortedCategories[j].categoryCount;
526 categoryList.appendChild(create_link(categoryHash, categoryName, categoryCount));
529 window.qBittorrent.Filters.categoriesFilterContextMenu.searchAndAddTargets();
532 const highlightSelectedCategory = function() {
533 const categoryList = document.getElementById("categoryFilterList");
534 if (!categoryList)
535 return;
537 for (const category of categoryList.children)
538 category.classList.toggle("selectedFilter", (Number(category.id) === selectedCategory));
541 const updateTagList = function() {
542 const tagFilterList = $("tagFilterList");
543 if (tagFilterList === null)
544 return;
546 tagFilterList.getChildren().each(c => c.destroy());
548 const tagItemTemplate = document.getElementById("tagFilterItem");
550 const createLink = function(hash, text, count) {
551 const tagFilterItem = tagItemTemplate.content.cloneNode(true).firstElementChild;
552 tagFilterItem.id = hash;
553 tagFilterItem.classList.toggle("selectedFilter", hash === selectedTag);
555 const span = tagFilterItem.firstElementChild;
556 span.lastChild.textContent = `${text} (${count})`;
558 return tagFilterItem;
561 const torrentsCount = torrentsTable.getRowIds().length;
562 let untagged = 0;
563 for (const key in torrentsTable.rows) {
564 if (Object.hasOwn(torrentsTable.rows, key) && (torrentsTable.rows[key]["full_data"].tags.length === 0))
565 untagged += 1;
567 tagFilterList.appendChild(createLink(TAGS_ALL, "QBT_TR(All)QBT_TR[CONTEXT=TagFilterModel]", torrentsCount));
568 tagFilterList.appendChild(createLink(TAGS_UNTAGGED, "QBT_TR(Untagged)QBT_TR[CONTEXT=TagFilterModel]", untagged));
570 const sortedTags = [];
571 tagList.forEach((tag, hash) => sortedTags.push({
572 tagName: tag.name,
573 tagHash: hash,
574 tagSize: tag.torrents.size
575 }));
576 sortedTags.sort((left, right) => window.qBittorrent.Misc.naturalSortCollator.compare(left.tagName, right.tagName));
578 for (const { tagName, tagHash, tagSize } of sortedTags)
579 tagFilterList.appendChild(createLink(tagHash, tagName, tagSize));
581 window.qBittorrent.Filters.tagsFilterContextMenu.searchAndAddTargets();
584 const highlightSelectedTag = function() {
585 const tagFilterList = document.getElementById("tagFilterList");
586 if (!tagFilterList)
587 return;
589 for (const tag of tagFilterList.children)
590 tag.classList.toggle("selectedFilter", (Number(tag.id) === selectedTag));
593 // getHost emulate the GUI version `QString getHost(const QString &url)`
594 const getHost = function(url) {
595 // We want the hostname.
596 // If failed to parse the domain, original input should be returned
598 if (!/^(?:https?|udp):/i.test(url))
599 return url;
601 try {
602 // hack: URL can not get hostname from udp protocol
603 const parsedUrl = new URL(url.replace(/^udp:/i, "https:"));
604 // host: "example.com:8443"
605 // hostname: "example.com"
606 const host = parsedUrl.hostname;
607 if (!host)
608 return url;
610 return host;
612 catch (error) {
613 return url;
617 const updateTrackerList = function() {
618 const trackerFilterList = $("trackerFilterList");
619 if (trackerFilterList === null)
620 return;
622 trackerFilterList.getChildren().each(c => c.destroy());
624 const trackerItemTemplate = document.getElementById("trackerFilterItem");
626 const createLink = function(hash, text, count) {
627 const trackerFilterItem = trackerItemTemplate.content.cloneNode(true).firstElementChild;
628 trackerFilterItem.id = hash;
629 trackerFilterItem.classList.toggle("selectedFilter", hash === selectedTracker);
631 const span = trackerFilterItem.firstElementChild;
632 span.lastChild.textContent = text.replace("%1", count);
634 return trackerFilterItem;
637 const torrentsCount = torrentsTable.getRowIds().length;
638 trackerFilterList.appendChild(createLink(TRACKERS_ALL, "QBT_TR(All (%1))QBT_TR[CONTEXT=TrackerFiltersList]", torrentsCount));
639 let trackerlessTorrentsCount = 0;
640 for (const key in torrentsTable.rows) {
641 if (Object.hasOwn(torrentsTable.rows, key) && (torrentsTable.rows[key]["full_data"].trackers_count === 0))
642 trackerlessTorrentsCount += 1;
644 trackerFilterList.appendChild(createLink(TRACKERS_TRACKERLESS, "QBT_TR(Trackerless (%1))QBT_TR[CONTEXT=TrackerFiltersList]", trackerlessTorrentsCount));
646 // Sort trackers by hostname
647 const sortedList = [];
648 trackerList.forEach(({ host, trackerTorrentMap }, hash) => {
649 const uniqueTorrents = new Set();
650 for (const torrents of trackerTorrentMap.values()) {
651 for (const torrent of torrents)
652 uniqueTorrents.add(torrent);
655 sortedList.push({
656 trackerHost: host,
657 trackerHash: hash,
658 trackerCount: uniqueTorrents.size,
661 sortedList.sort((left, right) => window.qBittorrent.Misc.naturalSortCollator.compare(left.trackerHost, right.trackerHost));
662 for (const { trackerHost, trackerHash, trackerCount } of sortedList)
663 trackerFilterList.appendChild(createLink(trackerHash, (trackerHost + " (%1)"), trackerCount));
665 window.qBittorrent.Filters.trackersFilterContextMenu.searchAndAddTargets();
668 const highlightSelectedTracker = function() {
669 const trackerFilterList = document.getElementById("trackerFilterList");
670 if (!trackerFilterList)
671 return;
673 for (const tracker of trackerFilterList.children)
674 tracker.classList.toggle("selectedFilter", (Number(tracker.id) === selectedTracker));
677 const setupCopyEventHandler = (function() {
678 let clipboardEvent;
680 return () => {
681 if (clipboardEvent)
682 clipboardEvent.destroy();
684 clipboardEvent = new ClipboardJS(".copyToClipboard", {
685 text: function(trigger) {
686 switch (trigger.id) {
687 case "copyName":
688 return copyNameFN();
689 case "copyInfohash1":
690 return copyInfohashFN(1);
691 case "copyInfohash2":
692 return copyInfohashFN(2);
693 case "copyMagnetLink":
694 return copyMagnetLinkFN();
695 case "copyID":
696 return copyIdFN();
697 case "copyComment":
698 return copyCommentFN();
699 default:
700 return "";
705 })();
707 let syncMainDataTimeoutID = -1;
708 let syncRequestInProgress = false;
709 const syncMainData = function() {
710 const url = new URI("api/v2/sync/maindata");
711 url.setData("rid", syncMainDataLastResponseId);
712 const request = new Request.JSON({
713 url: url,
714 noCache: true,
715 method: "get",
716 onFailure: function() {
717 const errorDiv = $("error_div");
718 if (errorDiv)
719 errorDiv.textContent = "QBT_TR(qBittorrent client is not reachable)QBT_TR[CONTEXT=HttpServer]";
720 syncRequestInProgress = false;
721 syncData(2000);
723 onSuccess: function(response) {
724 $("error_div").textContent = "";
725 if (response) {
726 clearTimeout(torrentsFilterInputTimer);
727 torrentsFilterInputTimer = -1;
729 let torrentsTableSelectedRows;
730 let update_categories = false;
731 let updateTags = false;
732 let updateTrackers = false;
733 const full_update = (response["full_update"] === true);
734 if (full_update) {
735 torrentsTableSelectedRows = torrentsTable.selectedRowsIds();
736 update_categories = true;
737 updateTags = true;
738 updateTrackers = true;
739 torrentsTable.clear();
740 category_list.clear();
741 tagList.clear();
742 trackerList.clear();
744 if (response["rid"])
745 syncMainDataLastResponseId = response["rid"];
746 if (response["categories"]) {
747 for (const key in response["categories"]) {
748 if (!Object.hasOwn(response["categories"], key))
749 continue;
751 const responseCategory = response["categories"][key];
752 const categoryHash = window.qBittorrent.Client.genHash(key);
753 const category = category_list.get(categoryHash);
754 if (category !== undefined) {
755 // only the save path can change for existing categories
756 category.savePath = responseCategory.savePath;
758 else {
759 category_list.set(categoryHash, {
760 name: responseCategory.name,
761 savePath: responseCategory.savePath,
762 torrents: new Set()
766 update_categories = true;
768 if (response["categories_removed"]) {
769 response["categories_removed"].each((category) => {
770 const categoryHash = window.qBittorrent.Client.genHash(category);
771 category_list.delete(categoryHash);
773 update_categories = true;
775 if (response["tags"]) {
776 for (const tag of response["tags"]) {
777 const tagHash = window.qBittorrent.Client.genHash(tag);
778 if (!tagList.has(tagHash)) {
779 tagList.set(tagHash, {
780 name: tag,
781 torrents: new Set()
785 updateTags = true;
787 if (response["tags_removed"]) {
788 for (let i = 0; i < response["tags_removed"].length; ++i) {
789 const tagHash = window.qBittorrent.Client.genHash(response["tags_removed"][i]);
790 tagList.delete(tagHash);
792 updateTags = true;
794 if (response["trackers"]) {
795 for (const [tracker, torrents] of Object.entries(response["trackers"])) {
796 const host = getHost(tracker);
797 const hash = window.qBittorrent.Client.genHash(host);
799 let trackerListItem = trackerList.get(hash);
800 if (trackerListItem === undefined) {
801 trackerListItem = { host: host, trackerTorrentMap: new Map() };
802 trackerList.set(hash, trackerListItem);
805 trackerListItem.trackerTorrentMap.set(tracker, [...torrents]);
807 updateTrackers = true;
809 if (response["trackers_removed"]) {
810 for (let i = 0; i < response["trackers_removed"].length; ++i) {
811 const tracker = response["trackers_removed"][i];
812 const hash = window.qBittorrent.Client.genHash(getHost(tracker));
813 const trackerListEntry = trackerList.get(hash);
814 if (trackerListEntry)
815 trackerListEntry.trackerTorrentMap.delete(tracker);
817 updateTrackers = true;
819 if (response["torrents"]) {
820 let updateTorrentList = false;
821 for (const key in response["torrents"]) {
822 if (!Object.hasOwn(response["torrents"], key))
823 continue;
825 response["torrents"][key]["hash"] = key;
826 response["torrents"][key]["rowId"] = key;
827 if (response["torrents"][key]["state"])
828 response["torrents"][key]["status"] = response["torrents"][key]["state"];
829 torrentsTable.updateRowData(response["torrents"][key]);
830 if (addTorrentToCategoryList(response["torrents"][key]))
831 update_categories = true;
832 if (addTorrentToTagList(response["torrents"][key]))
833 updateTags = true;
834 if (response["torrents"][key]["name"])
835 updateTorrentList = true;
838 if (updateTorrentList)
839 setupCopyEventHandler();
841 if (response["torrents_removed"]) {
842 response["torrents_removed"].each((hash) => {
843 torrentsTable.removeRow(hash);
844 removeTorrentFromCategoryList(hash);
845 update_categories = true; // Always to update All category
846 removeTorrentFromTagList(hash);
847 updateTags = true; // Always to update All tag
850 torrentsTable.updateTable(full_update);
851 if (response["server_state"]) {
852 const tmp = response["server_state"];
853 for (const k in tmp) {
854 if (!Object.hasOwn(tmp, k))
855 continue;
856 serverState[k] = tmp[k];
858 processServerState();
860 updateFiltersList();
861 if (update_categories) {
862 updateCategoryList();
863 window.qBittorrent.TransferList.contextMenu.updateCategoriesSubMenu(category_list);
865 if (updateTags) {
866 updateTagList();
867 window.qBittorrent.TransferList.contextMenu.updateTagsSubMenu(tagList);
869 if (updateTrackers)
870 updateTrackerList();
872 if (full_update)
873 // re-select previously selected rows
874 torrentsTable.reselectRows(torrentsTableSelectedRows);
876 syncRequestInProgress = false;
877 syncData(window.qBittorrent.Client.getSyncMainDataInterval());
880 syncRequestInProgress = true;
881 request.send();
884 updateMainData = function() {
885 torrentsTable.updateTable();
886 syncData(100);
889 const syncData = function(delay) {
890 if (syncRequestInProgress)
891 return;
893 clearTimeout(syncMainDataTimeoutID);
894 syncMainDataTimeoutID = -1;
896 if (window.qBittorrent.Client.isStopped())
897 return;
899 syncMainDataTimeoutID = syncMainData.delay(delay);
902 const processServerState = function() {
903 let transfer_info = window.qBittorrent.Misc.friendlyUnit(serverState.dl_info_speed, true);
904 if (serverState.dl_rate_limit > 0)
905 transfer_info += " [" + window.qBittorrent.Misc.friendlyUnit(serverState.dl_rate_limit, true) + "]";
906 transfer_info += " (" + window.qBittorrent.Misc.friendlyUnit(serverState.dl_info_data, false) + ")";
907 $("DlInfos").textContent = transfer_info;
908 transfer_info = window.qBittorrent.Misc.friendlyUnit(serverState.up_info_speed, true);
909 if (serverState.up_rate_limit > 0)
910 transfer_info += " [" + window.qBittorrent.Misc.friendlyUnit(serverState.up_rate_limit, true) + "]";
911 transfer_info += " (" + window.qBittorrent.Misc.friendlyUnit(serverState.up_info_data, false) + ")";
912 $("UpInfos").textContent = transfer_info;
914 document.title = (speedInTitle
915 ? (`QBT_TR([D: %1, U: %2])QBT_TR[CONTEXT=MainWindow] `
916 .replace("%1", window.qBittorrent.Misc.friendlyUnit(serverState.dl_info_speed, true))
917 .replace("%2", window.qBittorrent.Misc.friendlyUnit(serverState.up_info_speed, true)))
918 : "")
919 + window.qBittorrent.Client.mainTitle();
921 $("freeSpaceOnDisk").textContent = "QBT_TR(Free space: %1)QBT_TR[CONTEXT=HttpServer]".replace("%1", window.qBittorrent.Misc.friendlyUnit(serverState.free_space_on_disk));
922 $("DHTNodes").textContent = "QBT_TR(DHT: %1 nodes)QBT_TR[CONTEXT=StatusBar]".replace("%1", serverState.dht_nodes);
924 // Statistics dialog
925 if (document.getElementById("statisticsContent")) {
926 $("AlltimeDL").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.alltime_dl, false);
927 $("AlltimeUL").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.alltime_ul, false);
928 $("TotalWastedSession").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.total_wasted_session, false);
929 $("GlobalRatio").textContent = serverState.global_ratio;
930 $("TotalPeerConnections").textContent = serverState.total_peer_connections;
931 $("ReadCacheHits").textContent = serverState.read_cache_hits + "%";
932 $("TotalBuffersSize").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.total_buffers_size, false);
933 $("WriteCacheOverload").textContent = serverState.write_cache_overload + "%";
934 $("ReadCacheOverload").textContent = serverState.read_cache_overload + "%";
935 $("QueuedIOJobs").textContent = serverState.queued_io_jobs;
936 $("AverageTimeInQueue").textContent = serverState.average_time_queue + " ms";
937 $("TotalQueuedSize").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.total_queued_size, false);
940 switch (serverState.connection_status) {
941 case "connected":
942 $("connectionStatus").src = "images/connected.svg";
943 $("connectionStatus").alt = "QBT_TR(Connection status: Connected)QBT_TR[CONTEXT=MainWindow]";
944 $("connectionStatus").title = "QBT_TR(Connection status: Connected)QBT_TR[CONTEXT=MainWindow]";
945 break;
946 case "firewalled":
947 $("connectionStatus").src = "images/firewalled.svg";
948 $("connectionStatus").alt = "QBT_TR(Connection status: Firewalled)QBT_TR[CONTEXT=MainWindow]";
949 $("connectionStatus").title = "QBT_TR(Connection status: Firewalled)QBT_TR[CONTEXT=MainWindow]";
950 break;
951 default:
952 $("connectionStatus").src = "images/disconnected.svg";
953 $("connectionStatus").alt = "QBT_TR(Connection status: Disconnected)QBT_TR[CONTEXT=MainWindow]";
954 $("connectionStatus").title = "QBT_TR(Connection status: Disconnected)QBT_TR[CONTEXT=MainWindow]";
955 break;
958 if (queueing_enabled !== serverState.queueing) {
959 queueing_enabled = serverState.queueing;
960 torrentsTable.columns["priority"].force_hide = !queueing_enabled;
961 torrentsTable.updateColumn("priority");
962 if (queueing_enabled) {
963 $("topQueuePosItem").removeClass("invisible");
964 $("increaseQueuePosItem").removeClass("invisible");
965 $("decreaseQueuePosItem").removeClass("invisible");
966 $("bottomQueuePosItem").removeClass("invisible");
967 $("queueingButtons").removeClass("invisible");
968 $("queueingMenuItems").removeClass("invisible");
970 else {
971 $("topQueuePosItem").addClass("invisible");
972 $("increaseQueuePosItem").addClass("invisible");
973 $("decreaseQueuePosItem").addClass("invisible");
974 $("bottomQueuePosItem").addClass("invisible");
975 $("queueingButtons").addClass("invisible");
976 $("queueingMenuItems").addClass("invisible");
980 if (alternativeSpeedLimits !== serverState.use_alt_speed_limits) {
981 alternativeSpeedLimits = serverState.use_alt_speed_limits;
982 updateAltSpeedIcon(alternativeSpeedLimits);
985 if (useSubcategories !== serverState.use_subcategories) {
986 useSubcategories = serverState.use_subcategories;
987 updateCategoryList();
990 serverSyncMainDataInterval = Math.max(serverState.refresh_interval, 500);
993 const updateAltSpeedIcon = function(enabled) {
994 if (enabled) {
995 $("alternativeSpeedLimits").src = "images/slow.svg";
996 $("alternativeSpeedLimits").alt = "QBT_TR(Alternative speed limits: On)QBT_TR[CONTEXT=MainWindow]";
997 $("alternativeSpeedLimits").title = "QBT_TR(Alternative speed limits: On)QBT_TR[CONTEXT=MainWindow]";
999 else {
1000 $("alternativeSpeedLimits").src = "images/slow_off.svg";
1001 $("alternativeSpeedLimits").alt = "QBT_TR(Alternative speed limits: Off)QBT_TR[CONTEXT=MainWindow]";
1002 $("alternativeSpeedLimits").title = "QBT_TR(Alternative speed limits: Off)QBT_TR[CONTEXT=MainWindow]";
1006 $("alternativeSpeedLimits").addEventListener("click", () => {
1007 // Change icon immediately to give some feedback
1008 updateAltSpeedIcon(!alternativeSpeedLimits);
1010 new Request({
1011 url: "api/v2/transfer/toggleSpeedLimitsMode",
1012 method: "post",
1013 onComplete: function() {
1014 alternativeSpeedLimits = !alternativeSpeedLimits;
1015 updateMainData();
1017 onFailure: function() {
1018 // Restore icon in case of failure
1019 updateAltSpeedIcon(alternativeSpeedLimits);
1021 }).send();
1024 $("DlInfos").addEventListener("click", () => { globalDownloadLimitFN(); });
1025 $("UpInfos").addEventListener("click", () => { globalUploadLimitFN(); });
1027 $("showTopToolbarLink").addEventListener("click", (e) => {
1028 showTopToolbar = !showTopToolbar;
1029 LocalPreferences.set("show_top_toolbar", showTopToolbar.toString());
1030 if (showTopToolbar) {
1031 $("showTopToolbarLink").firstChild.style.opacity = "1";
1032 $("mochaToolbar").removeClass("invisible");
1034 else {
1035 $("showTopToolbarLink").firstChild.style.opacity = "0";
1036 $("mochaToolbar").addClass("invisible");
1038 MochaUI.Desktop.setDesktopSize();
1041 $("showStatusBarLink").addEventListener("click", (e) => {
1042 showStatusBar = !showStatusBar;
1043 LocalPreferences.set("show_status_bar", showStatusBar.toString());
1044 if (showStatusBar) {
1045 $("showStatusBarLink").firstChild.style.opacity = "1";
1046 $("desktopFooterWrapper").removeClass("invisible");
1048 else {
1049 $("showStatusBarLink").firstChild.style.opacity = "0";
1050 $("desktopFooterWrapper").addClass("invisible");
1052 MochaUI.Desktop.setDesktopSize();
1055 const registerMagnetHandler = function() {
1056 if (typeof navigator.registerProtocolHandler !== "function") {
1057 if (window.location.protocol !== "https:")
1058 alert("QBT_TR(To use this feature, the WebUI needs to be accessed over HTTPS)QBT_TR[CONTEXT=MainWindow]");
1059 else
1060 alert("QBT_TR(Your browser does not support this feature)QBT_TR[CONTEXT=MainWindow]");
1061 return;
1064 const hashString = location.hash ? location.hash.replace(/^#/, "") : "";
1065 const hashParams = new URLSearchParams(hashString);
1066 hashParams.set("download", "");
1068 const templateHashString = hashParams.toString().replace("download=", "download=%s");
1069 const templateUrl = location.origin + location.pathname
1070 + location.search + "#" + templateHashString;
1072 navigator.registerProtocolHandler("magnet", templateUrl,
1073 "qBittorrent WebUI magnet handler");
1075 $("registerMagnetHandlerLink").addEventListener("click", (e) => {
1076 registerMagnetHandler();
1079 $("showFiltersSidebarLink").addEventListener("click", (e) => {
1080 showFiltersSidebar = !showFiltersSidebar;
1081 LocalPreferences.set("show_filters_sidebar", showFiltersSidebar.toString());
1082 if (showFiltersSidebar) {
1083 $("showFiltersSidebarLink").firstChild.style.opacity = "1";
1084 $("filtersColumn").removeClass("invisible");
1085 $("filtersColumn_handle").removeClass("invisible");
1087 else {
1088 $("showFiltersSidebarLink").firstChild.style.opacity = "0";
1089 $("filtersColumn").addClass("invisible");
1090 $("filtersColumn_handle").addClass("invisible");
1092 MochaUI.Desktop.setDesktopSize();
1095 $("speedInBrowserTitleBarLink").addEventListener("click", (e) => {
1096 speedInTitle = !speedInTitle;
1097 LocalPreferences.set("speed_in_browser_title_bar", speedInTitle.toString());
1098 if (speedInTitle)
1099 $("speedInBrowserTitleBarLink").firstChild.style.opacity = "1";
1100 else
1101 $("speedInBrowserTitleBarLink").firstChild.style.opacity = "0";
1102 processServerState();
1105 $("showSearchEngineLink").addEventListener("click", (e) => {
1106 window.qBittorrent.Client.showSearchEngine(!window.qBittorrent.Client.isShowSearchEngine());
1107 LocalPreferences.set("show_search_engine", window.qBittorrent.Client.isShowSearchEngine().toString());
1108 updateTabDisplay();
1111 $("showRssReaderLink").addEventListener("click", (e) => {
1112 window.qBittorrent.Client.showRssReader(!window.qBittorrent.Client.isShowRssReader());
1113 LocalPreferences.set("show_rss_reader", window.qBittorrent.Client.isShowRssReader().toString());
1114 updateTabDisplay();
1117 $("showLogViewerLink").addEventListener("click", (e) => {
1118 window.qBittorrent.Client.showLogViewer(!window.qBittorrent.Client.isShowLogViewer());
1119 LocalPreferences.set("show_log_viewer", window.qBittorrent.Client.isShowLogViewer().toString());
1120 updateTabDisplay();
1123 const updateTabDisplay = function() {
1124 if (window.qBittorrent.Client.isShowRssReader()) {
1125 $("showRssReaderLink").firstChild.style.opacity = "1";
1126 $("mainWindowTabs").removeClass("invisible");
1127 $("rssTabLink").removeClass("invisible");
1128 if (!MochaUI.Panels.instances.RssPanel)
1129 addRssPanel();
1131 else {
1132 $("showRssReaderLink").firstChild.style.opacity = "0";
1133 $("rssTabLink").addClass("invisible");
1134 if ($("rssTabLink").hasClass("selected"))
1135 $("transfersTabLink").click();
1138 if (window.qBittorrent.Client.isShowSearchEngine()) {
1139 $("showSearchEngineLink").firstChild.style.opacity = "1";
1140 $("mainWindowTabs").removeClass("invisible");
1141 $("searchTabLink").removeClass("invisible");
1142 if (!MochaUI.Panels.instances.SearchPanel)
1143 addSearchPanel();
1145 else {
1146 $("showSearchEngineLink").firstChild.style.opacity = "0";
1147 $("searchTabLink").addClass("invisible");
1148 if ($("searchTabLink").hasClass("selected"))
1149 $("transfersTabLink").click();
1152 if (window.qBittorrent.Client.isShowLogViewer()) {
1153 $("showLogViewerLink").firstChild.style.opacity = "1";
1154 $("mainWindowTabs").removeClass("invisible");
1155 $("logTabLink").removeClass("invisible");
1156 if (!MochaUI.Panels.instances.LogPanel)
1157 addLogPanel();
1159 else {
1160 $("showLogViewerLink").firstChild.style.opacity = "0";
1161 $("logTabLink").addClass("invisible");
1162 if ($("logTabLink").hasClass("selected"))
1163 $("transfersTabLink").click();
1166 // display no tabs
1167 if (!window.qBittorrent.Client.isShowRssReader() && !window.qBittorrent.Client.isShowSearchEngine() && !window.qBittorrent.Client.isShowLogViewer())
1168 $("mainWindowTabs").addClass("invisible");
1171 $("StatisticsLink").addEventListener("click", () => { StatisticsLinkFN(); });
1173 // main window tabs
1175 const showTransfersTab = function() {
1176 const showFiltersSidebar = LocalPreferences.get("show_filters_sidebar", "true") === "true";
1177 if (showFiltersSidebar) {
1178 $("filtersColumn").removeClass("invisible");
1179 $("filtersColumn_handle").removeClass("invisible");
1181 $("mainColumn").removeClass("invisible");
1182 $("torrentsFilterToolbar").removeClass("invisible");
1184 customSyncMainDataInterval = null;
1185 syncData(100);
1187 hideSearchTab();
1188 hideRssTab();
1189 hideLogTab();
1191 LocalPreferences.set("selected_window_tab", "transfers");
1194 const hideTransfersTab = function() {
1195 $("filtersColumn").addClass("invisible");
1196 $("filtersColumn_handle").addClass("invisible");
1197 $("mainColumn").addClass("invisible");
1198 $("torrentsFilterToolbar").addClass("invisible");
1199 MochaUI.Desktop.resizePanels();
1202 const showSearchTab = (function() {
1203 let searchTabInitialized = false;
1205 return () => {
1206 // we must wait until the panel is fully loaded before proceeding.
1207 // this include's the panel's custom js, which is loaded via MochaUI.Panel's 'require' field.
1208 // MochaUI loads these files asynchronously and thus all required libs may not be available immediately
1209 if (!isSearchPanelLoaded) {
1210 setTimeout(() => {
1211 showSearchTab();
1212 }, 100);
1213 return;
1216 if (!searchTabInitialized) {
1217 window.qBittorrent.Search.init();
1218 searchTabInitialized = true;
1221 $("searchTabColumn").removeClass("invisible");
1222 customSyncMainDataInterval = 30000;
1223 hideTransfersTab();
1224 hideRssTab();
1225 hideLogTab();
1227 LocalPreferences.set("selected_window_tab", "search");
1229 })();
1231 const hideSearchTab = function() {
1232 $("searchTabColumn").addClass("invisible");
1233 MochaUI.Desktop.resizePanels();
1236 const showRssTab = (function() {
1237 let rssTabInitialized = false;
1239 return () => {
1240 if (!rssTabInitialized) {
1241 window.qBittorrent.Rss.init();
1242 rssTabInitialized = true;
1244 else {
1245 window.qBittorrent.Rss.load();
1248 $("rssTabColumn").removeClass("invisible");
1249 customSyncMainDataInterval = 30000;
1250 hideTransfersTab();
1251 hideSearchTab();
1252 hideLogTab();
1254 LocalPreferences.set("selected_window_tab", "rss");
1256 })();
1258 const hideRssTab = function() {
1259 $("rssTabColumn").addClass("invisible");
1260 window.qBittorrent.Rss && window.qBittorrent.Rss.unload();
1261 MochaUI.Desktop.resizePanels();
1264 const showLogTab = (function() {
1265 let logTabInitialized = false;
1267 return () => {
1268 // we must wait until the panel is fully loaded before proceeding.
1269 // this include's the panel's custom js, which is loaded via MochaUI.Panel's 'require' field.
1270 // MochaUI loads these files asynchronously and thus all required libs may not be available immediately
1271 if (!isLogPanelLoaded) {
1272 setTimeout(() => {
1273 showLogTab();
1274 }, 100);
1275 return;
1278 if (!logTabInitialized) {
1279 window.qBittorrent.Log.init();
1280 logTabInitialized = true;
1282 else {
1283 window.qBittorrent.Log.load();
1286 $("logTabColumn").removeClass("invisible");
1287 customSyncMainDataInterval = 30000;
1288 hideTransfersTab();
1289 hideSearchTab();
1290 hideRssTab();
1292 LocalPreferences.set("selected_window_tab", "log");
1294 })();
1296 const hideLogTab = function() {
1297 $("logTabColumn").addClass("invisible");
1298 MochaUI.Desktop.resizePanels();
1299 window.qBittorrent.Log && window.qBittorrent.Log.unload();
1302 const addSearchPanel = function() {
1303 new MochaUI.Panel({
1304 id: "SearchPanel",
1305 title: "Search",
1306 header: false,
1307 padding: {
1308 top: 0,
1309 right: 0,
1310 bottom: 0,
1311 left: 0
1313 loadMethod: "xhr",
1314 contentURL: "views/search.html",
1315 require: {
1316 js: ["scripts/search.js"],
1317 onload: () => {
1318 isSearchPanelLoaded = true;
1321 content: "",
1322 column: "searchTabColumn",
1323 height: null
1327 const addRssPanel = function() {
1328 new MochaUI.Panel({
1329 id: "RssPanel",
1330 title: "Rss",
1331 header: false,
1332 padding: {
1333 top: 0,
1334 right: 0,
1335 bottom: 0,
1336 left: 0
1338 loadMethod: "xhr",
1339 contentURL: "views/rss.html",
1340 content: "",
1341 column: "rssTabColumn",
1342 height: null
1346 const addLogPanel = function() {
1347 new MochaUI.Panel({
1348 id: "LogPanel",
1349 title: "Log",
1350 header: true,
1351 padding: {
1352 top: 0,
1353 right: 0,
1354 bottom: 0,
1355 left: 0
1357 loadMethod: "xhr",
1358 contentURL: "views/log.html",
1359 require: {
1360 css: ["css/vanillaSelectBox.css"],
1361 js: ["scripts/lib/vanillaSelectBox.js"],
1362 onload: () => {
1363 isLogPanelLoaded = true;
1366 tabsURL: "views/logTabs.html",
1367 tabsOnload: function() {
1368 MochaUI.initializeTabs("panelTabs");
1370 $("logMessageLink").addEventListener("click", (e) => {
1371 window.qBittorrent.Log.setCurrentTab("main");
1374 $("logPeerLink").addEventListener("click", (e) => {
1375 window.qBittorrent.Log.setCurrentTab("peer");
1378 collapsible: false,
1379 content: "",
1380 column: "logTabColumn",
1381 height: null
1385 const handleDownloadParam = function() {
1386 // Extract torrent URL from download param in WebUI URL hash
1387 const downloadHash = "#download=";
1388 if (location.hash.indexOf(downloadHash) !== 0)
1389 return;
1391 const url = decodeURIComponent(location.hash.substring(downloadHash.length));
1392 // Remove the processed hash from the URL
1393 history.replaceState("", document.title, (location.pathname + location.search));
1394 showDownloadPage([url]);
1397 new MochaUI.Panel({
1398 id: "transferList",
1399 title: "Panel",
1400 header: false,
1401 padding: {
1402 top: 0,
1403 right: 0,
1404 bottom: 0,
1405 left: 0
1407 loadMethod: "xhr",
1408 contentURL: "views/transferlist.html",
1409 onContentLoaded: function() {
1410 handleDownloadParam();
1411 updateMainData();
1413 column: "mainColumn",
1414 onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
1415 saveColumnSizes();
1417 height: null
1419 let prop_h = LocalPreferences.get("properties_height_rel");
1420 if (prop_h !== null)
1421 prop_h = prop_h.toFloat() * Window.getSize().y;
1422 else
1423 prop_h = Window.getSize().y / 2.0;
1424 new MochaUI.Panel({
1425 id: "propertiesPanel",
1426 title: "Panel",
1427 padding: {
1428 top: 0,
1429 right: 0,
1430 bottom: 0,
1431 left: 0
1433 contentURL: "views/properties.html",
1434 require: {
1435 js: ["scripts/prop-general.js", "scripts/prop-trackers.js", "scripts/prop-peers.js", "scripts/prop-webseeds.js", "scripts/prop-files.js"],
1436 onload: function() {
1437 updatePropertiesPanel = function() {
1438 switch (LocalPreferences.get("selected_properties_tab")) {
1439 case "propGeneralLink":
1440 window.qBittorrent.PropGeneral.updateData();
1441 break;
1442 case "propTrackersLink":
1443 window.qBittorrent.PropTrackers.updateData();
1444 break;
1445 case "propPeersLink":
1446 window.qBittorrent.PropPeers.updateData();
1447 break;
1448 case "propWebSeedsLink":
1449 window.qBittorrent.PropWebseeds.updateData();
1450 break;
1451 case "propFilesLink":
1452 window.qBittorrent.PropFiles.updateData();
1453 break;
1458 tabsURL: "views/propertiesToolbar.html",
1459 tabsOnload: function() {}, // must be included, otherwise panel won't load properly
1460 onContentLoaded: function() {
1461 this.panelHeaderCollapseBoxEl.classList.add("invisible");
1463 const togglePropertiesPanel = () => {
1464 this.collapseToggleEl.click();
1465 LocalPreferences.set("properties_panel_collapsed", this.isCollapsed.toString());
1468 const selectTab = (tabID) => {
1469 const isAlreadySelected = this.panelHeaderEl.getElementById(tabID).classList.contains("selected");
1470 if (!isAlreadySelected) {
1471 for (const tab of this.panelHeaderEl.getElementById("propertiesTabs").children)
1472 tab.classList.toggle("selected", tab.id === tabID);
1474 const tabContentID = tabID.replace("Link", "");
1475 for (const tabContent of this.contentEl.children)
1476 tabContent.classList.toggle("invisible", tabContent.id !== tabContentID);
1478 LocalPreferences.set("selected_properties_tab", tabID);
1481 if (isAlreadySelected || this.isCollapsed)
1482 togglePropertiesPanel();
1485 const lastUsedTab = LocalPreferences.get("selected_properties_tab", "propGeneralLink");
1486 selectTab(lastUsedTab);
1488 const startCollapsed = LocalPreferences.get("properties_panel_collapsed", "false") === "true";
1489 if (startCollapsed)
1490 togglePropertiesPanel();
1492 this.panelHeaderContentEl.addEventListener("click", (e) => {
1493 const selectedTab = e.target.closest("li");
1494 if (!selectedTab)
1495 return;
1497 selectTab(selectedTab.id);
1498 updatePropertiesPanel();
1500 const showFilesFilter = (selectedTab.id === "propFilesLink") && !this.isCollapsed;
1501 document.getElementById("torrentFilesFilterToolbar").classList.toggle("invisible", !showFilesFilter);
1504 column: "mainColumn",
1505 height: prop_h
1508 // listen for changes to torrentsFilterInput
1509 let torrentsFilterInputTimer = -1;
1510 $("torrentsFilterInput").addEventListener("input", () => {
1511 clearTimeout(torrentsFilterInputTimer);
1512 torrentsFilterInputTimer = setTimeout(() => {
1513 torrentsFilterInputTimer = -1;
1514 torrentsTable.updateTable();
1515 }, window.qBittorrent.Misc.FILTER_INPUT_DELAY);
1518 document.getElementById("torrentsFilterToolbar").addEventListener("change", (e) => { torrentsTable.updateTable(); });
1520 $("transfersTabLink").addEventListener("click", () => { showTransfersTab(); });
1521 $("searchTabLink").addEventListener("click", () => { showSearchTab(); });
1522 $("rssTabLink").addEventListener("click", () => { showRssTab(); });
1523 $("logTabLink").addEventListener("click", () => { showLogTab(); });
1524 updateTabDisplay();
1526 const registerDragAndDrop = () => {
1527 $("desktop").addEventListener("dragover", (ev) => {
1528 if (ev.preventDefault)
1529 ev.preventDefault();
1532 $("desktop").addEventListener("dragenter", (ev) => {
1533 if (ev.preventDefault)
1534 ev.preventDefault();
1537 $("desktop").addEventListener("drop", (ev) => {
1538 if (ev.preventDefault)
1539 ev.preventDefault();
1541 const droppedFiles = ev.dataTransfer.files;
1543 if (droppedFiles.length > 0) {
1544 // dropped files or folders
1546 // can't handle folder due to cannot put the filelist (from dropped folder)
1547 // to <input> `files` field
1548 for (const item of ev.dataTransfer.items) {
1549 if (item.webkitGetAsEntry().isDirectory)
1550 return;
1553 const id = "uploadPage";
1554 new MochaUI.Window({
1555 id: id,
1556 icon: "images/qbittorrent-tray.svg",
1557 title: "QBT_TR(Upload local torrent)QBT_TR[CONTEXT=HttpServer]",
1558 loadMethod: "iframe",
1559 contentURL: new URI("upload.html").toString(),
1560 addClass: "windowFrame", // fixes iframe scrolling on iOS Safari
1561 scrollbars: true,
1562 maximizable: false,
1563 paddingVertical: 0,
1564 paddingHorizontal: 0,
1565 width: loadWindowWidth(id, 500),
1566 height: loadWindowHeight(id, 460),
1567 onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
1568 saveWindowSize(id);
1570 onContentLoaded: () => {
1571 const fileInput = $(`${id}_iframe`).contentDocument.getElementById("fileselect");
1572 fileInput.files = droppedFiles;
1577 const droppedText = ev.dataTransfer.getData("text");
1578 if (droppedText.length > 0) {
1579 // dropped text
1581 const urls = droppedText.split("\n")
1582 .map((str) => str.trim())
1583 .filter((str) => {
1584 const lowercaseStr = str.toLowerCase();
1585 return lowercaseStr.startsWith("http:")
1586 || lowercaseStr.startsWith("https:")
1587 || lowercaseStr.startsWith("magnet:")
1588 || ((str.length === 40) && !(/[^0-9A-F]/i.test(str))) // v1 hex-encoded SHA-1 info-hash
1589 || ((str.length === 32) && !(/[^2-7A-Z]/i.test(str))); // v1 Base32 encoded SHA-1 info-hash
1592 if (urls.length <= 0)
1593 return;
1595 const id = "downloadPage";
1596 const contentURI = new URI("download.html").setData("urls", urls.map(encodeURIComponent).join("|"));
1597 new MochaUI.Window({
1598 id: id,
1599 icon: "images/qbittorrent-tray.svg",
1600 title: "QBT_TR(Download from URLs)QBT_TR[CONTEXT=downloadFromURL]",
1601 loadMethod: "iframe",
1602 contentURL: contentURI.toString(),
1603 addClass: "windowFrame", // fixes iframe scrolling on iOS Safari
1604 scrollbars: true,
1605 maximizable: false,
1606 closable: true,
1607 paddingVertical: 0,
1608 paddingHorizontal: 0,
1609 width: loadWindowWidth(id, 500),
1610 height: loadWindowHeight(id, 600),
1611 onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
1612 saveWindowSize(id);
1618 registerDragAndDrop();
1620 new Keyboard({
1621 defaultEventType: "keydown",
1622 events: {
1623 "ctrl+a": function(event) {
1624 if ((event.target.nodeName === "INPUT") || (event.target.nodeName === "TEXTAREA"))
1625 return;
1626 if (event.target.isContentEditable)
1627 return;
1628 torrentsTable.selectAll();
1629 event.preventDefault();
1631 "delete": function(event) {
1632 if ((event.target.nodeName === "INPUT") || (event.target.nodeName === "TEXTAREA"))
1633 return;
1634 if (event.target.isContentEditable)
1635 return;
1636 deleteFN();
1637 event.preventDefault();
1639 "shift+delete": (event) => {
1640 if ((event.target.nodeName === "INPUT") || (event.target.nodeName === "TEXTAREA"))
1641 return;
1642 if (event.target.isContentEditable)
1643 return;
1644 deleteFN(true);
1645 event.preventDefault();
1648 }).activate();
1651 window.addEventListener("load", () => {
1652 // fetch various data and store it in memory
1653 window.qBittorrent.Cache.buildInfo.init();
1654 window.qBittorrent.Cache.preferences.init();
1655 window.qBittorrent.Cache.qbtVersion.init();
1657 // switch to previously used tab
1658 const previouslyUsedTab = LocalPreferences.get("selected_window_tab", "transfers");
1659 switch (previouslyUsedTab) {
1660 case "search":
1661 if (window.qBittorrent.Client.isShowSearchEngine())
1662 $("searchTabLink").click();
1663 break;
1664 case "rss":
1665 if (window.qBittorrent.Client.isShowRssReader())
1666 $("rssTabLink").click();
1667 break;
1668 case "log":
1669 if (window.qBittorrent.Client.isShowLogViewer())
1670 $("logTabLink").click();
1671 break;
1672 case "transfers":
1673 $("transfersTabLink").click();
1674 break;
1675 default:
1676 console.error(`Unexpected 'selected_window_tab' value: ${previouslyUsedTab}`);
1677 $("transfersTabLink").click();
1678 break;