Enable customizing the save statistics time interval
[qBittorrent.git] / src / webui / www / private / scripts / client.js
blob9a8df230e3e6bed88cd39d12b435ac8e8de2f950
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>
6  *
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:
13  *
14  * The above copyright notice and this permission notice shall be included in
15  * all copies or substantial portions of the Software.
16  *
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.
24  */
26 "use strict";
28 window.qBittorrent ??= {};
29 window.qBittorrent.Client ??= (() => {
30     const exports = () => {
31         return {
32             closeWindow: closeWindow,
33             closeWindows: closeWindows,
34             getSyncMainDataInterval: getSyncMainDataInterval,
35             isStopped: isStopped,
36             stop: stop,
37             mainTitle: mainTitle,
38             showSearchEngine: showSearchEngine,
39             showRssReader: showRssReader,
40             showLogViewer: showLogViewer,
41             isShowSearchEngine: isShowSearchEngine,
42             isShowRssReader: isShowRssReader,
43             isShowLogViewer: isShowLogViewer
44         };
45     };
47     const closeWindow = function(windowID) {
48         const window = document.getElementById(windowID);
49         if (!window)
50             return;
51         MochaUI.closeWindow(window);
52     };
54     const closeWindows = function() {
55         MochaUI.closeAll();
56     };
58     const getSyncMainDataInterval = function() {
59         return customSyncMainDataInterval ? customSyncMainDataInterval : serverSyncMainDataInterval;
60     };
62     let stopped = false;
63     const isStopped = () => {
64         return stopped;
65     };
67     const stop = () => {
68         stopped = true;
69     };
71     const mainTitle = () => {
72         const emDash = "\u2014";
73         const qbtVersion = window.qBittorrent.Cache.qbtVersion.get();
74         const suffix = window.qBittorrent.Cache.preferences.get()["app_instance_name"] || "";
75         const title = `qBittorrent ${qbtVersion} QBT_TR(WebUI)QBT_TR[CONTEXT=OptionsDialog]`
76             + ((suffix.length > 0) ? ` ${emDash} ${suffix}` : "");
77         return title;
78     };
80     let showingSearchEngine = false;
81     let showingRssReader = false;
82     let showingLogViewer = false;
84     const showSearchEngine = function(bool) {
85         showingSearchEngine = bool;
86     };
87     const showRssReader = function(bool) {
88         showingRssReader = bool;
89     };
90     const showLogViewer = function(bool) {
91         showingLogViewer = bool;
92     };
93     const isShowSearchEngine = function() {
94         return showingSearchEngine;
95     };
96     const isShowRssReader = function() {
97         return showingRssReader;
98     };
99     const isShowLogViewer = function() {
100         return showingLogViewer;
101     };
103     return exports();
104 })();
105 Object.freeze(window.qBittorrent.Client);
107 // TODO: move global functions/variables into some namespace/scope
109 this.torrentsTable = new window.qBittorrent.DynamicTable.TorrentsTable();
111 let updatePropertiesPanel = function() {};
113 this.updateMainData = function() {};
114 let alternativeSpeedLimits = false;
115 let queueing_enabled = true;
116 let serverSyncMainDataInterval = 1500;
117 let customSyncMainDataInterval = null;
118 let useSubcategories = true;
119 const useAutoHideZeroStatusFilters = LocalPreferences.get("hide_zero_status_filters", "false") === "true";
120 const displayFullURLTrackerColumn = LocalPreferences.get("full_url_tracker_column", "false") === "true";
122 /* Categories filter */
123 const CATEGORIES_ALL = 1;
124 const CATEGORIES_UNCATEGORIZED = 2;
126 const category_list = new Map();
128 let selectedCategory = Number(LocalPreferences.get("selected_category", CATEGORIES_ALL));
129 let setCategoryFilter = function() {};
131 /* Tags filter */
132 const TAGS_ALL = 1;
133 const TAGS_UNTAGGED = 2;
135 const tagList = new Map();
137 let selectedTag = Number(LocalPreferences.get("selected_tag", TAGS_ALL));
138 let setTagFilter = function() {};
140 /* Trackers filter */
141 const TRACKERS_ALL = 1;
142 const TRACKERS_TRACKERLESS = 2;
144 /** @type Map<number, {host: string, trackerTorrentMap: Map<string, string[]>}> **/
145 const trackerList = new Map();
147 let selectedTracker = Number(LocalPreferences.get("selected_tracker", TRACKERS_ALL));
148 let setTrackerFilter = function() {};
150 /* All filters */
151 let selectedStatus = LocalPreferences.get("selected_filter", "all");
152 let setStatusFilter = function() {};
153 let toggleFilterDisplay = function() {};
155 window.addEventListener("DOMContentLoaded", () => {
156     let isSearchPanelLoaded = false;
157     let isLogPanelLoaded = false;
159     const saveColumnSizes = function() {
160         const filters_width = $("Filters").getSize().x;
161         LocalPreferences.set("filters_width", filters_width);
162         const properties_height_rel = $("propertiesPanel").getSize().y / Window.getSize().y;
163         LocalPreferences.set("properties_height_rel", properties_height_rel);
164     };
166     window.addEventListener("resize", window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
167         // only save sizes if the columns are visible
168         if (!$("mainColumn").hasClass("invisible"))
169             saveColumnSizes();
170     }));
172     /* MochaUI.Desktop = new MochaUI.Desktop();
173     MochaUI.Desktop.desktop.style.background = "#fff";
174     MochaUI.Desktop.desktop.style.visibility = "visible"; */
175     MochaUI.Desktop.initialize();
177     const buildTransfersTab = function() {
178         new MochaUI.Column({
179             id: "filtersColumn",
180             placement: "left",
181             onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
182                 saveColumnSizes();
183             }),
184             width: Number(LocalPreferences.get("filters_width", 210)),
185             resizeLimit: [1, 1000]
186         });
187         new MochaUI.Column({
188             id: "mainColumn",
189             placement: "main"
190         });
191     };
193     const buildSearchTab = function() {
194         new MochaUI.Column({
195             id: "searchTabColumn",
196             placement: "main",
197             width: null
198         });
200         // start off hidden
201         $("searchTabColumn").addClass("invisible");
202     };
204     const buildRssTab = function() {
205         new MochaUI.Column({
206             id: "rssTabColumn",
207             placement: "main",
208             width: null
209         });
211         // start off hidden
212         $("rssTabColumn").addClass("invisible");
213     };
215     const buildLogTab = function() {
216         new MochaUI.Column({
217             id: "logTabColumn",
218             placement: "main",
219             width: null
220         });
222         // start off hidden
223         $("logTabColumn").addClass("invisible");
224     };
226     buildTransfersTab();
227     buildSearchTab();
228     buildRssTab();
229     buildLogTab();
230     MochaUI.initializeTabs("mainWindowTabsList");
232     setStatusFilter = function(name) {
233         LocalPreferences.set("selected_filter", name);
234         selectedStatus = name;
235         highlightSelectedStatus();
236         updateMainData();
237     };
239     setCategoryFilter = function(hash) {
240         LocalPreferences.set("selected_category", hash);
241         selectedCategory = Number(hash);
242         highlightSelectedCategory();
243         updateMainData();
244     };
246     setTagFilter = function(hash) {
247         LocalPreferences.set("selected_tag", hash);
248         selectedTag = Number(hash);
249         highlightSelectedTag();
250         updateMainData();
251     };
253     setTrackerFilter = function(hash) {
254         LocalPreferences.set("selected_tracker", hash);
255         selectedTracker = Number(hash);
256         highlightSelectedTracker();
257         updateMainData();
258     };
260     toggleFilterDisplay = function(filterListID) {
261         const filterList = document.getElementById(filterListID);
262         const filterTitle = filterList.previousElementSibling;
263         const toggleIcon = filterTitle.firstElementChild;
264         toggleIcon.classList.toggle("rotate");
265         LocalPreferences.set(`filter_${filterListID.replace("FilterList", "")}_collapsed`, filterList.classList.toggle("invisible").toString());
266     };
268     new MochaUI.Panel({
269         id: "Filters",
270         title: "Panel",
271         header: false,
272         padding: {
273             top: 0,
274             right: 0,
275             bottom: 0,
276             left: 0
277         },
278         loadMethod: "xhr",
279         contentURL: "views/filters.html",
280         onContentLoaded: function() {
281             highlightSelectedStatus();
282         },
283         column: "filtersColumn",
284         height: 300
285     });
286     initializeWindows();
288     // Show Top Toolbar is enabled by default
289     let showTopToolbar = LocalPreferences.get("show_top_toolbar", "true") === "true";
290     if (!showTopToolbar) {
291         $("showTopToolbarLink").firstChild.style.opacity = "0";
292         $("mochaToolbar").addClass("invisible");
293     }
295     // Show Status Bar is enabled by default
296     let showStatusBar = LocalPreferences.get("show_status_bar", "true") === "true";
297     if (!showStatusBar) {
298         $("showStatusBarLink").firstChild.style.opacity = "0";
299         $("desktopFooterWrapper").addClass("invisible");
300     }
302     // Show Filters Sidebar is enabled by default
303     let showFiltersSidebar = LocalPreferences.get("show_filters_sidebar", "true") === "true";
304     if (!showFiltersSidebar) {
305         $("showFiltersSidebarLink").firstChild.style.opacity = "0";
306         $("filtersColumn").addClass("invisible");
307         $("filtersColumn_handle").addClass("invisible");
308     }
310     let speedInTitle = LocalPreferences.get("speed_in_browser_title_bar") === "true";
311     if (!speedInTitle)
312         $("speedInBrowserTitleBarLink").firstChild.style.opacity = "0";
314     // After showing/hiding the toolbar + status bar
315     window.qBittorrent.Client.showSearchEngine(LocalPreferences.get("show_search_engine") !== "false");
316     window.qBittorrent.Client.showRssReader(LocalPreferences.get("show_rss_reader") !== "false");
317     window.qBittorrent.Client.showLogViewer(LocalPreferences.get("show_log_viewer") === "true");
319     // After Show Top Toolbar
320     MochaUI.Desktop.setDesktopSize();
322     let syncMainDataLastResponseId = 0;
323     const serverState = {};
325     const removeTorrentFromCategoryList = function(hash) {
326         if (!hash)
327             return false;
329         let removed = false;
330         category_list.forEach((category) => {
331             const deleteResult = category.torrents.delete(hash);
332             removed ||= deleteResult;
333         });
335         return removed;
336     };
338     const addTorrentToCategoryList = function(torrent) {
339         const category = torrent["category"];
340         if (typeof category === "undefined")
341             return false;
343         const hash = torrent["hash"];
344         if (category.length === 0) { // Empty category
345             removeTorrentFromCategoryList(hash);
346             return true;
347         }
349         const categoryHash = window.qBittorrent.Misc.genHash(category);
350         if (!category_list.has(categoryHash)) { // This should not happen
351             category_list.set(categoryHash, {
352                 name: category,
353                 torrents: new Set()
354             });
355         }
357         const torrents = category_list.get(categoryHash).torrents;
358         if (!torrents.has(hash)) {
359             removeTorrentFromCategoryList(hash);
360             torrents.add(hash);
361             return true;
362         }
363         return false;
364     };
366     const removeTorrentFromTagList = function(hash) {
367         if (!hash)
368             return false;
370         let removed = false;
371         tagList.forEach((tag) => {
372             const deleteResult = tag.torrents.delete(hash);
373             removed ||= deleteResult;
374         });
376         return removed;
377     };
379     const addTorrentToTagList = function(torrent) {
380         if (torrent["tags"] === undefined) // Tags haven't changed
381             return false;
383         const hash = torrent["hash"];
384         removeTorrentFromTagList(hash);
386         if (torrent["tags"].length === 0) // No tags
387             return true;
389         const tags = torrent["tags"].split(",");
390         let added = false;
391         for (let i = 0; i < tags.length; ++i) {
392             const tagHash = window.qBittorrent.Misc.genHash(tags[i].trim());
393             if (!tagList.has(tagHash)) { // This should not happen
394                 tagList.set(tagHash, {
395                     name: tags,
396                     torrents: new Set()
397                 });
398             }
400             const torrents = tagList.get(tagHash).torrents;
401             if (!torrents.has(hash)) {
402                 torrents.add(hash);
403                 added = true;
404             }
405         }
406         return added;
407     };
409     const updateFilter = function(filter, filterTitle) {
410         const filterEl = document.getElementById(`${filter}_filter`);
411         const filterTorrentCount = torrentsTable.getFilteredTorrentsNumber(filter, CATEGORIES_ALL, TAGS_ALL, TRACKERS_ALL);
412         if (useAutoHideZeroStatusFilters) {
413             const hideFilter = (filterTorrentCount === 0) && (filter !== "all");
414             if (filterEl.classList.toggle("invisible", hideFilter))
415                 return;
416         }
417         filterEl.firstElementChild.lastChild.nodeValue = filterTitle.replace("%1", filterTorrentCount);
418     };
420     const updateFiltersList = function() {
421         updateFilter("all", "QBT_TR(All (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
422         updateFilter("downloading", "QBT_TR(Downloading (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
423         updateFilter("seeding", "QBT_TR(Seeding (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
424         updateFilter("completed", "QBT_TR(Completed (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
425         updateFilter("running", "QBT_TR(Running (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
426         updateFilter("stopped", "QBT_TR(Stopped (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
427         updateFilter("active", "QBT_TR(Active (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
428         updateFilter("inactive", "QBT_TR(Inactive (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
429         updateFilter("stalled", "QBT_TR(Stalled (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
430         updateFilter("stalled_uploading", "QBT_TR(Stalled Uploading (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
431         updateFilter("stalled_downloading", "QBT_TR(Stalled Downloading (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
432         updateFilter("checking", "QBT_TR(Checking (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
433         updateFilter("moving", "QBT_TR(Moving (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
434         updateFilter("errored", "QBT_TR(Errored (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
435     };
437     const highlightSelectedStatus = function() {
438         const statusFilter = document.getElementById("statusFilterList");
439         const filterID = `${selectedStatus}_filter`;
440         for (const status of statusFilter.children)
441             status.classList.toggle("selectedFilter", (status.id === filterID));
442     };
444     const updateCategoryList = function() {
445         const categoryList = document.getElementById("categoryFilterList");
446         if (!categoryList)
447             return;
448         categoryList.getChildren().each(c => c.destroy());
450         const categoryItemTemplate = document.getElementById("categoryFilterItem");
452         const createCategoryLink = (hash, name, count) => {
453             const categoryFilterItem = categoryItemTemplate.content.cloneNode(true).firstElementChild;
454             categoryFilterItem.id = hash;
455             categoryFilterItem.classList.toggle("selectedFilter", hash === selectedCategory);
457             const span = categoryFilterItem.firstElementChild;
458             span.lastElementChild.textContent = `${name} (${count})`;
460             return categoryFilterItem;
461         };
463         const createCategoryTree = (category) => {
464             const stack = [{ parent: categoriesFragment, category: category }];
465             while (stack.length > 0) {
466                 const { parent, category } = stack.pop();
467                 const displayName = category.nameSegments.at(-1);
468                 const listItem = createCategoryLink(category.categoryHash, displayName, category.categoryCount);
469                 listItem.firstElementChild.style.paddingLeft = `${(category.nameSegments.length - 1) * 20 + 6}px`;
471                 parent.appendChild(listItem);
473                 if (category.children.length > 0) {
474                     listItem.querySelector(".categoryToggle").style.visibility = "visible";
475                     const unorderedList = document.createElement("ul");
476                     listItem.appendChild(unorderedList);
477                     for (const subcategory of category.children.reverse())
478                         stack.push({ parent: unorderedList, category: subcategory });
479                 }
480                 const categoryLocalPref = `category_${category.categoryHash}_collapsed`;
481                 const isCollapsed = !category.forceExpand && (LocalPreferences.get(categoryLocalPref, "false") === "true");
482                 LocalPreferences.set(categoryLocalPref, listItem.classList.toggle("collapsedCategory", isCollapsed).toString());
483             }
484         };
486         const all = torrentsTable.getRowIds().length;
487         let uncategorized = 0;
488         for (const key in torrentsTable.rows) {
489             if (!Object.hasOwn(torrentsTable.rows, key))
490                 continue;
492             const row = torrentsTable.rows[key];
493             if (row["full_data"].category.length === 0)
494                 uncategorized += 1;
495         }
497         const sortedCategories = [];
498         category_list.forEach((category, hash) => sortedCategories.push({
499             categoryName: category.name,
500             categoryHash: hash,
501             categoryCount: category.torrents.size,
502             nameSegments: category.name.split("/"),
503             ...(useSubcategories && {
504                 children: [],
505                 parentID: null,
506                 forceExpand: LocalPreferences.get(`category_${hash}_collapsed`) === null
507             })
508         }));
509         sortedCategories.sort((left, right) => {
510             const leftSegments = left.nameSegments;
511             const rightSegments = right.nameSegments;
513             for (let i = 0, iMax = Math.min(leftSegments.length, rightSegments.length); i < iMax; ++i) {
514                 const compareResult = window.qBittorrent.Misc.naturalSortCollator.compare(
515                     leftSegments[i], rightSegments[i]);
516                 if (compareResult !== 0)
517                     return compareResult;
518             }
520             return leftSegments.length - rightSegments.length;
521         });
523         const categoriesFragment = new DocumentFragment();
524         categoriesFragment.appendChild(createCategoryLink(CATEGORIES_ALL, "QBT_TR(All)QBT_TR[CONTEXT=CategoryFilterModel]", all));
525         categoriesFragment.appendChild(createCategoryLink(CATEGORIES_UNCATEGORIZED, "QBT_TR(Uncategorized)QBT_TR[CONTEXT=CategoryFilterModel]", uncategorized));
527         if (useSubcategories) {
528             categoryList.classList.add("subcategories");
529             for (let i = 0; i < sortedCategories.length; ++i) {
530                 const category = sortedCategories[i];
531                 for (let j = (i + 1);
532                     ((j < sortedCategories.length) && sortedCategories[j].categoryName.startsWith(`${category.categoryName}/`)); ++j) {
533                     const subcategory = sortedCategories[j];
534                     category.categoryCount += subcategory.categoryCount;
535                     category.forceExpand ||= subcategory.forceExpand;
537                     const isDirectSubcategory = (subcategory.nameSegments.length - category.nameSegments.length) === 1;
538                     if (isDirectSubcategory) {
539                         subcategory.parentID = category.categoryHash;
540                         category.children.push(subcategory);
541                     }
542                 }
543             }
544             for (const category of sortedCategories) {
545                 if (category.parentID === null)
546                     createCategoryTree(category);
547             }
548         }
549         else {
550             categoryList.classList.remove("subcategories");
551             for (const { categoryHash, categoryName, categoryCount } of sortedCategories)
552                 categoriesFragment.appendChild(createCategoryLink(categoryHash, categoryName, categoryCount));
553         }
555         categoryList.appendChild(categoriesFragment);
556         window.qBittorrent.Filters.categoriesFilterContextMenu.searchAndAddTargets();
557     };
559     const highlightSelectedCategory = function() {
560         const categoryList = document.getElementById("categoryFilterList");
561         if (!categoryList)
562             return;
564         for (const category of categoryList.getElementsByTagName("li"))
565             category.classList.toggle("selectedFilter", (Number(category.id) === selectedCategory));
566     };
568     const updateTagList = function() {
569         const tagFilterList = $("tagFilterList");
570         if (tagFilterList === null)
571             return;
573         tagFilterList.getChildren().each(c => c.destroy());
575         const tagItemTemplate = document.getElementById("tagFilterItem");
577         const createLink = function(hash, text, count) {
578             const tagFilterItem = tagItemTemplate.content.cloneNode(true).firstElementChild;
579             tagFilterItem.id = hash;
580             tagFilterItem.classList.toggle("selectedFilter", hash === selectedTag);
582             const span = tagFilterItem.firstElementChild;
583             span.lastChild.textContent = `${text} (${count})`;
585             return tagFilterItem;
586         };
588         const torrentsCount = torrentsTable.getRowIds().length;
589         let untagged = 0;
590         for (const key in torrentsTable.rows) {
591             if (Object.hasOwn(torrentsTable.rows, key) && (torrentsTable.rows[key]["full_data"].tags.length === 0))
592                 untagged += 1;
593         }
594         tagFilterList.appendChild(createLink(TAGS_ALL, "QBT_TR(All)QBT_TR[CONTEXT=TagFilterModel]", torrentsCount));
595         tagFilterList.appendChild(createLink(TAGS_UNTAGGED, "QBT_TR(Untagged)QBT_TR[CONTEXT=TagFilterModel]", untagged));
597         const sortedTags = [];
598         tagList.forEach((tag, hash) => sortedTags.push({
599             tagName: tag.name,
600             tagHash: hash,
601             tagSize: tag.torrents.size
602         }));
603         sortedTags.sort((left, right) => window.qBittorrent.Misc.naturalSortCollator.compare(left.tagName, right.tagName));
605         for (const { tagName, tagHash, tagSize } of sortedTags)
606             tagFilterList.appendChild(createLink(tagHash, tagName, tagSize));
608         window.qBittorrent.Filters.tagsFilterContextMenu.searchAndAddTargets();
609     };
611     const highlightSelectedTag = function() {
612         const tagFilterList = document.getElementById("tagFilterList");
613         if (!tagFilterList)
614             return;
616         for (const tag of tagFilterList.children)
617             tag.classList.toggle("selectedFilter", (Number(tag.id) === selectedTag));
618     };
620     const updateTrackerList = function() {
621         const trackerFilterList = $("trackerFilterList");
622         if (trackerFilterList === null)
623             return;
625         trackerFilterList.getChildren().each(c => c.destroy());
627         const trackerItemTemplate = document.getElementById("trackerFilterItem");
629         const createLink = function(hash, text, count) {
630             const trackerFilterItem = trackerItemTemplate.content.cloneNode(true).firstElementChild;
631             trackerFilterItem.id = hash;
632             trackerFilterItem.classList.toggle("selectedFilter", hash === selectedTracker);
634             const span = trackerFilterItem.firstElementChild;
635             span.lastChild.textContent = text.replace("%1", count);
637             return trackerFilterItem;
638         };
640         const torrentsCount = torrentsTable.getRowIds().length;
641         trackerFilterList.appendChild(createLink(TRACKERS_ALL, "QBT_TR(All (%1))QBT_TR[CONTEXT=TrackerFiltersList]", torrentsCount));
642         let trackerlessTorrentsCount = 0;
643         for (const key in torrentsTable.rows) {
644             if (Object.hasOwn(torrentsTable.rows, key) && (torrentsTable.rows[key]["full_data"].trackers_count === 0))
645                 trackerlessTorrentsCount += 1;
646         }
647         trackerFilterList.appendChild(createLink(TRACKERS_TRACKERLESS, "QBT_TR(Trackerless (%1))QBT_TR[CONTEXT=TrackerFiltersList]", trackerlessTorrentsCount));
649         // Sort trackers by hostname
650         const sortedList = [];
651         trackerList.forEach(({ host, trackerTorrentMap }, hash) => {
652             const uniqueTorrents = new Set();
653             for (const torrents of trackerTorrentMap.values()) {
654                 for (const torrent of torrents)
655                     uniqueTorrents.add(torrent);
656             }
658             sortedList.push({
659                 trackerHost: host,
660                 trackerHash: hash,
661                 trackerCount: uniqueTorrents.size,
662             });
663         });
664         sortedList.sort((left, right) => window.qBittorrent.Misc.naturalSortCollator.compare(left.trackerHost, right.trackerHost));
665         for (const { trackerHost, trackerHash, trackerCount } of sortedList)
666             trackerFilterList.appendChild(createLink(trackerHash, (trackerHost + " (%1)"), trackerCount));
668         window.qBittorrent.Filters.trackersFilterContextMenu.searchAndAddTargets();
669     };
671     const highlightSelectedTracker = function() {
672         const trackerFilterList = document.getElementById("trackerFilterList");
673         if (!trackerFilterList)
674             return;
676         for (const tracker of trackerFilterList.children)
677             tracker.classList.toggle("selectedFilter", (Number(tracker.id) === selectedTracker));
678     };
680     const setupCopyEventHandler = (function() {
681         let clipboardEvent;
683         return () => {
684             if (clipboardEvent)
685                 clipboardEvent.destroy();
687             clipboardEvent = new ClipboardJS(".copyToClipboard", {
688                 text: function(trigger) {
689                     switch (trigger.id) {
690                         case "copyName":
691                             return copyNameFN();
692                         case "copyInfohash1":
693                             return copyInfohashFN(1);
694                         case "copyInfohash2":
695                             return copyInfohashFN(2);
696                         case "copyMagnetLink":
697                             return copyMagnetLinkFN();
698                         case "copyID":
699                             return copyIdFN();
700                         case "copyComment":
701                             return copyCommentFN();
702                         default:
703                             return "";
704                     }
705                 }
706             });
707         };
708     })();
710     let syncMainDataTimeoutID = -1;
711     let syncRequestInProgress = false;
712     const syncMainData = function() {
713         const url = new URI("api/v2/sync/maindata");
714         url.setData("rid", syncMainDataLastResponseId);
715         const request = new Request.JSON({
716             url: url,
717             noCache: true,
718             method: "get",
719             onFailure: function() {
720                 const errorDiv = $("error_div");
721                 if (errorDiv)
722                     errorDiv.textContent = "QBT_TR(qBittorrent client is not reachable)QBT_TR[CONTEXT=HttpServer]";
723                 syncRequestInProgress = false;
724                 syncData(2000);
725             },
726             onSuccess: function(response) {
727                 $("error_div").textContent = "";
728                 if (response) {
729                     clearTimeout(torrentsFilterInputTimer);
730                     torrentsFilterInputTimer = -1;
732                     let torrentsTableSelectedRows;
733                     let update_categories = false;
734                     let updateTags = false;
735                     let updateTrackers = false;
736                     const full_update = (response["full_update"] === true);
737                     if (full_update) {
738                         torrentsTableSelectedRows = torrentsTable.selectedRowsIds();
739                         update_categories = true;
740                         updateTags = true;
741                         updateTrackers = true;
742                         torrentsTable.clear();
743                         category_list.clear();
744                         tagList.clear();
745                         trackerList.clear();
746                     }
747                     if (response["rid"])
748                         syncMainDataLastResponseId = response["rid"];
749                     if (response["categories"]) {
750                         for (const key in response["categories"]) {
751                             if (!Object.hasOwn(response["categories"], key))
752                                 continue;
754                             const responseCategory = response["categories"][key];
755                             const categoryHash = window.qBittorrent.Misc.genHash(key);
756                             const category = category_list.get(categoryHash);
757                             if (category !== undefined) {
758                                 // only the save path can change for existing categories
759                                 category.savePath = responseCategory.savePath;
760                             }
761                             else {
762                                 category_list.set(categoryHash, {
763                                     name: responseCategory.name,
764                                     savePath: responseCategory.savePath,
765                                     torrents: new Set()
766                                 });
767                             }
768                         }
769                         update_categories = true;
770                     }
771                     if (response["categories_removed"]) {
772                         response["categories_removed"].each((category) => {
773                             const categoryHash = window.qBittorrent.Misc.genHash(category);
774                             category_list.delete(categoryHash);
775                         });
776                         update_categories = true;
777                     }
778                     if (response["tags"]) {
779                         for (const tag of response["tags"]) {
780                             const tagHash = window.qBittorrent.Misc.genHash(tag);
781                             if (!tagList.has(tagHash)) {
782                                 tagList.set(tagHash, {
783                                     name: tag,
784                                     torrents: new Set()
785                                 });
786                             }
787                         }
788                         updateTags = true;
789                     }
790                     if (response["tags_removed"]) {
791                         for (let i = 0; i < response["tags_removed"].length; ++i) {
792                             const tagHash = window.qBittorrent.Misc.genHash(response["tags_removed"][i]);
793                             tagList.delete(tagHash);
794                         }
795                         updateTags = true;
796                     }
797                     if (response["trackers"]) {
798                         for (const [tracker, torrents] of Object.entries(response["trackers"])) {
799                             const host = window.qBittorrent.Misc.getHost(tracker);
800                             const hash = window.qBittorrent.Misc.genHash(host);
802                             let trackerListItem = trackerList.get(hash);
803                             if (trackerListItem === undefined) {
804                                 trackerListItem = { host: host, trackerTorrentMap: new Map() };
805                                 trackerList.set(hash, trackerListItem);
806                             }
808                             trackerListItem.trackerTorrentMap.set(tracker, [...torrents]);
809                         }
810                         updateTrackers = true;
811                     }
812                     if (response["trackers_removed"]) {
813                         for (let i = 0; i < response["trackers_removed"].length; ++i) {
814                             const tracker = response["trackers_removed"][i];
815                             const host = window.qBittorrent.Misc.getHost(tracker);
816                             const hash = window.qBittorrent.Misc.genHash(host);
817                             const trackerListEntry = trackerList.get(hash);
818                             if (trackerListEntry)
819                                 trackerListEntry.trackerTorrentMap.delete(tracker);
820                         }
821                         updateTrackers = true;
822                     }
823                     if (response["torrents"]) {
824                         let updateTorrentList = false;
825                         for (const key in response["torrents"]) {
826                             if (!Object.hasOwn(response["torrents"], key))
827                                 continue;
829                             response["torrents"][key]["hash"] = key;
830                             response["torrents"][key]["rowId"] = key;
831                             if (response["torrents"][key]["state"])
832                                 response["torrents"][key]["status"] = response["torrents"][key]["state"];
833                             torrentsTable.updateRowData(response["torrents"][key]);
834                             if (addTorrentToCategoryList(response["torrents"][key]))
835                                 update_categories = true;
836                             if (addTorrentToTagList(response["torrents"][key]))
837                                 updateTags = true;
838                             if (response["torrents"][key]["name"])
839                                 updateTorrentList = true;
840                         }
842                         if (updateTorrentList)
843                             setupCopyEventHandler();
844                     }
845                     if (response["torrents_removed"]) {
846                         response["torrents_removed"].each((hash) => {
847                             torrentsTable.removeRow(hash);
848                             removeTorrentFromCategoryList(hash);
849                             update_categories = true; // Always to update All category
850                             removeTorrentFromTagList(hash);
851                             updateTags = true; // Always to update All tag
852                         });
853                     }
854                     torrentsTable.updateTable(full_update);
855                     if (response["server_state"]) {
856                         const tmp = response["server_state"];
857                         for (const k in tmp) {
858                             if (!Object.hasOwn(tmp, k))
859                                 continue;
860                             serverState[k] = tmp[k];
861                         }
862                         processServerState();
863                     }
864                     updateFiltersList();
865                     if (update_categories) {
866                         updateCategoryList();
867                         window.qBittorrent.TransferList.contextMenu.updateCategoriesSubMenu(category_list);
868                     }
869                     if (updateTags) {
870                         updateTagList();
871                         window.qBittorrent.TransferList.contextMenu.updateTagsSubMenu(tagList);
872                     }
873                     if (updateTrackers)
874                         updateTrackerList();
876                     if (full_update)
877                         // re-select previously selected rows
878                         torrentsTable.reselectRows(torrentsTableSelectedRows);
879                 }
880                 syncRequestInProgress = false;
881                 syncData(window.qBittorrent.Client.getSyncMainDataInterval());
882             }
883         });
884         syncRequestInProgress = true;
885         request.send();
886     };
888     updateMainData = function() {
889         torrentsTable.updateTable();
890         syncData(100);
891     };
893     const syncData = function(delay) {
894         if (syncRequestInProgress)
895             return;
897         clearTimeout(syncMainDataTimeoutID);
898         syncMainDataTimeoutID = -1;
900         if (window.qBittorrent.Client.isStopped())
901             return;
903         syncMainDataTimeoutID = syncMainData.delay(delay);
904     };
906     const processServerState = function() {
907         let transfer_info = window.qBittorrent.Misc.friendlyUnit(serverState.dl_info_speed, true);
908         if (serverState.dl_rate_limit > 0)
909             transfer_info += " [" + window.qBittorrent.Misc.friendlyUnit(serverState.dl_rate_limit, true) + "]";
910         transfer_info += " (" + window.qBittorrent.Misc.friendlyUnit(serverState.dl_info_data, false) + ")";
911         $("DlInfos").textContent = transfer_info;
912         transfer_info = window.qBittorrent.Misc.friendlyUnit(serverState.up_info_speed, true);
913         if (serverState.up_rate_limit > 0)
914             transfer_info += " [" + window.qBittorrent.Misc.friendlyUnit(serverState.up_rate_limit, true) + "]";
915         transfer_info += " (" + window.qBittorrent.Misc.friendlyUnit(serverState.up_info_data, false) + ")";
916         $("UpInfos").textContent = transfer_info;
918         document.title = (speedInTitle
919                 ? (`QBT_TR([D: %1, U: %2])QBT_TR[CONTEXT=MainWindow] `
920                     .replace("%1", window.qBittorrent.Misc.friendlyUnit(serverState.dl_info_speed, true))
921                     .replace("%2", window.qBittorrent.Misc.friendlyUnit(serverState.up_info_speed, true)))
922                 : "")
923             + window.qBittorrent.Client.mainTitle();
925         $("freeSpaceOnDisk").textContent = "QBT_TR(Free space: %1)QBT_TR[CONTEXT=HttpServer]".replace("%1", window.qBittorrent.Misc.friendlyUnit(serverState.free_space_on_disk));
926         $("DHTNodes").textContent = "QBT_TR(DHT: %1 nodes)QBT_TR[CONTEXT=StatusBar]".replace("%1", serverState.dht_nodes);
928         // Statistics dialog
929         if (document.getElementById("statisticsContent")) {
930             $("AlltimeDL").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.alltime_dl, false);
931             $("AlltimeUL").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.alltime_ul, false);
932             $("TotalWastedSession").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.total_wasted_session, false);
933             $("GlobalRatio").textContent = serverState.global_ratio;
934             $("TotalPeerConnections").textContent = serverState.total_peer_connections;
935             $("ReadCacheHits").textContent = serverState.read_cache_hits + "%";
936             $("TotalBuffersSize").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.total_buffers_size, false);
937             $("WriteCacheOverload").textContent = serverState.write_cache_overload + "%";
938             $("ReadCacheOverload").textContent = serverState.read_cache_overload + "%";
939             $("QueuedIOJobs").textContent = serverState.queued_io_jobs;
940             $("AverageTimeInQueue").textContent = serverState.average_time_queue + " ms";
941             $("TotalQueuedSize").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.total_queued_size, false);
942         }
944         switch (serverState.connection_status) {
945             case "connected":
946                 $("connectionStatus").src = "images/connected.svg";
947                 $("connectionStatus").alt = "QBT_TR(Connection status: Connected)QBT_TR[CONTEXT=MainWindow]";
948                 $("connectionStatus").title = "QBT_TR(Connection status: Connected)QBT_TR[CONTEXT=MainWindow]";
949                 break;
950             case "firewalled":
951                 $("connectionStatus").src = "images/firewalled.svg";
952                 $("connectionStatus").alt = "QBT_TR(Connection status: Firewalled)QBT_TR[CONTEXT=MainWindow]";
953                 $("connectionStatus").title = "QBT_TR(Connection status: Firewalled)QBT_TR[CONTEXT=MainWindow]";
954                 break;
955             default:
956                 $("connectionStatus").src = "images/disconnected.svg";
957                 $("connectionStatus").alt = "QBT_TR(Connection status: Disconnected)QBT_TR[CONTEXT=MainWindow]";
958                 $("connectionStatus").title = "QBT_TR(Connection status: Disconnected)QBT_TR[CONTEXT=MainWindow]";
959                 break;
960         }
962         if (queueing_enabled !== serverState.queueing) {
963             queueing_enabled = serverState.queueing;
964             torrentsTable.columns["priority"].force_hide = !queueing_enabled;
965             torrentsTable.updateColumn("priority");
966             if (queueing_enabled) {
967                 $("topQueuePosItem").removeClass("invisible");
968                 $("increaseQueuePosItem").removeClass("invisible");
969                 $("decreaseQueuePosItem").removeClass("invisible");
970                 $("bottomQueuePosItem").removeClass("invisible");
971                 $("queueingButtons").removeClass("invisible");
972                 $("queueingMenuItems").removeClass("invisible");
973             }
974             else {
975                 $("topQueuePosItem").addClass("invisible");
976                 $("increaseQueuePosItem").addClass("invisible");
977                 $("decreaseQueuePosItem").addClass("invisible");
978                 $("bottomQueuePosItem").addClass("invisible");
979                 $("queueingButtons").addClass("invisible");
980                 $("queueingMenuItems").addClass("invisible");
981             }
982         }
984         if (alternativeSpeedLimits !== serverState.use_alt_speed_limits) {
985             alternativeSpeedLimits = serverState.use_alt_speed_limits;
986             updateAltSpeedIcon(alternativeSpeedLimits);
987         }
989         if (useSubcategories !== serverState.use_subcategories) {
990             useSubcategories = serverState.use_subcategories;
991             updateCategoryList();
992         }
994         serverSyncMainDataInterval = Math.max(serverState.refresh_interval, 500);
995     };
997     const updateAltSpeedIcon = function(enabled) {
998         if (enabled) {
999             $("alternativeSpeedLimits").src = "images/slow.svg";
1000             $("alternativeSpeedLimits").alt = "QBT_TR(Alternative speed limits: On)QBT_TR[CONTEXT=MainWindow]";
1001             $("alternativeSpeedLimits").title = "QBT_TR(Alternative speed limits: On)QBT_TR[CONTEXT=MainWindow]";
1002         }
1003         else {
1004             $("alternativeSpeedLimits").src = "images/slow_off.svg";
1005             $("alternativeSpeedLimits").alt = "QBT_TR(Alternative speed limits: Off)QBT_TR[CONTEXT=MainWindow]";
1006             $("alternativeSpeedLimits").title = "QBT_TR(Alternative speed limits: Off)QBT_TR[CONTEXT=MainWindow]";
1007         }
1008     };
1010     $("alternativeSpeedLimits").addEventListener("click", () => {
1011         // Change icon immediately to give some feedback
1012         updateAltSpeedIcon(!alternativeSpeedLimits);
1014         new Request({
1015             url: "api/v2/transfer/toggleSpeedLimitsMode",
1016             method: "post",
1017             onComplete: function() {
1018                 alternativeSpeedLimits = !alternativeSpeedLimits;
1019                 updateMainData();
1020             },
1021             onFailure: function() {
1022                 // Restore icon in case of failure
1023                 updateAltSpeedIcon(alternativeSpeedLimits);
1024             }
1025         }).send();
1026     });
1028     $("DlInfos").addEventListener("click", () => { globalDownloadLimitFN(); });
1029     $("UpInfos").addEventListener("click", () => { globalUploadLimitFN(); });
1031     $("showTopToolbarLink").addEventListener("click", (e) => {
1032         showTopToolbar = !showTopToolbar;
1033         LocalPreferences.set("show_top_toolbar", showTopToolbar.toString());
1034         if (showTopToolbar) {
1035             $("showTopToolbarLink").firstChild.style.opacity = "1";
1036             $("mochaToolbar").removeClass("invisible");
1037         }
1038         else {
1039             $("showTopToolbarLink").firstChild.style.opacity = "0";
1040             $("mochaToolbar").addClass("invisible");
1041         }
1042         MochaUI.Desktop.setDesktopSize();
1043     });
1045     $("showStatusBarLink").addEventListener("click", (e) => {
1046         showStatusBar = !showStatusBar;
1047         LocalPreferences.set("show_status_bar", showStatusBar.toString());
1048         if (showStatusBar) {
1049             $("showStatusBarLink").firstChild.style.opacity = "1";
1050             $("desktopFooterWrapper").removeClass("invisible");
1051         }
1052         else {
1053             $("showStatusBarLink").firstChild.style.opacity = "0";
1054             $("desktopFooterWrapper").addClass("invisible");
1055         }
1056         MochaUI.Desktop.setDesktopSize();
1057     });
1059     const registerMagnetHandler = function() {
1060         if (typeof navigator.registerProtocolHandler !== "function") {
1061             if (window.location.protocol !== "https:")
1062                 alert("QBT_TR(To use this feature, the WebUI needs to be accessed over HTTPS)QBT_TR[CONTEXT=MainWindow]");
1063             else
1064                 alert("QBT_TR(Your browser does not support this feature)QBT_TR[CONTEXT=MainWindow]");
1065             return;
1066         }
1068         const hashString = location.hash ? location.hash.replace(/^#/, "") : "";
1069         const hashParams = new URLSearchParams(hashString);
1070         hashParams.set("download", "");
1072         const templateHashString = hashParams.toString().replace("download=", "download=%s");
1073         const templateUrl = location.origin + location.pathname
1074             + location.search + "#" + templateHashString;
1076         navigator.registerProtocolHandler("magnet", templateUrl,
1077             "qBittorrent WebUI magnet handler");
1078     };
1079     $("registerMagnetHandlerLink").addEventListener("click", (e) => {
1080         registerMagnetHandler();
1081     });
1083     $("showFiltersSidebarLink").addEventListener("click", (e) => {
1084         showFiltersSidebar = !showFiltersSidebar;
1085         LocalPreferences.set("show_filters_sidebar", showFiltersSidebar.toString());
1086         if (showFiltersSidebar) {
1087             $("showFiltersSidebarLink").firstChild.style.opacity = "1";
1088             $("filtersColumn").removeClass("invisible");
1089             $("filtersColumn_handle").removeClass("invisible");
1090         }
1091         else {
1092             $("showFiltersSidebarLink").firstChild.style.opacity = "0";
1093             $("filtersColumn").addClass("invisible");
1094             $("filtersColumn_handle").addClass("invisible");
1095         }
1096         MochaUI.Desktop.setDesktopSize();
1097     });
1099     $("speedInBrowserTitleBarLink").addEventListener("click", (e) => {
1100         speedInTitle = !speedInTitle;
1101         LocalPreferences.set("speed_in_browser_title_bar", speedInTitle.toString());
1102         if (speedInTitle)
1103             $("speedInBrowserTitleBarLink").firstChild.style.opacity = "1";
1104         else
1105             $("speedInBrowserTitleBarLink").firstChild.style.opacity = "0";
1106         processServerState();
1107     });
1109     $("showSearchEngineLink").addEventListener("click", (e) => {
1110         window.qBittorrent.Client.showSearchEngine(!window.qBittorrent.Client.isShowSearchEngine());
1111         LocalPreferences.set("show_search_engine", window.qBittorrent.Client.isShowSearchEngine().toString());
1112         updateTabDisplay();
1113     });
1115     $("showRssReaderLink").addEventListener("click", (e) => {
1116         window.qBittorrent.Client.showRssReader(!window.qBittorrent.Client.isShowRssReader());
1117         LocalPreferences.set("show_rss_reader", window.qBittorrent.Client.isShowRssReader().toString());
1118         updateTabDisplay();
1119     });
1121     $("showLogViewerLink").addEventListener("click", (e) => {
1122         window.qBittorrent.Client.showLogViewer(!window.qBittorrent.Client.isShowLogViewer());
1123         LocalPreferences.set("show_log_viewer", window.qBittorrent.Client.isShowLogViewer().toString());
1124         updateTabDisplay();
1125     });
1127     const updateTabDisplay = function() {
1128         if (window.qBittorrent.Client.isShowRssReader()) {
1129             $("showRssReaderLink").firstChild.style.opacity = "1";
1130             $("mainWindowTabs").removeClass("invisible");
1131             $("rssTabLink").removeClass("invisible");
1132             if (!MochaUI.Panels.instances.RssPanel)
1133                 addRssPanel();
1134         }
1135         else {
1136             $("showRssReaderLink").firstChild.style.opacity = "0";
1137             $("rssTabLink").addClass("invisible");
1138             if ($("rssTabLink").hasClass("selected"))
1139                 $("transfersTabLink").click();
1140         }
1142         if (window.qBittorrent.Client.isShowSearchEngine()) {
1143             $("showSearchEngineLink").firstChild.style.opacity = "1";
1144             $("mainWindowTabs").removeClass("invisible");
1145             $("searchTabLink").removeClass("invisible");
1146             if (!MochaUI.Panels.instances.SearchPanel)
1147                 addSearchPanel();
1148         }
1149         else {
1150             $("showSearchEngineLink").firstChild.style.opacity = "0";
1151             $("searchTabLink").addClass("invisible");
1152             if ($("searchTabLink").hasClass("selected"))
1153                 $("transfersTabLink").click();
1154         }
1156         if (window.qBittorrent.Client.isShowLogViewer()) {
1157             $("showLogViewerLink").firstChild.style.opacity = "1";
1158             $("mainWindowTabs").removeClass("invisible");
1159             $("logTabLink").removeClass("invisible");
1160             if (!MochaUI.Panels.instances.LogPanel)
1161                 addLogPanel();
1162         }
1163         else {
1164             $("showLogViewerLink").firstChild.style.opacity = "0";
1165             $("logTabLink").addClass("invisible");
1166             if ($("logTabLink").hasClass("selected"))
1167                 $("transfersTabLink").click();
1168         }
1170         // display no tabs
1171         if (!window.qBittorrent.Client.isShowRssReader() && !window.qBittorrent.Client.isShowSearchEngine() && !window.qBittorrent.Client.isShowLogViewer())
1172             $("mainWindowTabs").addClass("invisible");
1173     };
1175     $("StatisticsLink").addEventListener("click", () => { StatisticsLinkFN(); });
1177     // main window tabs
1179     const showTransfersTab = function() {
1180         const showFiltersSidebar = LocalPreferences.get("show_filters_sidebar", "true") === "true";
1181         if (showFiltersSidebar) {
1182             $("filtersColumn").removeClass("invisible");
1183             $("filtersColumn_handle").removeClass("invisible");
1184         }
1185         $("mainColumn").removeClass("invisible");
1186         $("torrentsFilterToolbar").removeClass("invisible");
1188         customSyncMainDataInterval = null;
1189         syncData(100);
1191         hideSearchTab();
1192         hideRssTab();
1193         hideLogTab();
1195         LocalPreferences.set("selected_window_tab", "transfers");
1196     };
1198     const hideTransfersTab = function() {
1199         $("filtersColumn").addClass("invisible");
1200         $("filtersColumn_handle").addClass("invisible");
1201         $("mainColumn").addClass("invisible");
1202         $("torrentsFilterToolbar").addClass("invisible");
1203         MochaUI.Desktop.resizePanels();
1204     };
1206     const showSearchTab = (function() {
1207         let searchTabInitialized = false;
1209         return () => {
1210             // we must wait until the panel is fully loaded before proceeding.
1211             // this include's the panel's custom js, which is loaded via MochaUI.Panel's 'require' field.
1212             // MochaUI loads these files asynchronously and thus all required libs may not be available immediately
1213             if (!isSearchPanelLoaded) {
1214                 setTimeout(() => {
1215                     showSearchTab();
1216                 }, 100);
1217                 return;
1218             }
1220             if (!searchTabInitialized) {
1221                 window.qBittorrent.Search.init();
1222                 searchTabInitialized = true;
1223             }
1225             $("searchTabColumn").removeClass("invisible");
1226             customSyncMainDataInterval = 30000;
1227             hideTransfersTab();
1228             hideRssTab();
1229             hideLogTab();
1231             LocalPreferences.set("selected_window_tab", "search");
1232         };
1233     })();
1235     const hideSearchTab = function() {
1236         $("searchTabColumn").addClass("invisible");
1237         MochaUI.Desktop.resizePanels();
1238     };
1240     const showRssTab = (function() {
1241         let rssTabInitialized = false;
1243         return () => {
1244             if (!rssTabInitialized) {
1245                 window.qBittorrent.Rss.init();
1246                 rssTabInitialized = true;
1247             }
1248             else {
1249                 window.qBittorrent.Rss.load();
1250             }
1252             $("rssTabColumn").removeClass("invisible");
1253             customSyncMainDataInterval = 30000;
1254             hideTransfersTab();
1255             hideSearchTab();
1256             hideLogTab();
1258             LocalPreferences.set("selected_window_tab", "rss");
1259         };
1260     })();
1262     const hideRssTab = function() {
1263         $("rssTabColumn").addClass("invisible");
1264         window.qBittorrent.Rss && window.qBittorrent.Rss.unload();
1265         MochaUI.Desktop.resizePanels();
1266     };
1268     const showLogTab = (function() {
1269         let logTabInitialized = false;
1271         return () => {
1272             // we must wait until the panel is fully loaded before proceeding.
1273             // this include's the panel's custom js, which is loaded via MochaUI.Panel's 'require' field.
1274             // MochaUI loads these files asynchronously and thus all required libs may not be available immediately
1275             if (!isLogPanelLoaded) {
1276                 setTimeout(() => {
1277                     showLogTab();
1278                 }, 100);
1279                 return;
1280             }
1282             if (!logTabInitialized) {
1283                 window.qBittorrent.Log.init();
1284                 logTabInitialized = true;
1285             }
1286             else {
1287                 window.qBittorrent.Log.load();
1288             }
1290             $("logTabColumn").removeClass("invisible");
1291             customSyncMainDataInterval = 30000;
1292             hideTransfersTab();
1293             hideSearchTab();
1294             hideRssTab();
1296             LocalPreferences.set("selected_window_tab", "log");
1297         };
1298     })();
1300     const hideLogTab = function() {
1301         $("logTabColumn").addClass("invisible");
1302         MochaUI.Desktop.resizePanels();
1303         window.qBittorrent.Log && window.qBittorrent.Log.unload();
1304     };
1306     const addSearchPanel = function() {
1307         new MochaUI.Panel({
1308             id: "SearchPanel",
1309             title: "Search",
1310             header: false,
1311             padding: {
1312                 top: 0,
1313                 right: 0,
1314                 bottom: 0,
1315                 left: 0
1316             },
1317             loadMethod: "xhr",
1318             contentURL: "views/search.html",
1319             require: {
1320                 js: ["scripts/search.js"],
1321                 onload: () => {
1322                     isSearchPanelLoaded = true;
1323                 },
1324             },
1325             content: "",
1326             column: "searchTabColumn",
1327             height: null
1328         });
1329     };
1331     const addRssPanel = function() {
1332         new MochaUI.Panel({
1333             id: "RssPanel",
1334             title: "Rss",
1335             header: false,
1336             padding: {
1337                 top: 0,
1338                 right: 0,
1339                 bottom: 0,
1340                 left: 0
1341             },
1342             loadMethod: "xhr",
1343             contentURL: "views/rss.html",
1344             content: "",
1345             column: "rssTabColumn",
1346             height: null
1347         });
1348     };
1350     const addLogPanel = function() {
1351         new MochaUI.Panel({
1352             id: "LogPanel",
1353             title: "Log",
1354             header: true,
1355             padding: {
1356                 top: 0,
1357                 right: 0,
1358                 bottom: 0,
1359                 left: 0
1360             },
1361             loadMethod: "xhr",
1362             contentURL: "views/log.html",
1363             require: {
1364                 css: ["css/vanillaSelectBox.css"],
1365                 js: ["scripts/lib/vanillaSelectBox.js"],
1366                 onload: () => {
1367                     isLogPanelLoaded = true;
1368                 },
1369             },
1370             tabsURL: "views/logTabs.html",
1371             tabsOnload: function() {
1372                 MochaUI.initializeTabs("panelTabs");
1374                 $("logMessageLink").addEventListener("click", (e) => {
1375                     window.qBittorrent.Log.setCurrentTab("main");
1376                 });
1378                 $("logPeerLink").addEventListener("click", (e) => {
1379                     window.qBittorrent.Log.setCurrentTab("peer");
1380                 });
1381             },
1382             collapsible: false,
1383             content: "",
1384             column: "logTabColumn",
1385             height: null
1386         });
1387     };
1389     const handleDownloadParam = function() {
1390         // Extract torrent URL from download param in WebUI URL hash
1391         const downloadHash = "#download=";
1392         if (location.hash.indexOf(downloadHash) !== 0)
1393             return;
1395         const url = decodeURIComponent(location.hash.substring(downloadHash.length));
1396         // Remove the processed hash from the URL
1397         history.replaceState("", document.title, (location.pathname + location.search));
1398         showDownloadPage([url]);
1399     };
1401     new MochaUI.Panel({
1402         id: "transferList",
1403         title: "Panel",
1404         header: false,
1405         padding: {
1406             top: 0,
1407             right: 0,
1408             bottom: 0,
1409             left: 0
1410         },
1411         loadMethod: "xhr",
1412         contentURL: "views/transferlist.html",
1413         onContentLoaded: function() {
1414             handleDownloadParam();
1415             updateMainData();
1416         },
1417         column: "mainColumn",
1418         onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
1419             saveColumnSizes();
1420         }),
1421         height: null
1422     });
1423     let prop_h = LocalPreferences.get("properties_height_rel");
1424     if (prop_h !== null)
1425         prop_h = prop_h.toFloat() * Window.getSize().y;
1426     else
1427         prop_h = Window.getSize().y / 2.0;
1428     new MochaUI.Panel({
1429         id: "propertiesPanel",
1430         title: "Panel",
1431         padding: {
1432             top: 0,
1433             right: 0,
1434             bottom: 0,
1435             left: 0
1436         },
1437         contentURL: "views/properties.html",
1438         require: {
1439             js: ["scripts/prop-general.js", "scripts/prop-trackers.js", "scripts/prop-peers.js", "scripts/prop-webseeds.js", "scripts/prop-files.js"],
1440             onload: function() {
1441                 updatePropertiesPanel = function() {
1442                     switch (LocalPreferences.get("selected_properties_tab")) {
1443                         case "propGeneralLink":
1444                             window.qBittorrent.PropGeneral.updateData();
1445                             break;
1446                         case "propTrackersLink":
1447                             window.qBittorrent.PropTrackers.updateData();
1448                             break;
1449                         case "propPeersLink":
1450                             window.qBittorrent.PropPeers.updateData();
1451                             break;
1452                         case "propWebSeedsLink":
1453                             window.qBittorrent.PropWebseeds.updateData();
1454                             break;
1455                         case "propFilesLink":
1456                             window.qBittorrent.PropFiles.updateData();
1457                             break;
1458                     }
1459                 };
1460             }
1461         },
1462         tabsURL: "views/propertiesToolbar.html",
1463         tabsOnload: function() {}, // must be included, otherwise panel won't load properly
1464         onContentLoaded: function() {
1465             this.panelHeaderCollapseBoxEl.classList.add("invisible");
1467             const togglePropertiesPanel = () => {
1468                 this.collapseToggleEl.click();
1469                 LocalPreferences.set("properties_panel_collapsed", this.isCollapsed.toString());
1470             };
1472             const selectTab = (tabID) => {
1473                 const isAlreadySelected = this.panelHeaderEl.getElementById(tabID).classList.contains("selected");
1474                 if (!isAlreadySelected) {
1475                     for (const tab of this.panelHeaderEl.getElementById("propertiesTabs").children)
1476                         tab.classList.toggle("selected", tab.id === tabID);
1478                     const tabContentID = tabID.replace("Link", "");
1479                     for (const tabContent of this.contentEl.children)
1480                         tabContent.classList.toggle("invisible", tabContent.id !== tabContentID);
1482                     LocalPreferences.set("selected_properties_tab", tabID);
1483                 }
1485                 if (isAlreadySelected || this.isCollapsed)
1486                     togglePropertiesPanel();
1487             };
1489             const lastUsedTab = LocalPreferences.get("selected_properties_tab", "propGeneralLink");
1490             selectTab(lastUsedTab);
1492             const startCollapsed = LocalPreferences.get("properties_panel_collapsed", "false") === "true";
1493             if (startCollapsed)
1494                 togglePropertiesPanel();
1496             this.panelHeaderContentEl.addEventListener("click", (e) => {
1497                 const selectedTab = e.target.closest("li");
1498                 if (!selectedTab)
1499                     return;
1501                 selectTab(selectedTab.id);
1502                 updatePropertiesPanel();
1504                 const showFilesFilter = (selectedTab.id === "propFilesLink") && !this.isCollapsed;
1505                 document.getElementById("torrentFilesFilterToolbar").classList.toggle("invisible", !showFilesFilter);
1506             });
1507         },
1508         column: "mainColumn",
1509         height: prop_h
1510     });
1512     // listen for changes to torrentsFilterInput
1513     let torrentsFilterInputTimer = -1;
1514     $("torrentsFilterInput").addEventListener("input", () => {
1515         clearTimeout(torrentsFilterInputTimer);
1516         torrentsFilterInputTimer = setTimeout(() => {
1517             torrentsFilterInputTimer = -1;
1518             torrentsTable.updateTable();
1519         }, window.qBittorrent.Misc.FILTER_INPUT_DELAY);
1520     });
1522     document.getElementById("torrentsFilterToolbar").addEventListener("change", (e) => { torrentsTable.updateTable(); });
1524     $("transfersTabLink").addEventListener("click", () => { showTransfersTab(); });
1525     $("searchTabLink").addEventListener("click", () => { showSearchTab(); });
1526     $("rssTabLink").addEventListener("click", () => { showRssTab(); });
1527     $("logTabLink").addEventListener("click", () => { showLogTab(); });
1528     updateTabDisplay();
1530     const registerDragAndDrop = () => {
1531         $("desktop").addEventListener("dragover", (ev) => {
1532             if (ev.preventDefault)
1533                 ev.preventDefault();
1534         });
1536         $("desktop").addEventListener("dragenter", (ev) => {
1537             if (ev.preventDefault)
1538                 ev.preventDefault();
1539         });
1541         $("desktop").addEventListener("drop", (ev) => {
1542             if (ev.preventDefault)
1543                 ev.preventDefault();
1545             const droppedFiles = ev.dataTransfer.files;
1547             if (droppedFiles.length > 0) {
1548                 // dropped files or folders
1550                 // can't handle folder due to cannot put the filelist (from dropped folder)
1551                 // to <input> `files` field
1552                 for (const item of ev.dataTransfer.items) {
1553                     if (item.webkitGetAsEntry().isDirectory)
1554                         return;
1555                 }
1557                 const id = "uploadPage";
1558                 new MochaUI.Window({
1559                     id: id,
1560                     icon: "images/qbittorrent-tray.svg",
1561                     title: "QBT_TR(Upload local torrent)QBT_TR[CONTEXT=HttpServer]",
1562                     loadMethod: "iframe",
1563                     contentURL: new URI("upload.html").toString(),
1564                     addClass: "windowFrame", // fixes iframe scrolling on iOS Safari
1565                     scrollbars: true,
1566                     maximizable: false,
1567                     paddingVertical: 0,
1568                     paddingHorizontal: 0,
1569                     width: loadWindowWidth(id, 500),
1570                     height: loadWindowHeight(id, 460),
1571                     onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
1572                         saveWindowSize(id);
1573                     }),
1574                     onContentLoaded: () => {
1575                         const fileInput = $(`${id}_iframe`).contentDocument.getElementById("fileselect");
1576                         fileInput.files = droppedFiles;
1577                     }
1578                 });
1579             }
1581             const droppedText = ev.dataTransfer.getData("text");
1582             if (droppedText.length > 0) {
1583                 // dropped text
1585                 const urls = droppedText.split("\n")
1586                     .map((str) => str.trim())
1587                     .filter((str) => {
1588                         const lowercaseStr = str.toLowerCase();
1589                         return lowercaseStr.startsWith("http:")
1590                             || lowercaseStr.startsWith("https:")
1591                             || lowercaseStr.startsWith("magnet:")
1592                             || ((str.length === 40) && !(/[^0-9A-F]/i.test(str))) // v1 hex-encoded SHA-1 info-hash
1593                             || ((str.length === 32) && !(/[^2-7A-Z]/i.test(str))); // v1 Base32 encoded SHA-1 info-hash
1594                     });
1596                 if (urls.length <= 0)
1597                     return;
1599                 const id = "downloadPage";
1600                 const contentURI = new URI("download.html").setData("urls", urls.map(encodeURIComponent).join("|"));
1601                 new MochaUI.Window({
1602                     id: id,
1603                     icon: "images/qbittorrent-tray.svg",
1604                     title: "QBT_TR(Download from URLs)QBT_TR[CONTEXT=downloadFromURL]",
1605                     loadMethod: "iframe",
1606                     contentURL: contentURI.toString(),
1607                     addClass: "windowFrame", // fixes iframe scrolling on iOS Safari
1608                     scrollbars: true,
1609                     maximizable: false,
1610                     closable: true,
1611                     paddingVertical: 0,
1612                     paddingHorizontal: 0,
1613                     width: loadWindowWidth(id, 500),
1614                     height: loadWindowHeight(id, 600),
1615                     onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
1616                         saveWindowSize(id);
1617                     })
1618                 });
1619             }
1620         });
1621     };
1622     registerDragAndDrop();
1624     new Keyboard({
1625         defaultEventType: "keydown",
1626         events: {
1627             "ctrl+a": function(event) {
1628                 if ((event.target.nodeName === "INPUT") || (event.target.nodeName === "TEXTAREA"))
1629                     return;
1630                 if (event.target.isContentEditable)
1631                     return;
1632                 torrentsTable.selectAll();
1633                 event.preventDefault();
1634             },
1635             "delete": function(event) {
1636                 if ((event.target.nodeName === "INPUT") || (event.target.nodeName === "TEXTAREA"))
1637                     return;
1638                 if (event.target.isContentEditable)
1639                     return;
1640                 deleteFN();
1641                 event.preventDefault();
1642             },
1643             "shift+delete": (event) => {
1644                 if ((event.target.nodeName === "INPUT") || (event.target.nodeName === "TEXTAREA"))
1645                     return;
1646                 if (event.target.isContentEditable)
1647                     return;
1648                 deleteFN(true);
1649                 event.preventDefault();
1650             }
1651         }
1652     }).activate();
1655 window.addEventListener("load", () => {
1656     // fetch various data and store it in memory
1657     window.qBittorrent.Cache.buildInfo.init();
1658     window.qBittorrent.Cache.preferences.init();
1659     window.qBittorrent.Cache.qbtVersion.init();
1661     // switch to previously used tab
1662     const previouslyUsedTab = LocalPreferences.get("selected_window_tab", "transfers");
1663     switch (previouslyUsedTab) {
1664         case "search":
1665             if (window.qBittorrent.Client.isShowSearchEngine())
1666                 $("searchTabLink").click();
1667             break;
1668         case "rss":
1669             if (window.qBittorrent.Client.isShowRssReader())
1670                 $("rssTabLink").click();
1671             break;
1672         case "log":
1673             if (window.qBittorrent.Client.isShowLogViewer())
1674                 $("logTabLink").click();
1675             break;
1676         case "transfers":
1677             $("transfersTabLink").click();
1678             break;
1679         default:
1680             console.error(`Unexpected 'selected_window_tab' value: ${previouslyUsedTab}`);
1681             $("transfersTabLink").click();
1682             break;
1683     };