Merge pull request #21179 from Chocobo1/webui_style
[qBittorrent.git] / src / webui / www / private / scripts / client.js
blob106e3a93a87bf49721a1f3feb5f740bd99c2f69f
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             closeWindows: closeWindows,
33             genHash: genHash,
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 closeWindows = function() {
48         MochaUI.closeAll();
49     };
51     const genHash = function(string) {
52         // origins:
53         // https://stackoverflow.com/a/8831937
54         // https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0
55         let hash = 0;
56         for (let i = 0; i < string.length; ++i)
57             hash = ((Math.imul(hash, 31) + string.charCodeAt(i)) | 0);
58         return hash;
59     };
61     const getSyncMainDataInterval = function() {
62         return customSyncMainDataInterval ? customSyncMainDataInterval : serverSyncMainDataInterval;
63     };
65     let stopped = false;
66     const isStopped = () => {
67         return stopped;
68     };
70     const stop = () => {
71         stopped = true;
72     };
74     const mainTitle = () => {
75         const emDash = "\u2014";
76         const qbtVersion = window.qBittorrent.Cache.qbtVersion.get();
77         const suffix = window.qBittorrent.Cache.preferences.get()["app_instance_name"] || "";
78         const title = `qBittorrent ${qbtVersion} QBT_TR(WebUI)QBT_TR[CONTEXT=OptionsDialog]`
79             + ((suffix.length > 0) ? ` ${emDash} ${suffix}` : "");
80         return title;
81     };
83     let showingSearchEngine = false;
84     let showingRssReader = false;
85     let showingLogViewer = false;
87     const showSearchEngine = function(bool) {
88         showingSearchEngine = bool;
89     };
90     const showRssReader = function(bool) {
91         showingRssReader = bool;
92     };
93     const showLogViewer = function(bool) {
94         showingLogViewer = bool;
95     };
96     const isShowSearchEngine = function() {
97         return showingSearchEngine;
98     };
99     const isShowRssReader = function() {
100         return showingRssReader;
101     };
102     const isShowLogViewer = function() {
103         return showingLogViewer;
104     };
106     return exports();
107 })();
108 Object.freeze(window.qBittorrent.Client);
110 // TODO: move global functions/variables into some namespace/scope
112 this.torrentsTable = new window.qBittorrent.DynamicTable.TorrentsTable();
114 let updatePropertiesPanel = function() {};
116 this.updateMainData = function() {};
117 let alternativeSpeedLimits = false;
118 let queueing_enabled = true;
119 let serverSyncMainDataInterval = 1500;
120 let customSyncMainDataInterval = null;
121 let useSubcategories = true;
122 const useAutoHideZeroStatusFilters = LocalPreferences.get("hide_zero_status_filters", "false") === "true";
124 /* Categories filter */
125 const CATEGORIES_ALL = 1;
126 const CATEGORIES_UNCATEGORIZED = 2;
128 const category_list = new Map();
130 let selected_category = Number(LocalPreferences.get("selected_category", CATEGORIES_ALL));
131 let setCategoryFilter = function() {};
133 /* Tags filter */
134 const TAGS_ALL = 1;
135 const TAGS_UNTAGGED = 2;
137 const tagList = new Map();
139 let selectedTag = Number(LocalPreferences.get("selected_tag", TAGS_ALL));
140 let setTagFilter = function() {};
142 /* Trackers filter */
143 const TRACKERS_ALL = 1;
144 const TRACKERS_TRACKERLESS = 2;
146 /** @type Map<number, {host: string, trackerTorrentMap: Map<string, string[]>}> **/
147 const trackerList = new Map();
149 let selectedTracker = LocalPreferences.get("selected_tracker", TRACKERS_ALL);
150 let setTrackerFilter = function() {};
152 /* All filters */
153 let selected_filter = LocalPreferences.get("selected_filter", "all");
154 let setFilter = function() {};
155 let toggleFilterDisplay = function() {};
157 window.addEventListener("DOMContentLoaded", () => {
158     let isSearchPanelLoaded = false;
159     let isLogPanelLoaded = false;
161     const saveColumnSizes = function() {
162         const filters_width = $("Filters").getSize().x;
163         LocalPreferences.set("filters_width", filters_width);
164         const properties_height_rel = $("propertiesPanel").getSize().y / Window.getSize().y;
165         LocalPreferences.set("properties_height_rel", properties_height_rel);
166     };
168     window.addEventListener("resize", () => {
169         // only save sizes if the columns are visible
170         if (!$("mainColumn").hasClass("invisible"))
171             saveColumnSizes.delay(200); // Resizing might takes some time.
172     });
174     /* MochaUI.Desktop = new MochaUI.Desktop();
175     MochaUI.Desktop.desktop.style.background = "#fff";
176     MochaUI.Desktop.desktop.style.visibility = "visible"; */
177     MochaUI.Desktop.initialize();
179     const buildTransfersTab = function() {
180         const filt_w = Number(LocalPreferences.get("filters_width", 120));
181         new MochaUI.Column({
182             id: "filtersColumn",
183             placement: "left",
184             onResize: saveColumnSizes,
185             width: filt_w,
186             resizeLimit: [1, 300]
187         });
188         new MochaUI.Column({
189             id: "mainColumn",
190             placement: "main"
191         });
192     };
194     const buildSearchTab = function() {
195         new MochaUI.Column({
196             id: "searchTabColumn",
197             placement: "main",
198             width: null
199         });
201         // start off hidden
202         $("searchTabColumn").addClass("invisible");
203     };
205     const buildRssTab = function() {
206         new MochaUI.Column({
207             id: "rssTabColumn",
208             placement: "main",
209             width: null
210         });
212         // start off hidden
213         $("rssTabColumn").addClass("invisible");
214     };
216     const buildLogTab = function() {
217         new MochaUI.Column({
218             id: "logTabColumn",
219             placement: "main",
220             width: null
221         });
223         // start off hidden
224         $("logTabColumn").addClass("invisible");
225     };
227     buildTransfersTab();
228     buildSearchTab();
229     buildRssTab();
230     buildLogTab();
231     MochaUI.initializeTabs("mainWindowTabsList");
233     setCategoryFilter = function(hash) {
234         selected_category = hash;
235         LocalPreferences.set("selected_category", selected_category);
236         highlightSelectedCategory();
237         if (typeof torrentsTable.tableBody !== "undefined")
238             updateMainData();
239     };
241     setTagFilter = function(hash) {
242         selectedTag = hash;
243         LocalPreferences.set("selected_tag", selectedTag);
244         highlightSelectedTag();
245         if (torrentsTable.tableBody !== undefined)
246             updateMainData();
247     };
249     setTrackerFilter = function(hash) {
250         selectedTracker = hash.toString();
251         LocalPreferences.set("selected_tracker", selectedTracker);
252         highlightSelectedTracker();
253         if (torrentsTable.tableBody !== undefined)
254             updateMainData();
255     };
257     setFilter = function(f) {
258         // Visually Select the right filter
259         $("all_filter").removeClass("selectedFilter");
260         $("downloading_filter").removeClass("selectedFilter");
261         $("seeding_filter").removeClass("selectedFilter");
262         $("completed_filter").removeClass("selectedFilter");
263         $("stopped_filter").removeClass("selectedFilter");
264         $("running_filter").removeClass("selectedFilter");
265         $("active_filter").removeClass("selectedFilter");
266         $("inactive_filter").removeClass("selectedFilter");
267         $("stalled_filter").removeClass("selectedFilter");
268         $("stalled_uploading_filter").removeClass("selectedFilter");
269         $("stalled_downloading_filter").removeClass("selectedFilter");
270         $("checking_filter").removeClass("selectedFilter");
271         $("moving_filter").removeClass("selectedFilter");
272         $("errored_filter").removeClass("selectedFilter");
273         $(f + "_filter").addClass("selectedFilter");
274         selected_filter = f;
275         LocalPreferences.set("selected_filter", f);
276         // Reload torrents
277         if (typeof torrentsTable.tableBody !== "undefined")
278             updateMainData();
279     };
281     toggleFilterDisplay = function(filter) {
282         const element = filter + "FilterList";
283         LocalPreferences.set("filter_" + filter + "_collapsed", !$(element).hasClass("invisible"));
284         $(element).toggleClass("invisible");
285         const parent = $(element).getParent(".filterWrapper");
286         const toggleIcon = $(parent).getChildren(".filterTitle img");
287         if (toggleIcon)
288             toggleIcon[0].toggleClass("rotate");
289     };
291     new MochaUI.Panel({
292         id: "Filters",
293         title: "Panel",
294         header: false,
295         padding: {
296             top: 0,
297             right: 0,
298             bottom: 0,
299             left: 0
300         },
301         loadMethod: "xhr",
302         contentURL: "views/filters.html",
303         onContentLoaded: function() {
304             setFilter(selected_filter);
305         },
306         column: "filtersColumn",
307         height: 300
308     });
309     initializeWindows();
311     // Show Top Toolbar is enabled by default
312     let showTopToolbar = LocalPreferences.get("show_top_toolbar", "true") === "true";
313     if (!showTopToolbar) {
314         $("showTopToolbarLink").firstChild.style.opacity = "0";
315         $("mochaToolbar").addClass("invisible");
316     }
318     // Show Status Bar is enabled by default
319     let showStatusBar = LocalPreferences.get("show_status_bar", "true") === "true";
320     if (!showStatusBar) {
321         $("showStatusBarLink").firstChild.style.opacity = "0";
322         $("desktopFooterWrapper").addClass("invisible");
323     }
325     // Show Filters Sidebar is enabled by default
326     let showFiltersSidebar = LocalPreferences.get("show_filters_sidebar", "true") === "true";
327     if (!showFiltersSidebar) {
328         $("showFiltersSidebarLink").firstChild.style.opacity = "0";
329         $("filtersColumn").addClass("invisible");
330         $("filtersColumn_handle").addClass("invisible");
331     }
333     let speedInTitle = LocalPreferences.get("speed_in_browser_title_bar") === "true";
334     if (!speedInTitle)
335         $("speedInBrowserTitleBarLink").firstChild.style.opacity = "0";
337     // After showing/hiding the toolbar + status bar
338     window.qBittorrent.Client.showSearchEngine(LocalPreferences.get("show_search_engine") !== "false");
339     window.qBittorrent.Client.showRssReader(LocalPreferences.get("show_rss_reader") !== "false");
340     window.qBittorrent.Client.showLogViewer(LocalPreferences.get("show_log_viewer") === "true");
342     // After Show Top Toolbar
343     MochaUI.Desktop.setDesktopSize();
345     let syncMainDataLastResponseId = 0;
346     const serverState = {};
348     const removeTorrentFromCategoryList = function(hash) {
349         if (!hash)
350             return false;
352         let removed = false;
353         category_list.forEach((category) => {
354             const deleteResult = category.torrents.delete(hash);
355             removed ||= deleteResult;
356         });
358         return removed;
359     };
361     const addTorrentToCategoryList = function(torrent) {
362         const category = torrent["category"];
363         if (typeof category === "undefined")
364             return false;
366         const hash = torrent["hash"];
367         if (category.length === 0) { // Empty category
368             removeTorrentFromCategoryList(hash);
369             return true;
370         }
372         const categoryHash = window.qBittorrent.Client.genHash(category);
373         if (!category_list.has(categoryHash)) { // This should not happen
374             category_list.set(categoryHash, {
375                 name: category,
376                 torrents: new Set()
377             });
378         }
380         const torrents = category_list.get(categoryHash).torrents;
381         if (!torrents.has(hash)) {
382             removeTorrentFromCategoryList(hash);
383             torrents.add(hash);
384             return true;
385         }
386         return false;
387     };
389     const removeTorrentFromTagList = function(hash) {
390         if (!hash)
391             return false;
393         let removed = false;
394         tagList.forEach((tag) => {
395             const deleteResult = tag.torrents.delete(hash);
396             removed ||= deleteResult;
397         });
399         return removed;
400     };
402     const addTorrentToTagList = function(torrent) {
403         if (torrent["tags"] === undefined) // Tags haven't changed
404             return false;
406         const hash = torrent["hash"];
407         removeTorrentFromTagList(hash);
409         if (torrent["tags"].length === 0) // No tags
410             return true;
412         const tags = torrent["tags"].split(",");
413         let added = false;
414         for (let i = 0; i < tags.length; ++i) {
415             const tagHash = window.qBittorrent.Client.genHash(tags[i].trim());
416             if (!tagList.has(tagHash)) { // This should not happen
417                 tagList.set(tagHash, {
418                     name: tags,
419                     torrents: new Set()
420                 });
421             }
423             const torrents = tagList.get(tagHash).torrents;
424             if (!torrents.has(hash)) {
425                 torrents.add(hash);
426                 added = true;
427             }
428         }
429         return added;
430     };
432     const updateFilter = function(filter, filterTitle) {
433         const filterEl = document.getElementById(`${filter}_filter`);
434         const filterTorrentCount = torrentsTable.getFilteredTorrentsNumber(filter, CATEGORIES_ALL, TAGS_ALL, TRACKERS_ALL);
435         if (useAutoHideZeroStatusFilters) {
436             const hideFilter = (filterTorrentCount === 0) && (filter !== "all");
437             if (filterEl.classList.toggle("invisible", hideFilter))
438                 return;
439         }
440         filterEl.firstElementChild.lastChild.nodeValue = filterTitle.replace("%1", filterTorrentCount);
441     };
443     const updateFiltersList = function() {
444         updateFilter("all", "QBT_TR(All (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
445         updateFilter("downloading", "QBT_TR(Downloading (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
446         updateFilter("seeding", "QBT_TR(Seeding (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
447         updateFilter("completed", "QBT_TR(Completed (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
448         updateFilter("running", "QBT_TR(Running (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
449         updateFilter("stopped", "QBT_TR(Stopped (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
450         updateFilter("active", "QBT_TR(Active (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
451         updateFilter("inactive", "QBT_TR(Inactive (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
452         updateFilter("stalled", "QBT_TR(Stalled (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
453         updateFilter("stalled_uploading", "QBT_TR(Stalled Uploading (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
454         updateFilter("stalled_downloading", "QBT_TR(Stalled Downloading (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
455         updateFilter("checking", "QBT_TR(Checking (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
456         updateFilter("moving", "QBT_TR(Moving (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
457         updateFilter("errored", "QBT_TR(Errored (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
458     };
460     const updateCategoryList = function() {
461         const categoryList = $("categoryFilterList");
462         if (!categoryList)
463             return;
464         categoryList.getChildren().each(c => c.destroy());
466         const create_link = function(hash, text, count) {
467             let display_name = text;
468             let margin_left = 0;
469             if (useSubcategories) {
470                 const category_path = text.split("/");
471                 display_name = category_path[category_path.length - 1];
472                 margin_left = (category_path.length - 1) * 20;
473             }
475             const span = document.createElement("span");
476             span.classList.add("link");
477             span.href = "#";
478             span.style.marginLeft = `${margin_left}px`;
479             span.textContent = `${display_name} (${count})`;
480             span.addEventListener("click", (event) => {
481                 event.preventDefault();
482                 setCategoryFilter(hash);
483             });
485             const img = document.createElement("img");
486             img.src = "images/view-categories.svg";
487             span.prepend(img);
489             const listItem = document.createElement("li");
490             listItem.id = hash;
491             listItem.appendChild(span);
493             window.qBittorrent.Filters.categoriesFilterContextMenu.addTarget(listItem);
494             return listItem;
495         };
497         const all = torrentsTable.getRowIds().length;
498         let uncategorized = 0;
499         for (const key in torrentsTable.rows) {
500             if (!Object.hasOwn(torrentsTable.rows, key))
501                 continue;
503             const row = torrentsTable.rows[key];
504             if (row["full_data"].category.length === 0)
505                 uncategorized += 1;
506         }
507         categoryList.appendChild(create_link(CATEGORIES_ALL, "QBT_TR(All)QBT_TR[CONTEXT=CategoryFilterModel]", all));
508         categoryList.appendChild(create_link(CATEGORIES_UNCATEGORIZED, "QBT_TR(Uncategorized)QBT_TR[CONTEXT=CategoryFilterModel]", uncategorized));
510         const sortedCategories = [];
511         category_list.forEach((category, hash) => sortedCategories.push({
512             categoryName: category.name,
513             categoryHash: hash,
514             categoryCount: category.torrents.size
515         }));
516         sortedCategories.sort((left, right) => {
517             const leftSegments = left.categoryName.split("/");
518             const rightSegments = right.categoryName.split("/");
520             for (let i = 0, iMax = Math.min(leftSegments.length, rightSegments.length); i < iMax; ++i) {
521                 const compareResult = window.qBittorrent.Misc.naturalSortCollator.compare(
522                     leftSegments[i], rightSegments[i]);
523                 if (compareResult !== 0)
524                     return compareResult;
525             }
527             return leftSegments.length - rightSegments.length;
528         });
530         for (let i = 0; i < sortedCategories.length; ++i) {
531             const { categoryName, categoryHash } = sortedCategories[i];
532             let { categoryCount } = sortedCategories[i];
534             if (useSubcategories) {
535                 for (let j = (i + 1);
536                     ((j < sortedCategories.length) && sortedCategories[j].categoryName.startsWith(categoryName + "/")); ++j)
537                     categoryCount += sortedCategories[j].categoryCount;
538             }
540             categoryList.appendChild(create_link(categoryHash, categoryName, categoryCount));
541         }
543         highlightSelectedCategory();
544     };
546     const highlightSelectedCategory = function() {
547         const categoryList = $("categoryFilterList");
548         if (!categoryList)
549             return;
550         const children = categoryList.childNodes;
551         for (let i = 0; i < children.length; ++i) {
552             if (Number(children[i].id) === selected_category)
553                 children[i].className = "selectedFilter";
554             else
555                 children[i].className = "";
556         }
557     };
559     const updateTagList = function() {
560         const tagFilterList = $("tagFilterList");
561         if (tagFilterList === null)
562             return;
564         tagFilterList.getChildren().each(c => c.destroy());
566         const createLink = function(hash, text, count) {
567             const span = document.createElement("span");
568             span.classList.add("link");
569             span.href = "#";
570             span.textContent = `${text} (${count})`;
571             span.addEventListener("click", (event) => {
572                 event.preventDefault();
573                 setTagFilter(hash);
574             });
576             const img = document.createElement("img");
577             img.src = "images/tags.svg";
578             span.prepend(img);
580             const listItem = document.createElement("li");
581             listItem.id = hash;
582             listItem.appendChild(span);
584             window.qBittorrent.Filters.tagsFilterContextMenu.addTarget(listItem);
585             return listItem;
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         highlightSelectedTag();
609     };
611     const highlightSelectedTag = function() {
612         const tagFilterList = $("tagFilterList");
613         if (!tagFilterList)
614             return;
616         const children = tagFilterList.childNodes;
617         for (let i = 0; i < children.length; ++i)
618             children[i].className = (Number(children[i].id) === selectedTag) ? "selectedFilter" : "";
619     };
621     // getHost emulate the GUI version `QString getHost(const QString &url)`
622     const getHost = function(url) {
623         // We want the hostname.
624         // If failed to parse the domain, original input should be returned
626         if (!/^(?:https?|udp):/i.test(url))
627             return url;
629         try {
630             // hack: URL can not get hostname from udp protocol
631             const parsedUrl = new URL(url.replace(/^udp:/i, "https:"));
632             // host: "example.com:8443"
633             // hostname: "example.com"
634             const host = parsedUrl.hostname;
635             if (!host)
636                 return url;
638             return host;
639         }
640         catch (error) {
641             return url;
642         }
643     };
645     const updateTrackerList = function() {
646         const trackerFilterList = $("trackerFilterList");
647         if (trackerFilterList === null)
648             return;
650         trackerFilterList.getChildren().each(c => c.destroy());
652         const createLink = function(hash, text, count) {
653             const span = document.createElement("span");
654             span.classList.add("link");
655             span.href = "#";
656             span.textContent = text.replace("%1", count);
657             span.addEventListener("click", (event) => {
658                 event.preventDefault();
659                 setTrackerFilter(hash);
660             });
662             const img = document.createElement("img");
663             img.src = "images/trackers.svg";
664             span.prepend(img);
666             const listItem = document.createElement("li");
667             listItem.id = hash;
668             listItem.appendChild(span);
670             window.qBittorrent.Filters.trackersFilterContextMenu.addTarget(listItem);
671             return listItem;
672         };
674         const torrentsCount = torrentsTable.getRowIds().length;
675         trackerFilterList.appendChild(createLink(TRACKERS_ALL, "QBT_TR(All (%1))QBT_TR[CONTEXT=TrackerFiltersList]", torrentsCount));
676         let trackerlessTorrentsCount = 0;
677         for (const key in torrentsTable.rows) {
678             if (Object.hasOwn(torrentsTable.rows, key) && (torrentsTable.rows[key]["full_data"].trackers_count === 0))
679                 trackerlessTorrentsCount += 1;
680         }
681         trackerFilterList.appendChild(createLink(TRACKERS_TRACKERLESS, "QBT_TR(Trackerless (%1))QBT_TR[CONTEXT=TrackerFiltersList]", trackerlessTorrentsCount));
683         // Sort trackers by hostname
684         const sortedList = [];
685         trackerList.forEach(({ host, trackerTorrentMap }, hash) => {
686             const uniqueTorrents = new Set();
687             for (const torrents of trackerTorrentMap.values()) {
688                 for (const torrent of torrents)
689                     uniqueTorrents.add(torrent);
690             }
692             sortedList.push({
693                 trackerHost: host,
694                 trackerHash: hash,
695                 trackerCount: uniqueTorrents.size,
696             });
697         });
698         sortedList.sort((left, right) => window.qBittorrent.Misc.naturalSortCollator.compare(left.trackerHost, right.trackerHost));
699         for (const { trackerHost, trackerHash, trackerCount } of sortedList)
700             trackerFilterList.appendChild(createLink(trackerHash, (trackerHost + " (%1)"), trackerCount));
702         highlightSelectedTracker();
703     };
705     const highlightSelectedTracker = function() {
706         const trackerFilterList = $("trackerFilterList");
707         if (!trackerFilterList)
708             return;
710         const children = trackerFilterList.childNodes;
711         for (const child of children)
712             child.className = (child.id === selectedTracker) ? "selectedFilter" : "";
713     };
715     const setupCopyEventHandler = (function() {
716         let clipboardEvent;
718         return () => {
719             if (clipboardEvent)
720                 clipboardEvent.destroy();
722             clipboardEvent = new ClipboardJS(".copyToClipboard", {
723                 text: function(trigger) {
724                     switch (trigger.id) {
725                         case "copyName":
726                             return copyNameFN();
727                         case "copyInfohash1":
728                             return copyInfohashFN(1);
729                         case "copyInfohash2":
730                             return copyInfohashFN(2);
731                         case "copyMagnetLink":
732                             return copyMagnetLinkFN();
733                         case "copyID":
734                             return copyIdFN();
735                         case "copyComment":
736                             return copyCommentFN();
737                         default:
738                             return "";
739                     }
740                 }
741             });
742         };
743     })();
745     let syncMainDataTimeoutID = -1;
746     let syncRequestInProgress = false;
747     const syncMainData = function() {
748         const url = new URI("api/v2/sync/maindata");
749         url.setData("rid", syncMainDataLastResponseId);
750         const request = new Request.JSON({
751             url: url,
752             noCache: true,
753             method: "get",
754             onFailure: function() {
755                 const errorDiv = $("error_div");
756                 if (errorDiv)
757                     errorDiv.textContent = "QBT_TR(qBittorrent client is not reachable)QBT_TR[CONTEXT=HttpServer]";
758                 syncRequestInProgress = false;
759                 syncData(2000);
760             },
761             onSuccess: function(response) {
762                 $("error_div").textContent = "";
763                 if (response) {
764                     clearTimeout(torrentsFilterInputTimer);
765                     torrentsFilterInputTimer = -1;
767                     let torrentsTableSelectedRows;
768                     let update_categories = false;
769                     let updateTags = false;
770                     let updateTrackers = false;
771                     const full_update = (response["full_update"] === true);
772                     if (full_update) {
773                         torrentsTableSelectedRows = torrentsTable.selectedRowsIds();
774                         torrentsTable.clear();
775                         category_list.clear();
776                         tagList.clear();
777                         trackerList.clear();
778                     }
779                     if (response["rid"])
780                         syncMainDataLastResponseId = response["rid"];
781                     if (response["categories"]) {
782                         for (const key in response["categories"]) {
783                             if (!Object.hasOwn(response["categories"], key))
784                                 continue;
786                             const responseCategory = response["categories"][key];
787                             const categoryHash = window.qBittorrent.Client.genHash(key);
788                             const category = category_list.get(categoryHash);
789                             if (category !== undefined) {
790                                 // only the save path can change for existing categories
791                                 category.savePath = responseCategory.savePath;
792                             }
793                             else {
794                                 category_list.set(categoryHash, {
795                                     name: responseCategory.name,
796                                     savePath: responseCategory.savePath,
797                                     torrents: new Set()
798                                 });
799                             }
800                         }
801                         update_categories = true;
802                     }
803                     if (response["categories_removed"]) {
804                         response["categories_removed"].each((category) => {
805                             const categoryHash = window.qBittorrent.Client.genHash(category);
806                             category_list.delete(categoryHash);
807                         });
808                         update_categories = true;
809                     }
810                     if (response["tags"]) {
811                         for (const tag of response["tags"]) {
812                             const tagHash = window.qBittorrent.Client.genHash(tag);
813                             if (!tagList.has(tagHash)) {
814                                 tagList.set(tagHash, {
815                                     name: tag,
816                                     torrents: new Set()
817                                 });
818                             }
819                         }
820                         updateTags = true;
821                     }
822                     if (response["tags_removed"]) {
823                         for (let i = 0; i < response["tags_removed"].length; ++i) {
824                             const tagHash = window.qBittorrent.Client.genHash(response["tags_removed"][i]);
825                             tagList.delete(tagHash);
826                         }
827                         updateTags = true;
828                     }
829                     if (response["trackers"]) {
830                         for (const [tracker, torrents] of Object.entries(response["trackers"])) {
831                             const host = getHost(tracker);
832                             const hash = window.qBittorrent.Client.genHash(host);
834                             let trackerListItem = trackerList.get(hash);
835                             if (trackerListItem === undefined) {
836                                 trackerListItem = { host: host, trackerTorrentMap: new Map() };
837                                 trackerList.set(hash, trackerListItem);
838                             }
840                             trackerListItem.trackerTorrentMap.set(tracker, [...torrents]);
841                         }
842                         updateTrackers = true;
843                     }
844                     if (response["trackers_removed"]) {
845                         for (let i = 0; i < response["trackers_removed"].length; ++i) {
846                             const tracker = response["trackers_removed"][i];
847                             const hash = window.qBittorrent.Client.genHash(getHost(tracker));
848                             const trackerListEntry = trackerList.get(hash);
849                             if (trackerListEntry)
850                                 trackerListEntry.trackerTorrentMap.delete(tracker);
851                         }
852                         updateTrackers = true;
853                     }
854                     if (response["torrents"]) {
855                         let updateTorrentList = false;
856                         for (const key in response["torrents"]) {
857                             if (!Object.hasOwn(response["torrents"], key))
858                                 continue;
860                             response["torrents"][key]["hash"] = key;
861                             response["torrents"][key]["rowId"] = key;
862                             if (response["torrents"][key]["state"])
863                                 response["torrents"][key]["status"] = response["torrents"][key]["state"];
864                             torrentsTable.updateRowData(response["torrents"][key]);
865                             if (addTorrentToCategoryList(response["torrents"][key]))
866                                 update_categories = true;
867                             if (addTorrentToTagList(response["torrents"][key]))
868                                 updateTags = true;
869                             if (response["torrents"][key]["name"])
870                                 updateTorrentList = true;
871                         }
873                         if (updateTorrentList)
874                             setupCopyEventHandler();
875                     }
876                     if (response["torrents_removed"]) {
877                         response["torrents_removed"].each((hash) => {
878                             torrentsTable.removeRow(hash);
879                             removeTorrentFromCategoryList(hash);
880                             update_categories = true; // Always to update All category
881                             removeTorrentFromTagList(hash);
882                             updateTags = true; // Always to update All tag
883                         });
884                     }
885                     torrentsTable.updateTable(full_update);
886                     if (response["server_state"]) {
887                         const tmp = response["server_state"];
888                         for (const k in tmp) {
889                             if (!Object.hasOwn(tmp, k))
890                                 continue;
891                             serverState[k] = tmp[k];
892                         }
893                         processServerState();
894                     }
895                     updateFiltersList();
896                     if (update_categories) {
897                         updateCategoryList();
898                         window.qBittorrent.TransferList.contextMenu.updateCategoriesSubMenu(category_list);
899                     }
900                     if (updateTags) {
901                         updateTagList();
902                         window.qBittorrent.TransferList.contextMenu.updateTagsSubMenu(tagList);
903                     }
904                     if (updateTrackers)
905                         updateTrackerList();
907                     if (full_update)
908                         // re-select previously selected rows
909                         torrentsTable.reselectRows(torrentsTableSelectedRows);
910                 }
911                 syncRequestInProgress = false;
912                 syncData(window.qBittorrent.Client.getSyncMainDataInterval());
913             }
914         });
915         syncRequestInProgress = true;
916         request.send();
917     };
919     updateMainData = function() {
920         torrentsTable.updateTable();
921         syncData(100);
922     };
924     const syncData = function(delay) {
925         if (syncRequestInProgress)
926             return;
928         clearTimeout(syncMainDataTimeoutID);
929         syncMainDataTimeoutID = -1;
931         if (window.qBittorrent.Client.isStopped())
932             return;
934         syncMainDataTimeoutID = syncMainData.delay(delay);
935     };
937     const processServerState = function() {
938         let transfer_info = window.qBittorrent.Misc.friendlyUnit(serverState.dl_info_speed, true);
939         if (serverState.dl_rate_limit > 0)
940             transfer_info += " [" + window.qBittorrent.Misc.friendlyUnit(serverState.dl_rate_limit, true) + "]";
941         transfer_info += " (" + window.qBittorrent.Misc.friendlyUnit(serverState.dl_info_data, false) + ")";
942         $("DlInfos").textContent = transfer_info;
943         transfer_info = window.qBittorrent.Misc.friendlyUnit(serverState.up_info_speed, true);
944         if (serverState.up_rate_limit > 0)
945             transfer_info += " [" + window.qBittorrent.Misc.friendlyUnit(serverState.up_rate_limit, true) + "]";
946         transfer_info += " (" + window.qBittorrent.Misc.friendlyUnit(serverState.up_info_data, false) + ")";
947         $("UpInfos").textContent = transfer_info;
949         document.title = (speedInTitle
950                 ? (`QBT_TR([D: %1, U: %2])QBT_TR[CONTEXT=MainWindow] `
951                     .replace("%1", window.qBittorrent.Misc.friendlyUnit(serverState.dl_info_speed, true))
952                     .replace("%2", window.qBittorrent.Misc.friendlyUnit(serverState.up_info_speed, true)))
953                 : "")
954             + window.qBittorrent.Client.mainTitle();
956         $("freeSpaceOnDisk").textContent = "QBT_TR(Free space: %1)QBT_TR[CONTEXT=HttpServer]".replace("%1", window.qBittorrent.Misc.friendlyUnit(serverState.free_space_on_disk));
957         $("DHTNodes").textContent = "QBT_TR(DHT: %1 nodes)QBT_TR[CONTEXT=StatusBar]".replace("%1", serverState.dht_nodes);
959         // Statistics dialog
960         if (document.getElementById("statisticsContent")) {
961             $("AlltimeDL").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.alltime_dl, false);
962             $("AlltimeUL").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.alltime_ul, false);
963             $("TotalWastedSession").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.total_wasted_session, false);
964             $("GlobalRatio").textContent = serverState.global_ratio;
965             $("TotalPeerConnections").textContent = serverState.total_peer_connections;
966             $("ReadCacheHits").textContent = serverState.read_cache_hits + "%";
967             $("TotalBuffersSize").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.total_buffers_size, false);
968             $("WriteCacheOverload").textContent = serverState.write_cache_overload + "%";
969             $("ReadCacheOverload").textContent = serverState.read_cache_overload + "%";
970             $("QueuedIOJobs").textContent = serverState.queued_io_jobs;
971             $("AverageTimeInQueue").textContent = serverState.average_time_queue + " ms";
972             $("TotalQueuedSize").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.total_queued_size, false);
973         }
975         switch (serverState.connection_status) {
976             case "connected":
977                 $("connectionStatus").src = "images/connected.svg";
978                 $("connectionStatus").alt = "QBT_TR(Connection status: Connected)QBT_TR[CONTEXT=MainWindow]";
979                 $("connectionStatus").title = "QBT_TR(Connection status: Connected)QBT_TR[CONTEXT=MainWindow]";
980                 break;
981             case "firewalled":
982                 $("connectionStatus").src = "images/firewalled.svg";
983                 $("connectionStatus").alt = "QBT_TR(Connection status: Firewalled)QBT_TR[CONTEXT=MainWindow]";
984                 $("connectionStatus").title = "QBT_TR(Connection status: Firewalled)QBT_TR[CONTEXT=MainWindow]";
985                 break;
986             default:
987                 $("connectionStatus").src = "images/disconnected.svg";
988                 $("connectionStatus").alt = "QBT_TR(Connection status: Disconnected)QBT_TR[CONTEXT=MainWindow]";
989                 $("connectionStatus").title = "QBT_TR(Connection status: Disconnected)QBT_TR[CONTEXT=MainWindow]";
990                 break;
991         }
993         if (queueing_enabled !== serverState.queueing) {
994             queueing_enabled = serverState.queueing;
995             torrentsTable.columns["priority"].force_hide = !queueing_enabled;
996             torrentsTable.updateColumn("priority");
997             if (queueing_enabled) {
998                 $("topQueuePosItem").removeClass("invisible");
999                 $("increaseQueuePosItem").removeClass("invisible");
1000                 $("decreaseQueuePosItem").removeClass("invisible");
1001                 $("bottomQueuePosItem").removeClass("invisible");
1002                 $("queueingButtons").removeClass("invisible");
1003                 $("queueingMenuItems").removeClass("invisible");
1004             }
1005             else {
1006                 $("topQueuePosItem").addClass("invisible");
1007                 $("increaseQueuePosItem").addClass("invisible");
1008                 $("decreaseQueuePosItem").addClass("invisible");
1009                 $("bottomQueuePosItem").addClass("invisible");
1010                 $("queueingButtons").addClass("invisible");
1011                 $("queueingMenuItems").addClass("invisible");
1012             }
1013         }
1015         if (alternativeSpeedLimits !== serverState.use_alt_speed_limits) {
1016             alternativeSpeedLimits = serverState.use_alt_speed_limits;
1017             updateAltSpeedIcon(alternativeSpeedLimits);
1018         }
1020         if (useSubcategories !== serverState.use_subcategories) {
1021             useSubcategories = serverState.use_subcategories;
1022             updateCategoryList();
1023         }
1025         serverSyncMainDataInterval = Math.max(serverState.refresh_interval, 500);
1026     };
1028     const updateAltSpeedIcon = function(enabled) {
1029         if (enabled) {
1030             $("alternativeSpeedLimits").src = "images/slow.svg";
1031             $("alternativeSpeedLimits").alt = "QBT_TR(Alternative speed limits: On)QBT_TR[CONTEXT=MainWindow]";
1032             $("alternativeSpeedLimits").title = "QBT_TR(Alternative speed limits: On)QBT_TR[CONTEXT=MainWindow]";
1033         }
1034         else {
1035             $("alternativeSpeedLimits").src = "images/slow_off.svg";
1036             $("alternativeSpeedLimits").alt = "QBT_TR(Alternative speed limits: Off)QBT_TR[CONTEXT=MainWindow]";
1037             $("alternativeSpeedLimits").title = "QBT_TR(Alternative speed limits: Off)QBT_TR[CONTEXT=MainWindow]";
1038         }
1039     };
1041     $("alternativeSpeedLimits").addEventListener("click", () => {
1042         // Change icon immediately to give some feedback
1043         updateAltSpeedIcon(!alternativeSpeedLimits);
1045         new Request({
1046             url: "api/v2/transfer/toggleSpeedLimitsMode",
1047             method: "post",
1048             onComplete: function() {
1049                 alternativeSpeedLimits = !alternativeSpeedLimits;
1050                 updateMainData();
1051             },
1052             onFailure: function() {
1053                 // Restore icon in case of failure
1054                 updateAltSpeedIcon(alternativeSpeedLimits);
1055             }
1056         }).send();
1057     });
1059     $("DlInfos").addEventListener("click", globalDownloadLimitFN);
1060     $("UpInfos").addEventListener("click", globalUploadLimitFN);
1062     $("showTopToolbarLink").addEventListener("click", (e) => {
1063         showTopToolbar = !showTopToolbar;
1064         LocalPreferences.set("show_top_toolbar", showTopToolbar.toString());
1065         if (showTopToolbar) {
1066             $("showTopToolbarLink").firstChild.style.opacity = "1";
1067             $("mochaToolbar").removeClass("invisible");
1068         }
1069         else {
1070             $("showTopToolbarLink").firstChild.style.opacity = "0";
1071             $("mochaToolbar").addClass("invisible");
1072         }
1073         MochaUI.Desktop.setDesktopSize();
1074     });
1076     $("showStatusBarLink").addEventListener("click", (e) => {
1077         showStatusBar = !showStatusBar;
1078         LocalPreferences.set("show_status_bar", showStatusBar.toString());
1079         if (showStatusBar) {
1080             $("showStatusBarLink").firstChild.style.opacity = "1";
1081             $("desktopFooterWrapper").removeClass("invisible");
1082         }
1083         else {
1084             $("showStatusBarLink").firstChild.style.opacity = "0";
1085             $("desktopFooterWrapper").addClass("invisible");
1086         }
1087         MochaUI.Desktop.setDesktopSize();
1088     });
1090     const registerMagnetHandler = function() {
1091         if (typeof navigator.registerProtocolHandler !== "function") {
1092             if (window.location.protocol !== "https:")
1093                 alert("QBT_TR(To use this feature, the WebUI needs to be accessed over HTTPS)QBT_TR[CONTEXT=MainWindow]");
1094             else
1095                 alert("QBT_TR(Your browser does not support this feature)QBT_TR[CONTEXT=MainWindow]");
1096             return;
1097         }
1099         const hashString = location.hash ? location.hash.replace(/^#/, "") : "";
1100         const hashParams = new URLSearchParams(hashString);
1101         hashParams.set("download", "");
1103         const templateHashString = hashParams.toString().replace("download=", "download=%s");
1104         const templateUrl = location.origin + location.pathname
1105             + location.search + "#" + templateHashString;
1107         navigator.registerProtocolHandler("magnet", templateUrl,
1108             "qBittorrent WebUI magnet handler");
1109     };
1110     $("registerMagnetHandlerLink").addEventListener("click", (e) => {
1111         registerMagnetHandler();
1112     });
1114     $("showFiltersSidebarLink").addEventListener("click", (e) => {
1115         showFiltersSidebar = !showFiltersSidebar;
1116         LocalPreferences.set("show_filters_sidebar", showFiltersSidebar.toString());
1117         if (showFiltersSidebar) {
1118             $("showFiltersSidebarLink").firstChild.style.opacity = "1";
1119             $("filtersColumn").removeClass("invisible");
1120             $("filtersColumn_handle").removeClass("invisible");
1121         }
1122         else {
1123             $("showFiltersSidebarLink").firstChild.style.opacity = "0";
1124             $("filtersColumn").addClass("invisible");
1125             $("filtersColumn_handle").addClass("invisible");
1126         }
1127         MochaUI.Desktop.setDesktopSize();
1128     });
1130     $("speedInBrowserTitleBarLink").addEventListener("click", (e) => {
1131         speedInTitle = !speedInTitle;
1132         LocalPreferences.set("speed_in_browser_title_bar", speedInTitle.toString());
1133         if (speedInTitle)
1134             $("speedInBrowserTitleBarLink").firstChild.style.opacity = "1";
1135         else
1136             $("speedInBrowserTitleBarLink").firstChild.style.opacity = "0";
1137         processServerState();
1138     });
1140     $("showSearchEngineLink").addEventListener("click", (e) => {
1141         window.qBittorrent.Client.showSearchEngine(!window.qBittorrent.Client.isShowSearchEngine());
1142         LocalPreferences.set("show_search_engine", window.qBittorrent.Client.isShowSearchEngine().toString());
1143         updateTabDisplay();
1144     });
1146     $("showRssReaderLink").addEventListener("click", (e) => {
1147         window.qBittorrent.Client.showRssReader(!window.qBittorrent.Client.isShowRssReader());
1148         LocalPreferences.set("show_rss_reader", window.qBittorrent.Client.isShowRssReader().toString());
1149         updateTabDisplay();
1150     });
1152     $("showLogViewerLink").addEventListener("click", (e) => {
1153         window.qBittorrent.Client.showLogViewer(!window.qBittorrent.Client.isShowLogViewer());
1154         LocalPreferences.set("show_log_viewer", window.qBittorrent.Client.isShowLogViewer().toString());
1155         updateTabDisplay();
1156     });
1158     const updateTabDisplay = function() {
1159         if (window.qBittorrent.Client.isShowRssReader()) {
1160             $("showRssReaderLink").firstChild.style.opacity = "1";
1161             $("mainWindowTabs").removeClass("invisible");
1162             $("rssTabLink").removeClass("invisible");
1163             if (!MochaUI.Panels.instances.RssPanel)
1164                 addRssPanel();
1165         }
1166         else {
1167             $("showRssReaderLink").firstChild.style.opacity = "0";
1168             $("rssTabLink").addClass("invisible");
1169             if ($("rssTabLink").hasClass("selected"))
1170                 $("transfersTabLink").click();
1171         }
1173         if (window.qBittorrent.Client.isShowSearchEngine()) {
1174             $("showSearchEngineLink").firstChild.style.opacity = "1";
1175             $("mainWindowTabs").removeClass("invisible");
1176             $("searchTabLink").removeClass("invisible");
1177             if (!MochaUI.Panels.instances.SearchPanel)
1178                 addSearchPanel();
1179         }
1180         else {
1181             $("showSearchEngineLink").firstChild.style.opacity = "0";
1182             $("searchTabLink").addClass("invisible");
1183             if ($("searchTabLink").hasClass("selected"))
1184                 $("transfersTabLink").click();
1185         }
1187         if (window.qBittorrent.Client.isShowLogViewer()) {
1188             $("showLogViewerLink").firstChild.style.opacity = "1";
1189             $("mainWindowTabs").removeClass("invisible");
1190             $("logTabLink").removeClass("invisible");
1191             if (!MochaUI.Panels.instances.LogPanel)
1192                 addLogPanel();
1193         }
1194         else {
1195             $("showLogViewerLink").firstChild.style.opacity = "0";
1196             $("logTabLink").addClass("invisible");
1197             if ($("logTabLink").hasClass("selected"))
1198                 $("transfersTabLink").click();
1199         }
1201         // display no tabs
1202         if (!window.qBittorrent.Client.isShowRssReader() && !window.qBittorrent.Client.isShowSearchEngine() && !window.qBittorrent.Client.isShowLogViewer())
1203             $("mainWindowTabs").addClass("invisible");
1204     };
1206     $("StatisticsLink").addEventListener("click", StatisticsLinkFN);
1208     // main window tabs
1210     const showTransfersTab = function() {
1211         const showFiltersSidebar = LocalPreferences.get("show_filters_sidebar", "true") === "true";
1212         if (showFiltersSidebar) {
1213             $("filtersColumn").removeClass("invisible");
1214             $("filtersColumn_handle").removeClass("invisible");
1215         }
1216         $("mainColumn").removeClass("invisible");
1217         $("torrentsFilterToolbar").removeClass("invisible");
1219         customSyncMainDataInterval = null;
1220         syncData(100);
1222         hideSearchTab();
1223         hideRssTab();
1224         hideLogTab();
1226         LocalPreferences.set("selected_window_tab", "transfers");
1227     };
1229     const hideTransfersTab = function() {
1230         $("filtersColumn").addClass("invisible");
1231         $("filtersColumn_handle").addClass("invisible");
1232         $("mainColumn").addClass("invisible");
1233         $("torrentsFilterToolbar").addClass("invisible");
1234         MochaUI.Desktop.resizePanels();
1235     };
1237     const showSearchTab = (function() {
1238         let searchTabInitialized = false;
1240         return () => {
1241             // we must wait until the panel is fully loaded before proceeding.
1242             // this include's the panel's custom js, which is loaded via MochaUI.Panel's 'require' field.
1243             // MochaUI loads these files asynchronously and thus all required libs may not be available immediately
1244             if (!isSearchPanelLoaded) {
1245                 setTimeout(() => {
1246                     showSearchTab();
1247                 }, 100);
1248                 return;
1249             }
1251             if (!searchTabInitialized) {
1252                 window.qBittorrent.Search.init();
1253                 searchTabInitialized = true;
1254             }
1256             $("searchTabColumn").removeClass("invisible");
1257             customSyncMainDataInterval = 30000;
1258             hideTransfersTab();
1259             hideRssTab();
1260             hideLogTab();
1262             LocalPreferences.set("selected_window_tab", "search");
1263         };
1264     })();
1266     const hideSearchTab = function() {
1267         $("searchTabColumn").addClass("invisible");
1268         MochaUI.Desktop.resizePanels();
1269     };
1271     const showRssTab = (function() {
1272         let rssTabInitialized = false;
1274         return () => {
1275             if (!rssTabInitialized) {
1276                 window.qBittorrent.Rss.init();
1277                 rssTabInitialized = true;
1278             }
1279             else {
1280                 window.qBittorrent.Rss.load();
1281             }
1283             $("rssTabColumn").removeClass("invisible");
1284             customSyncMainDataInterval = 30000;
1285             hideTransfersTab();
1286             hideSearchTab();
1287             hideLogTab();
1289             LocalPreferences.set("selected_window_tab", "rss");
1290         };
1291     })();
1293     const hideRssTab = function() {
1294         $("rssTabColumn").addClass("invisible");
1295         window.qBittorrent.Rss && window.qBittorrent.Rss.unload();
1296         MochaUI.Desktop.resizePanels();
1297     };
1299     const showLogTab = (function() {
1300         let logTabInitialized = false;
1302         return () => {
1303             // we must wait until the panel is fully loaded before proceeding.
1304             // this include's the panel's custom js, which is loaded via MochaUI.Panel's 'require' field.
1305             // MochaUI loads these files asynchronously and thus all required libs may not be available immediately
1306             if (!isLogPanelLoaded) {
1307                 setTimeout(() => {
1308                     showLogTab();
1309                 }, 100);
1310                 return;
1311             }
1313             if (!logTabInitialized) {
1314                 window.qBittorrent.Log.init();
1315                 logTabInitialized = true;
1316             }
1317             else {
1318                 window.qBittorrent.Log.load();
1319             }
1321             $("logTabColumn").removeClass("invisible");
1322             customSyncMainDataInterval = 30000;
1323             hideTransfersTab();
1324             hideSearchTab();
1325             hideRssTab();
1327             LocalPreferences.set("selected_window_tab", "log");
1328         };
1329     })();
1331     const hideLogTab = function() {
1332         $("logTabColumn").addClass("invisible");
1333         MochaUI.Desktop.resizePanels();
1334         window.qBittorrent.Log && window.qBittorrent.Log.unload();
1335     };
1337     const addSearchPanel = function() {
1338         new MochaUI.Panel({
1339             id: "SearchPanel",
1340             title: "Search",
1341             header: false,
1342             padding: {
1343                 top: 0,
1344                 right: 0,
1345                 bottom: 0,
1346                 left: 0
1347             },
1348             loadMethod: "xhr",
1349             contentURL: "views/search.html",
1350             require: {
1351                 js: ["scripts/search.js"],
1352                 onload: () => {
1353                     isSearchPanelLoaded = true;
1354                 },
1355             },
1356             content: "",
1357             column: "searchTabColumn",
1358             height: null
1359         });
1360     };
1362     const addRssPanel = function() {
1363         new MochaUI.Panel({
1364             id: "RssPanel",
1365             title: "Rss",
1366             header: false,
1367             padding: {
1368                 top: 0,
1369                 right: 0,
1370                 bottom: 0,
1371                 left: 0
1372             },
1373             loadMethod: "xhr",
1374             contentURL: "views/rss.html",
1375             content: "",
1376             column: "rssTabColumn",
1377             height: null
1378         });
1379     };
1381     const addLogPanel = function() {
1382         new MochaUI.Panel({
1383             id: "LogPanel",
1384             title: "Log",
1385             header: true,
1386             padding: {
1387                 top: 0,
1388                 right: 0,
1389                 bottom: 0,
1390                 left: 0
1391             },
1392             loadMethod: "xhr",
1393             contentURL: "views/log.html",
1394             require: {
1395                 css: ["css/vanillaSelectBox.css"],
1396                 js: ["scripts/lib/vanillaSelectBox.js"],
1397                 onload: () => {
1398                     isLogPanelLoaded = true;
1399                 },
1400             },
1401             tabsURL: "views/logTabs.html",
1402             tabsOnload: function() {
1403                 MochaUI.initializeTabs("panelTabs");
1405                 $("logMessageLink").addEventListener("click", (e) => {
1406                     window.qBittorrent.Log.setCurrentTab("main");
1407                 });
1409                 $("logPeerLink").addEventListener("click", (e) => {
1410                     window.qBittorrent.Log.setCurrentTab("peer");
1411                 });
1412             },
1413             collapsible: false,
1414             content: "",
1415             column: "logTabColumn",
1416             height: null
1417         });
1418     };
1420     const handleDownloadParam = function() {
1421         // Extract torrent URL from download param in WebUI URL hash
1422         const downloadHash = "#download=";
1423         if (location.hash.indexOf(downloadHash) !== 0)
1424             return;
1426         const url = decodeURIComponent(location.hash.substring(downloadHash.length));
1427         // Remove the processed hash from the URL
1428         history.replaceState("", document.title, (location.pathname + location.search));
1429         showDownloadPage([url]);
1430     };
1432     new MochaUI.Panel({
1433         id: "transferList",
1434         title: "Panel",
1435         header: false,
1436         padding: {
1437             top: 0,
1438             right: 0,
1439             bottom: 0,
1440             left: 0
1441         },
1442         loadMethod: "xhr",
1443         contentURL: "views/transferlist.html",
1444         onContentLoaded: function() {
1445             handleDownloadParam();
1446             updateMainData();
1447         },
1448         column: "mainColumn",
1449         onResize: saveColumnSizes,
1450         height: null
1451     });
1452     let prop_h = LocalPreferences.get("properties_height_rel");
1453     if (prop_h !== null)
1454         prop_h = prop_h.toFloat() * Window.getSize().y;
1455     else
1456         prop_h = Window.getSize().y / 2.0;
1457     new MochaUI.Panel({
1458         id: "propertiesPanel",
1459         title: "Panel",
1460         header: true,
1461         padding: {
1462             top: 0,
1463             right: 0,
1464             bottom: 0,
1465             left: 0
1466         },
1467         contentURL: "views/properties.html",
1468         require: {
1469             css: ["css/Tabs.css", "css/dynamicTable.css"],
1470             js: ["scripts/prop-general.js", "scripts/prop-trackers.js", "scripts/prop-peers.js", "scripts/prop-webseeds.js", "scripts/prop-files.js"],
1471         },
1472         tabsURL: "views/propertiesToolbar.html",
1473         tabsOnload: function() {
1474             MochaUI.initializeTabs("propertiesTabs");
1476             updatePropertiesPanel = function() {
1477                 if (!$("prop_general").hasClass("invisible")) {
1478                     if (window.qBittorrent.PropGeneral !== undefined)
1479                         window.qBittorrent.PropGeneral.updateData();
1480                 }
1481                 else if (!$("prop_trackers").hasClass("invisible")) {
1482                     if (window.qBittorrent.PropTrackers !== undefined)
1483                         window.qBittorrent.PropTrackers.updateData();
1484                 }
1485                 else if (!$("prop_peers").hasClass("invisible")) {
1486                     if (window.qBittorrent.PropPeers !== undefined)
1487                         window.qBittorrent.PropPeers.updateData();
1488                 }
1489                 else if (!$("prop_webseeds").hasClass("invisible")) {
1490                     if (window.qBittorrent.PropWebseeds !== undefined)
1491                         window.qBittorrent.PropWebseeds.updateData();
1492                 }
1493                 else if (!$("prop_files").hasClass("invisible")) {
1494                     if (window.qBittorrent.PropFiles !== undefined)
1495                         window.qBittorrent.PropFiles.updateData();
1496                 }
1497             };
1499             $("PropGeneralLink").addEventListener("click", function(e) {
1500                 $$(".propertiesTabContent").addClass("invisible");
1501                 $("prop_general").removeClass("invisible");
1502                 hideFilesFilter();
1503                 updatePropertiesPanel();
1504                 LocalPreferences.set("selected_tab", this.id);
1505             });
1507             $("PropTrackersLink").addEventListener("click", function(e) {
1508                 $$(".propertiesTabContent").addClass("invisible");
1509                 $("prop_trackers").removeClass("invisible");
1510                 hideFilesFilter();
1511                 updatePropertiesPanel();
1512                 LocalPreferences.set("selected_tab", this.id);
1513             });
1515             $("PropPeersLink").addEventListener("click", function(e) {
1516                 $$(".propertiesTabContent").addClass("invisible");
1517                 $("prop_peers").removeClass("invisible");
1518                 hideFilesFilter();
1519                 updatePropertiesPanel();
1520                 LocalPreferences.set("selected_tab", this.id);
1521             });
1523             $("PropWebSeedsLink").addEventListener("click", function(e) {
1524                 $$(".propertiesTabContent").addClass("invisible");
1525                 $("prop_webseeds").removeClass("invisible");
1526                 hideFilesFilter();
1527                 updatePropertiesPanel();
1528                 LocalPreferences.set("selected_tab", this.id);
1529             });
1531             $("PropFilesLink").addEventListener("click", function(e) {
1532                 $$(".propertiesTabContent").addClass("invisible");
1533                 $("prop_files").removeClass("invisible");
1534                 showFilesFilter();
1535                 updatePropertiesPanel();
1536                 LocalPreferences.set("selected_tab", this.id);
1537             });
1539             $("propertiesPanel_collapseToggle").addEventListener("click", (e) => {
1540                 updatePropertiesPanel();
1541             });
1542         },
1543         column: "mainColumn",
1544         height: prop_h
1545     });
1547     const showFilesFilter = function() {
1548         $("torrentFilesFilterToolbar").removeClass("invisible");
1549     };
1551     const hideFilesFilter = function() {
1552         $("torrentFilesFilterToolbar").addClass("invisible");
1553     };
1555     // listen for changes to torrentsFilterInput
1556     let torrentsFilterInputTimer = -1;
1557     $("torrentsFilterInput").addEventListener("input", () => {
1558         clearTimeout(torrentsFilterInputTimer);
1559         torrentsFilterInputTimer = setTimeout(() => {
1560             torrentsFilterInputTimer = -1;
1561             torrentsTable.updateTable();
1562         }, window.qBittorrent.Misc.FILTER_INPUT_DELAY);
1563     });
1565     document.getElementById("torrentsFilterToolbar").addEventListener("change", (e) => { torrentsTable.updateTable(); });
1567     $("transfersTabLink").addEventListener("click", showTransfersTab);
1568     $("searchTabLink").addEventListener("click", showSearchTab);
1569     $("rssTabLink").addEventListener("click", showRssTab);
1570     $("logTabLink").addEventListener("click", showLogTab);
1571     updateTabDisplay();
1573     const registerDragAndDrop = () => {
1574         $("desktop").addEventListener("dragover", (ev) => {
1575             if (ev.preventDefault)
1576                 ev.preventDefault();
1577         });
1579         $("desktop").addEventListener("dragenter", (ev) => {
1580             if (ev.preventDefault)
1581                 ev.preventDefault();
1582         });
1584         $("desktop").addEventListener("drop", (ev) => {
1585             if (ev.preventDefault)
1586                 ev.preventDefault();
1588             const droppedFiles = ev.dataTransfer.files;
1590             if (droppedFiles.length > 0) {
1591                 // dropped files or folders
1593                 // can't handle folder due to cannot put the filelist (from dropped folder)
1594                 // to <input> `files` field
1595                 for (const item of ev.dataTransfer.items) {
1596                     if (item.webkitGetAsEntry().isDirectory)
1597                         return;
1598                 }
1600                 const id = "uploadPage";
1601                 new MochaUI.Window({
1602                     id: id,
1603                     title: "QBT_TR(Upload local torrent)QBT_TR[CONTEXT=HttpServer]",
1604                     loadMethod: "iframe",
1605                     contentURL: new URI("upload.html").toString(),
1606                     addClass: "windowFrame", // fixes iframe scrolling on iOS Safari
1607                     scrollbars: true,
1608                     maximizable: false,
1609                     paddingVertical: 0,
1610                     paddingHorizontal: 0,
1611                     width: loadWindowWidth(id, 500),
1612                     height: loadWindowHeight(id, 460),
1613                     onResize: () => {
1614                         saveWindowSize(id);
1615                     },
1616                     onContentLoaded: () => {
1617                         const fileInput = $(`${id}_iframe`).contentDocument.getElementById("fileselect");
1618                         fileInput.files = droppedFiles;
1619                     }
1620                 });
1621             }
1623             const droppedText = ev.dataTransfer.getData("text");
1624             if (droppedText.length > 0) {
1625                 // dropped text
1627                 const urls = droppedText.split("\n")
1628                     .map((str) => str.trim())
1629                     .filter((str) => {
1630                         const lowercaseStr = str.toLowerCase();
1631                         return lowercaseStr.startsWith("http:")
1632                             || lowercaseStr.startsWith("https:")
1633                             || lowercaseStr.startsWith("magnet:")
1634                             || ((str.length === 40) && !(/[^0-9A-F]/i.test(str))) // v1 hex-encoded SHA-1 info-hash
1635                             || ((str.length === 32) && !(/[^2-7A-Z]/i.test(str))); // v1 Base32 encoded SHA-1 info-hash
1636                     });
1638                 if (urls.length <= 0)
1639                     return;
1641                 const id = "downloadPage";
1642                 const contentURI = new URI("download.html").setData("urls", urls.map(encodeURIComponent).join("|"));
1643                 new MochaUI.Window({
1644                     id: id,
1645                     title: "QBT_TR(Download from URLs)QBT_TR[CONTEXT=downloadFromURL]",
1646                     loadMethod: "iframe",
1647                     contentURL: contentURI.toString(),
1648                     addClass: "windowFrame", // fixes iframe scrolling on iOS Safari
1649                     scrollbars: true,
1650                     maximizable: false,
1651                     closable: true,
1652                     paddingVertical: 0,
1653                     paddingHorizontal: 0,
1654                     width: loadWindowWidth(id, 500),
1655                     height: loadWindowHeight(id, 600),
1656                     onResize: () => {
1657                         saveWindowSize(id);
1658                     }
1659                 });
1660             }
1661         });
1662     };
1663     registerDragAndDrop();
1665     new Keyboard({
1666         defaultEventType: "keydown",
1667         events: {
1668             "ctrl+a": function(event) {
1669                 if ((event.target.nodeName === "INPUT") || (event.target.nodeName === "TEXTAREA"))
1670                     return;
1671                 if (event.target.isContentEditable)
1672                     return;
1673                 torrentsTable.selectAll();
1674                 event.preventDefault();
1675             },
1676             "delete": function(event) {
1677                 if ((event.target.nodeName === "INPUT") || (event.target.nodeName === "TEXTAREA"))
1678                     return;
1679                 if (event.target.isContentEditable)
1680                     return;
1681                 deleteFN();
1682                 event.preventDefault();
1683             },
1684             "shift+delete": (event) => {
1685                 if ((event.target.nodeName === "INPUT") || (event.target.nodeName === "TEXTAREA"))
1686                     return;
1687                 if (event.target.isContentEditable)
1688                     return;
1689                 deleteFN(true);
1690                 event.preventDefault();
1691             }
1692         }
1693     }).activate();
1696 window.addEventListener("load", () => {
1697     // fetch various data and store it in memory
1698     window.qBittorrent.Cache.buildInfo.init();
1699     window.qBittorrent.Cache.preferences.init();
1700     window.qBittorrent.Cache.qbtVersion.init();
1702     // switch to previously used tab
1703     const previouslyUsedTab = LocalPreferences.get("selected_window_tab", "transfers");
1704     switch (previouslyUsedTab) {
1705         case "search":
1706             if (window.qBittorrent.Client.isShowSearchEngine())
1707                 $("searchTabLink").click();
1708             break;
1709         case "rss":
1710             if (window.qBittorrent.Client.isShowRssReader())
1711                 $("rssTabLink").click();
1712             break;
1713         case "log":
1714             if (window.qBittorrent.Client.isShowLogViewer())
1715                 $("logTabLink").click();
1716             break;
1717         case "transfers":
1718             $("transfersTabLink").click();
1719             break;
1720         default:
1721             console.error(`Unexpected 'selected_window_tab' value: ${previouslyUsedTab}`);
1722             $("transfersTabLink").click();
1723             break;
1724     };