WebUI: Improve hash copy actions in context menu
[qBittorrent.git] / src / webui / www / private / scripts / client.js
blob80f2e7a556197f3bd6bcf6399b7692fcee0e022a
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.rows.size;
487         let uncategorized = 0;
488         for (const { full_data: { category } } of torrentsTable.rows.values()) {
489             if (category.length === 0)
490                 uncategorized += 1;
491         }
493         const sortedCategories = [];
494         category_list.forEach((category, hash) => sortedCategories.push({
495             categoryName: category.name,
496             categoryHash: hash,
497             categoryCount: category.torrents.size,
498             nameSegments: category.name.split("/"),
499             ...(useSubcategories && {
500                 children: [],
501                 parentID: null,
502                 forceExpand: LocalPreferences.get(`category_${hash}_collapsed`) === null
503             })
504         }));
505         sortedCategories.sort((left, right) => {
506             const leftSegments = left.nameSegments;
507             const rightSegments = right.nameSegments;
509             for (let i = 0, iMax = Math.min(leftSegments.length, rightSegments.length); i < iMax; ++i) {
510                 const compareResult = window.qBittorrent.Misc.naturalSortCollator.compare(
511                     leftSegments[i], rightSegments[i]);
512                 if (compareResult !== 0)
513                     return compareResult;
514             }
516             return leftSegments.length - rightSegments.length;
517         });
519         const categoriesFragment = new DocumentFragment();
520         categoriesFragment.appendChild(createCategoryLink(CATEGORIES_ALL, "QBT_TR(All)QBT_TR[CONTEXT=CategoryFilterModel]", all));
521         categoriesFragment.appendChild(createCategoryLink(CATEGORIES_UNCATEGORIZED, "QBT_TR(Uncategorized)QBT_TR[CONTEXT=CategoryFilterModel]", uncategorized));
523         if (useSubcategories) {
524             categoryList.classList.add("subcategories");
525             for (let i = 0; i < sortedCategories.length; ++i) {
526                 const category = sortedCategories[i];
527                 for (let j = (i + 1);
528                     ((j < sortedCategories.length) && sortedCategories[j].categoryName.startsWith(`${category.categoryName}/`)); ++j) {
529                     const subcategory = sortedCategories[j];
530                     category.categoryCount += subcategory.categoryCount;
531                     category.forceExpand ||= subcategory.forceExpand;
533                     const isDirectSubcategory = (subcategory.nameSegments.length - category.nameSegments.length) === 1;
534                     if (isDirectSubcategory) {
535                         subcategory.parentID = category.categoryHash;
536                         category.children.push(subcategory);
537                     }
538                 }
539             }
540             for (const category of sortedCategories) {
541                 if (category.parentID === null)
542                     createCategoryTree(category);
543             }
544         }
545         else {
546             categoryList.classList.remove("subcategories");
547             for (const { categoryHash, categoryName, categoryCount } of sortedCategories)
548                 categoriesFragment.appendChild(createCategoryLink(categoryHash, categoryName, categoryCount));
549         }
551         categoryList.appendChild(categoriesFragment);
552         window.qBittorrent.Filters.categoriesFilterContextMenu.searchAndAddTargets();
553     };
555     const highlightSelectedCategory = function() {
556         const categoryList = document.getElementById("categoryFilterList");
557         if (!categoryList)
558             return;
560         for (const category of categoryList.getElementsByTagName("li"))
561             category.classList.toggle("selectedFilter", (Number(category.id) === selectedCategory));
562     };
564     const updateTagList = function() {
565         const tagFilterList = $("tagFilterList");
566         if (tagFilterList === null)
567             return;
569         tagFilterList.getChildren().each(c => c.destroy());
571         const tagItemTemplate = document.getElementById("tagFilterItem");
573         const createLink = function(hash, text, count) {
574             const tagFilterItem = tagItemTemplate.content.cloneNode(true).firstElementChild;
575             tagFilterItem.id = hash;
576             tagFilterItem.classList.toggle("selectedFilter", hash === selectedTag);
578             const span = tagFilterItem.firstElementChild;
579             span.lastChild.textContent = `${text} (${count})`;
581             return tagFilterItem;
582         };
584         const torrentsCount = torrentsTable.rows.size;
585         let untagged = 0;
586         for (const { full_data: { tags } } of torrentsTable.rows.values()) {
587             if (tags.length === 0)
588                 untagged += 1;
589         }
591         tagFilterList.appendChild(createLink(TAGS_ALL, "QBT_TR(All)QBT_TR[CONTEXT=TagFilterModel]", torrentsCount));
592         tagFilterList.appendChild(createLink(TAGS_UNTAGGED, "QBT_TR(Untagged)QBT_TR[CONTEXT=TagFilterModel]", untagged));
594         const sortedTags = [];
595         tagList.forEach((tag, hash) => sortedTags.push({
596             tagName: tag.name,
597             tagHash: hash,
598             tagSize: tag.torrents.size
599         }));
600         sortedTags.sort((left, right) => window.qBittorrent.Misc.naturalSortCollator.compare(left.tagName, right.tagName));
602         for (const { tagName, tagHash, tagSize } of sortedTags)
603             tagFilterList.appendChild(createLink(tagHash, tagName, tagSize));
605         window.qBittorrent.Filters.tagsFilterContextMenu.searchAndAddTargets();
606     };
608     const highlightSelectedTag = function() {
609         const tagFilterList = document.getElementById("tagFilterList");
610         if (!tagFilterList)
611             return;
613         for (const tag of tagFilterList.children)
614             tag.classList.toggle("selectedFilter", (Number(tag.id) === selectedTag));
615     };
617     const updateTrackerList = function() {
618         const trackerFilterList = $("trackerFilterList");
619         if (trackerFilterList === null)
620             return;
622         trackerFilterList.getChildren().each(c => c.destroy());
624         const trackerItemTemplate = document.getElementById("trackerFilterItem");
626         const createLink = function(hash, text, count) {
627             const trackerFilterItem = trackerItemTemplate.content.cloneNode(true).firstElementChild;
628             trackerFilterItem.id = hash;
629             trackerFilterItem.classList.toggle("selectedFilter", hash === selectedTracker);
631             const span = trackerFilterItem.firstElementChild;
632             span.lastChild.textContent = text.replace("%1", count);
634             return trackerFilterItem;
635         };
637         const torrentsCount = torrentsTable.rows.size;
638         let trackerlessTorrentsCount = 0;
639         for (const { full_data: { trackers_count: trackersCount } } of torrentsTable.rows.values()) {
640             if (trackersCount === 0)
641                 trackerlessTorrentsCount += 1;
642         }
644         trackerFilterList.appendChild(createLink(TRACKERS_ALL, "QBT_TR(All (%1))QBT_TR[CONTEXT=TrackerFiltersList]", torrentsCount));
645         trackerFilterList.appendChild(createLink(TRACKERS_TRACKERLESS, "QBT_TR(Trackerless (%1))QBT_TR[CONTEXT=TrackerFiltersList]", trackerlessTorrentsCount));
647         // Sort trackers by hostname
648         const sortedList = [];
649         trackerList.forEach(({ host, trackerTorrentMap }, hash) => {
650             const uniqueTorrents = new Set();
651             for (const torrents of trackerTorrentMap.values()) {
652                 for (const torrent of torrents)
653                     uniqueTorrents.add(torrent);
654             }
656             sortedList.push({
657                 trackerHost: host,
658                 trackerHash: hash,
659                 trackerCount: uniqueTorrents.size,
660             });
661         });
662         sortedList.sort((left, right) => window.qBittorrent.Misc.naturalSortCollator.compare(left.trackerHost, right.trackerHost));
663         for (const { trackerHost, trackerHash, trackerCount } of sortedList)
664             trackerFilterList.appendChild(createLink(trackerHash, (trackerHost + " (%1)"), trackerCount));
666         window.qBittorrent.Filters.trackersFilterContextMenu.searchAndAddTargets();
667     };
669     const highlightSelectedTracker = function() {
670         const trackerFilterList = document.getElementById("trackerFilterList");
671         if (!trackerFilterList)
672             return;
674         for (const tracker of trackerFilterList.children)
675             tracker.classList.toggle("selectedFilter", (Number(tracker.id) === selectedTracker));
676     };
678     const setupCopyEventHandler = (function() {
679         let clipboardEvent;
681         return () => {
682             if (clipboardEvent)
683                 clipboardEvent.destroy();
685             clipboardEvent = new ClipboardJS(".copyToClipboard", {
686                 text: function(trigger) {
687                     switch (trigger.id) {
688                         case "copyName":
689                             return copyNameFN();
690                         case "copyInfohash1":
691                             return copyInfohashFN(1);
692                         case "copyInfohash2":
693                             return copyInfohashFN(2);
694                         case "copyMagnetLink":
695                             return copyMagnetLinkFN();
696                         case "copyID":
697                             return copyIdFN();
698                         case "copyComment":
699                             return copyCommentFN();
700                         default:
701                             return "";
702                     }
703                 }
704             });
705         };
706     })();
708     let syncMainDataTimeoutID = -1;
709     let syncRequestInProgress = false;
710     const syncMainData = function() {
711         const url = new URI("api/v2/sync/maindata");
712         url.setData("rid", syncMainDataLastResponseId);
713         const request = new Request.JSON({
714             url: url,
715             noCache: true,
716             method: "get",
717             onFailure: function() {
718                 const errorDiv = $("error_div");
719                 if (errorDiv)
720                     errorDiv.textContent = "QBT_TR(qBittorrent client is not reachable)QBT_TR[CONTEXT=HttpServer]";
721                 syncRequestInProgress = false;
722                 syncData(2000);
723             },
724             onSuccess: function(response) {
725                 $("error_div").textContent = "";
726                 if (response) {
727                     clearTimeout(torrentsFilterInputTimer);
728                     torrentsFilterInputTimer = -1;
730                     let torrentsTableSelectedRows;
731                     let update_categories = false;
732                     let updateTags = false;
733                     let updateTrackers = false;
734                     const full_update = (response["full_update"] === true);
735                     if (full_update) {
736                         torrentsTableSelectedRows = torrentsTable.selectedRowsIds();
737                         update_categories = true;
738                         updateTags = true;
739                         updateTrackers = true;
740                         torrentsTable.clear();
741                         category_list.clear();
742                         tagList.clear();
743                         trackerList.clear();
744                     }
745                     if (response["rid"])
746                         syncMainDataLastResponseId = response["rid"];
747                     if (response["categories"]) {
748                         for (const key in response["categories"]) {
749                             if (!Object.hasOwn(response["categories"], key))
750                                 continue;
752                             const responseCategory = response["categories"][key];
753                             const categoryHash = window.qBittorrent.Misc.genHash(key);
754                             const category = category_list.get(categoryHash);
755                             if (category !== undefined) {
756                                 // only the save path can change for existing categories
757                                 category.savePath = responseCategory.savePath;
758                             }
759                             else {
760                                 category_list.set(categoryHash, {
761                                     name: responseCategory.name,
762                                     savePath: responseCategory.savePath,
763                                     torrents: new Set()
764                                 });
765                             }
766                         }
767                         update_categories = true;
768                     }
769                     if (response["categories_removed"]) {
770                         response["categories_removed"].each((category) => {
771                             const categoryHash = window.qBittorrent.Misc.genHash(category);
772                             category_list.delete(categoryHash);
773                         });
774                         update_categories = true;
775                     }
776                     if (response["tags"]) {
777                         for (const tag of response["tags"]) {
778                             const tagHash = window.qBittorrent.Misc.genHash(tag);
779                             if (!tagList.has(tagHash)) {
780                                 tagList.set(tagHash, {
781                                     name: tag,
782                                     torrents: new Set()
783                                 });
784                             }
785                         }
786                         updateTags = true;
787                     }
788                     if (response["tags_removed"]) {
789                         for (let i = 0; i < response["tags_removed"].length; ++i) {
790                             const tagHash = window.qBittorrent.Misc.genHash(response["tags_removed"][i]);
791                             tagList.delete(tagHash);
792                         }
793                         updateTags = true;
794                     }
795                     if (response["trackers"]) {
796                         for (const [tracker, torrents] of Object.entries(response["trackers"])) {
797                             const host = window.qBittorrent.Misc.getHost(tracker);
798                             const hash = window.qBittorrent.Misc.genHash(host);
800                             let trackerListItem = trackerList.get(hash);
801                             if (trackerListItem === undefined) {
802                                 trackerListItem = { host: host, trackerTorrentMap: new Map() };
803                                 trackerList.set(hash, trackerListItem);
804                             }
806                             trackerListItem.trackerTorrentMap.set(tracker, [...torrents]);
807                         }
808                         updateTrackers = true;
809                     }
810                     if (response["trackers_removed"]) {
811                         for (let i = 0; i < response["trackers_removed"].length; ++i) {
812                             const tracker = response["trackers_removed"][i];
813                             const host = window.qBittorrent.Misc.getHost(tracker);
814                             const hash = window.qBittorrent.Misc.genHash(host);
815                             const trackerListEntry = trackerList.get(hash);
816                             if (trackerListEntry)
817                                 trackerListEntry.trackerTorrentMap.delete(tracker);
818                         }
819                         updateTrackers = true;
820                     }
821                     if (response["torrents"]) {
822                         let updateTorrentList = false;
823                         for (const key in response["torrents"]) {
824                             if (!Object.hasOwn(response["torrents"], key))
825                                 continue;
827                             response["torrents"][key]["hash"] = key;
828                             response["torrents"][key]["rowId"] = key;
829                             if (response["torrents"][key]["state"])
830                                 response["torrents"][key]["status"] = response["torrents"][key]["state"];
831                             torrentsTable.updateRowData(response["torrents"][key]);
832                             if (addTorrentToCategoryList(response["torrents"][key]))
833                                 update_categories = true;
834                             if (addTorrentToTagList(response["torrents"][key]))
835                                 updateTags = true;
836                             if (response["torrents"][key]["name"])
837                                 updateTorrentList = true;
838                         }
840                         if (updateTorrentList)
841                             setupCopyEventHandler();
842                     }
843                     if (response["torrents_removed"]) {
844                         response["torrents_removed"].each((hash) => {
845                             torrentsTable.removeRow(hash);
846                             removeTorrentFromCategoryList(hash);
847                             update_categories = true; // Always to update All category
848                             removeTorrentFromTagList(hash);
849                             updateTags = true; // Always to update All tag
850                         });
851                     }
852                     torrentsTable.updateTable(full_update);
853                     if (response["server_state"]) {
854                         const tmp = response["server_state"];
855                         for (const k in tmp) {
856                             if (!Object.hasOwn(tmp, k))
857                                 continue;
858                             serverState[k] = tmp[k];
859                         }
860                         processServerState();
861                     }
862                     updateFiltersList();
863                     if (update_categories) {
864                         updateCategoryList();
865                         window.qBittorrent.TransferList.contextMenu.updateCategoriesSubMenu(category_list);
866                     }
867                     if (updateTags) {
868                         updateTagList();
869                         window.qBittorrent.TransferList.contextMenu.updateTagsSubMenu(tagList);
870                     }
871                     if (updateTrackers)
872                         updateTrackerList();
874                     if (full_update)
875                         // re-select previously selected rows
876                         torrentsTable.reselectRows(torrentsTableSelectedRows);
877                 }
878                 syncRequestInProgress = false;
879                 syncData(window.qBittorrent.Client.getSyncMainDataInterval());
880             }
881         });
882         syncRequestInProgress = true;
883         request.send();
884     };
886     updateMainData = function() {
887         torrentsTable.updateTable();
888         syncData(100);
889     };
891     const syncData = function(delay) {
892         if (syncRequestInProgress)
893             return;
895         clearTimeout(syncMainDataTimeoutID);
896         syncMainDataTimeoutID = -1;
898         if (window.qBittorrent.Client.isStopped())
899             return;
901         syncMainDataTimeoutID = syncMainData.delay(delay);
902     };
904     const processServerState = function() {
905         let transfer_info = window.qBittorrent.Misc.friendlyUnit(serverState.dl_info_speed, true);
906         if (serverState.dl_rate_limit > 0)
907             transfer_info += " [" + window.qBittorrent.Misc.friendlyUnit(serverState.dl_rate_limit, true) + "]";
908         transfer_info += " (" + window.qBittorrent.Misc.friendlyUnit(serverState.dl_info_data, false) + ")";
909         $("DlInfos").textContent = transfer_info;
910         transfer_info = window.qBittorrent.Misc.friendlyUnit(serverState.up_info_speed, true);
911         if (serverState.up_rate_limit > 0)
912             transfer_info += " [" + window.qBittorrent.Misc.friendlyUnit(serverState.up_rate_limit, true) + "]";
913         transfer_info += " (" + window.qBittorrent.Misc.friendlyUnit(serverState.up_info_data, false) + ")";
914         $("UpInfos").textContent = transfer_info;
916         document.title = (speedInTitle
917                 ? (`QBT_TR([D: %1, U: %2])QBT_TR[CONTEXT=MainWindow] `
918                     .replace("%1", window.qBittorrent.Misc.friendlyUnit(serverState.dl_info_speed, true))
919                     .replace("%2", window.qBittorrent.Misc.friendlyUnit(serverState.up_info_speed, true)))
920                 : "")
921             + window.qBittorrent.Client.mainTitle();
923         $("freeSpaceOnDisk").textContent = "QBT_TR(Free space: %1)QBT_TR[CONTEXT=HttpServer]".replace("%1", window.qBittorrent.Misc.friendlyUnit(serverState.free_space_on_disk));
924         $("DHTNodes").textContent = "QBT_TR(DHT: %1 nodes)QBT_TR[CONTEXT=StatusBar]".replace("%1", serverState.dht_nodes);
926         // Statistics dialog
927         if (document.getElementById("statisticsContent")) {
928             $("AlltimeDL").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.alltime_dl, false);
929             $("AlltimeUL").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.alltime_ul, false);
930             $("TotalWastedSession").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.total_wasted_session, false);
931             $("GlobalRatio").textContent = serverState.global_ratio;
932             $("TotalPeerConnections").textContent = serverState.total_peer_connections;
933             $("ReadCacheHits").textContent = serverState.read_cache_hits + "%";
934             $("TotalBuffersSize").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.total_buffers_size, false);
935             $("WriteCacheOverload").textContent = serverState.write_cache_overload + "%";
936             $("ReadCacheOverload").textContent = serverState.read_cache_overload + "%";
937             $("QueuedIOJobs").textContent = serverState.queued_io_jobs;
938             $("AverageTimeInQueue").textContent = serverState.average_time_queue + " ms";
939             $("TotalQueuedSize").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.total_queued_size, false);
940         }
942         switch (serverState.connection_status) {
943             case "connected":
944                 $("connectionStatus").src = "images/connected.svg";
945                 $("connectionStatus").alt = "QBT_TR(Connection status: Connected)QBT_TR[CONTEXT=MainWindow]";
946                 $("connectionStatus").title = "QBT_TR(Connection status: Connected)QBT_TR[CONTEXT=MainWindow]";
947                 break;
948             case "firewalled":
949                 $("connectionStatus").src = "images/firewalled.svg";
950                 $("connectionStatus").alt = "QBT_TR(Connection status: Firewalled)QBT_TR[CONTEXT=MainWindow]";
951                 $("connectionStatus").title = "QBT_TR(Connection status: Firewalled)QBT_TR[CONTEXT=MainWindow]";
952                 break;
953             default:
954                 $("connectionStatus").src = "images/disconnected.svg";
955                 $("connectionStatus").alt = "QBT_TR(Connection status: Disconnected)QBT_TR[CONTEXT=MainWindow]";
956                 $("connectionStatus").title = "QBT_TR(Connection status: Disconnected)QBT_TR[CONTEXT=MainWindow]";
957                 break;
958         }
960         if (queueing_enabled !== serverState.queueing) {
961             queueing_enabled = serverState.queueing;
962             torrentsTable.columns["priority"].force_hide = !queueing_enabled;
963             torrentsTable.updateColumn("priority");
964             if (queueing_enabled) {
965                 $("topQueuePosItem").removeClass("invisible");
966                 $("increaseQueuePosItem").removeClass("invisible");
967                 $("decreaseQueuePosItem").removeClass("invisible");
968                 $("bottomQueuePosItem").removeClass("invisible");
969                 $("queueingButtons").removeClass("invisible");
970                 $("queueingMenuItems").removeClass("invisible");
971             }
972             else {
973                 $("topQueuePosItem").addClass("invisible");
974                 $("increaseQueuePosItem").addClass("invisible");
975                 $("decreaseQueuePosItem").addClass("invisible");
976                 $("bottomQueuePosItem").addClass("invisible");
977                 $("queueingButtons").addClass("invisible");
978                 $("queueingMenuItems").addClass("invisible");
979             }
980         }
982         if (alternativeSpeedLimits !== serverState.use_alt_speed_limits) {
983             alternativeSpeedLimits = serverState.use_alt_speed_limits;
984             updateAltSpeedIcon(alternativeSpeedLimits);
985         }
987         if (useSubcategories !== serverState.use_subcategories) {
988             useSubcategories = serverState.use_subcategories;
989             updateCategoryList();
990         }
992         serverSyncMainDataInterval = Math.max(serverState.refresh_interval, 500);
993     };
995     const updateAltSpeedIcon = function(enabled) {
996         if (enabled) {
997             $("alternativeSpeedLimits").src = "images/slow.svg";
998             $("alternativeSpeedLimits").alt = "QBT_TR(Alternative speed limits: On)QBT_TR[CONTEXT=MainWindow]";
999             $("alternativeSpeedLimits").title = "QBT_TR(Alternative speed limits: On)QBT_TR[CONTEXT=MainWindow]";
1000         }
1001         else {
1002             $("alternativeSpeedLimits").src = "images/slow_off.svg";
1003             $("alternativeSpeedLimits").alt = "QBT_TR(Alternative speed limits: Off)QBT_TR[CONTEXT=MainWindow]";
1004             $("alternativeSpeedLimits").title = "QBT_TR(Alternative speed limits: Off)QBT_TR[CONTEXT=MainWindow]";
1005         }
1006     };
1008     $("alternativeSpeedLimits").addEventListener("click", () => {
1009         // Change icon immediately to give some feedback
1010         updateAltSpeedIcon(!alternativeSpeedLimits);
1012         new Request({
1013             url: "api/v2/transfer/toggleSpeedLimitsMode",
1014             method: "post",
1015             onComplete: function() {
1016                 alternativeSpeedLimits = !alternativeSpeedLimits;
1017                 updateMainData();
1018             },
1019             onFailure: function() {
1020                 // Restore icon in case of failure
1021                 updateAltSpeedIcon(alternativeSpeedLimits);
1022             }
1023         }).send();
1024     });
1026     $("DlInfos").addEventListener("click", () => { globalDownloadLimitFN(); });
1027     $("UpInfos").addEventListener("click", () => { globalUploadLimitFN(); });
1029     $("showTopToolbarLink").addEventListener("click", (e) => {
1030         showTopToolbar = !showTopToolbar;
1031         LocalPreferences.set("show_top_toolbar", showTopToolbar.toString());
1032         if (showTopToolbar) {
1033             $("showTopToolbarLink").firstChild.style.opacity = "1";
1034             $("mochaToolbar").removeClass("invisible");
1035         }
1036         else {
1037             $("showTopToolbarLink").firstChild.style.opacity = "0";
1038             $("mochaToolbar").addClass("invisible");
1039         }
1040         MochaUI.Desktop.setDesktopSize();
1041     });
1043     $("showStatusBarLink").addEventListener("click", (e) => {
1044         showStatusBar = !showStatusBar;
1045         LocalPreferences.set("show_status_bar", showStatusBar.toString());
1046         if (showStatusBar) {
1047             $("showStatusBarLink").firstChild.style.opacity = "1";
1048             $("desktopFooterWrapper").removeClass("invisible");
1049         }
1050         else {
1051             $("showStatusBarLink").firstChild.style.opacity = "0";
1052             $("desktopFooterWrapper").addClass("invisible");
1053         }
1054         MochaUI.Desktop.setDesktopSize();
1055     });
1057     const registerMagnetHandler = function() {
1058         if (typeof navigator.registerProtocolHandler !== "function") {
1059             if (window.location.protocol !== "https:")
1060                 alert("QBT_TR(To use this feature, the WebUI needs to be accessed over HTTPS)QBT_TR[CONTEXT=MainWindow]");
1061             else
1062                 alert("QBT_TR(Your browser does not support this feature)QBT_TR[CONTEXT=MainWindow]");
1063             return;
1064         }
1066         const hashString = location.hash ? location.hash.replace(/^#/, "") : "";
1067         const hashParams = new URLSearchParams(hashString);
1068         hashParams.set("download", "");
1070         const templateHashString = hashParams.toString().replace("download=", "download=%s");
1071         const templateUrl = location.origin + location.pathname
1072             + location.search + "#" + templateHashString;
1074         navigator.registerProtocolHandler("magnet", templateUrl,
1075             "qBittorrent WebUI magnet handler");
1076     };
1077     $("registerMagnetHandlerLink").addEventListener("click", (e) => {
1078         registerMagnetHandler();
1079     });
1081     $("showFiltersSidebarLink").addEventListener("click", (e) => {
1082         showFiltersSidebar = !showFiltersSidebar;
1083         LocalPreferences.set("show_filters_sidebar", showFiltersSidebar.toString());
1084         if (showFiltersSidebar) {
1085             $("showFiltersSidebarLink").firstChild.style.opacity = "1";
1086             $("filtersColumn").removeClass("invisible");
1087             $("filtersColumn_handle").removeClass("invisible");
1088         }
1089         else {
1090             $("showFiltersSidebarLink").firstChild.style.opacity = "0";
1091             $("filtersColumn").addClass("invisible");
1092             $("filtersColumn_handle").addClass("invisible");
1093         }
1094         MochaUI.Desktop.setDesktopSize();
1095     });
1097     $("speedInBrowserTitleBarLink").addEventListener("click", (e) => {
1098         speedInTitle = !speedInTitle;
1099         LocalPreferences.set("speed_in_browser_title_bar", speedInTitle.toString());
1100         if (speedInTitle)
1101             $("speedInBrowserTitleBarLink").firstChild.style.opacity = "1";
1102         else
1103             $("speedInBrowserTitleBarLink").firstChild.style.opacity = "0";
1104         processServerState();
1105     });
1107     $("showSearchEngineLink").addEventListener("click", (e) => {
1108         window.qBittorrent.Client.showSearchEngine(!window.qBittorrent.Client.isShowSearchEngine());
1109         LocalPreferences.set("show_search_engine", window.qBittorrent.Client.isShowSearchEngine().toString());
1110         updateTabDisplay();
1111     });
1113     $("showRssReaderLink").addEventListener("click", (e) => {
1114         window.qBittorrent.Client.showRssReader(!window.qBittorrent.Client.isShowRssReader());
1115         LocalPreferences.set("show_rss_reader", window.qBittorrent.Client.isShowRssReader().toString());
1116         updateTabDisplay();
1117     });
1119     $("showLogViewerLink").addEventListener("click", (e) => {
1120         window.qBittorrent.Client.showLogViewer(!window.qBittorrent.Client.isShowLogViewer());
1121         LocalPreferences.set("show_log_viewer", window.qBittorrent.Client.isShowLogViewer().toString());
1122         updateTabDisplay();
1123     });
1125     const updateTabDisplay = function() {
1126         if (window.qBittorrent.Client.isShowRssReader()) {
1127             $("showRssReaderLink").firstChild.style.opacity = "1";
1128             $("mainWindowTabs").removeClass("invisible");
1129             $("rssTabLink").removeClass("invisible");
1130             if (!MochaUI.Panels.instances.RssPanel)
1131                 addRssPanel();
1132         }
1133         else {
1134             $("showRssReaderLink").firstChild.style.opacity = "0";
1135             $("rssTabLink").addClass("invisible");
1136             if ($("rssTabLink").hasClass("selected"))
1137                 $("transfersTabLink").click();
1138         }
1140         if (window.qBittorrent.Client.isShowSearchEngine()) {
1141             $("showSearchEngineLink").firstChild.style.opacity = "1";
1142             $("mainWindowTabs").removeClass("invisible");
1143             $("searchTabLink").removeClass("invisible");
1144             if (!MochaUI.Panels.instances.SearchPanel)
1145                 addSearchPanel();
1146         }
1147         else {
1148             $("showSearchEngineLink").firstChild.style.opacity = "0";
1149             $("searchTabLink").addClass("invisible");
1150             if ($("searchTabLink").hasClass("selected"))
1151                 $("transfersTabLink").click();
1152         }
1154         if (window.qBittorrent.Client.isShowLogViewer()) {
1155             $("showLogViewerLink").firstChild.style.opacity = "1";
1156             $("mainWindowTabs").removeClass("invisible");
1157             $("logTabLink").removeClass("invisible");
1158             if (!MochaUI.Panels.instances.LogPanel)
1159                 addLogPanel();
1160         }
1161         else {
1162             $("showLogViewerLink").firstChild.style.opacity = "0";
1163             $("logTabLink").addClass("invisible");
1164             if ($("logTabLink").hasClass("selected"))
1165                 $("transfersTabLink").click();
1166         }
1168         // display no tabs
1169         if (!window.qBittorrent.Client.isShowRssReader() && !window.qBittorrent.Client.isShowSearchEngine() && !window.qBittorrent.Client.isShowLogViewer())
1170             $("mainWindowTabs").addClass("invisible");
1171     };
1173     $("StatisticsLink").addEventListener("click", () => { StatisticsLinkFN(); });
1175     // main window tabs
1177     const showTransfersTab = function() {
1178         const showFiltersSidebar = LocalPreferences.get("show_filters_sidebar", "true") === "true";
1179         if (showFiltersSidebar) {
1180             $("filtersColumn").removeClass("invisible");
1181             $("filtersColumn_handle").removeClass("invisible");
1182         }
1183         $("mainColumn").removeClass("invisible");
1184         $("torrentsFilterToolbar").removeClass("invisible");
1186         customSyncMainDataInterval = null;
1187         syncData(100);
1189         hideSearchTab();
1190         hideRssTab();
1191         hideLogTab();
1193         LocalPreferences.set("selected_window_tab", "transfers");
1194     };
1196     const hideTransfersTab = function() {
1197         $("filtersColumn").addClass("invisible");
1198         $("filtersColumn_handle").addClass("invisible");
1199         $("mainColumn").addClass("invisible");
1200         $("torrentsFilterToolbar").addClass("invisible");
1201         MochaUI.Desktop.resizePanels();
1202     };
1204     const showSearchTab = (function() {
1205         let searchTabInitialized = false;
1207         return () => {
1208             // we must wait until the panel is fully loaded before proceeding.
1209             // this include's the panel's custom js, which is loaded via MochaUI.Panel's 'require' field.
1210             // MochaUI loads these files asynchronously and thus all required libs may not be available immediately
1211             if (!isSearchPanelLoaded) {
1212                 setTimeout(() => {
1213                     showSearchTab();
1214                 }, 100);
1215                 return;
1216             }
1218             if (!searchTabInitialized) {
1219                 window.qBittorrent.Search.init();
1220                 searchTabInitialized = true;
1221             }
1223             $("searchTabColumn").removeClass("invisible");
1224             customSyncMainDataInterval = 30000;
1225             hideTransfersTab();
1226             hideRssTab();
1227             hideLogTab();
1229             LocalPreferences.set("selected_window_tab", "search");
1230         };
1231     })();
1233     const hideSearchTab = function() {
1234         $("searchTabColumn").addClass("invisible");
1235         MochaUI.Desktop.resizePanels();
1236     };
1238     const showRssTab = (function() {
1239         let rssTabInitialized = false;
1241         return () => {
1242             if (!rssTabInitialized) {
1243                 window.qBittorrent.Rss.init();
1244                 rssTabInitialized = true;
1245             }
1246             else {
1247                 window.qBittorrent.Rss.load();
1248             }
1250             $("rssTabColumn").removeClass("invisible");
1251             customSyncMainDataInterval = 30000;
1252             hideTransfersTab();
1253             hideSearchTab();
1254             hideLogTab();
1256             LocalPreferences.set("selected_window_tab", "rss");
1257         };
1258     })();
1260     const hideRssTab = function() {
1261         $("rssTabColumn").addClass("invisible");
1262         window.qBittorrent.Rss && window.qBittorrent.Rss.unload();
1263         MochaUI.Desktop.resizePanels();
1264     };
1266     const showLogTab = (function() {
1267         let logTabInitialized = false;
1269         return () => {
1270             // we must wait until the panel is fully loaded before proceeding.
1271             // this include's the panel's custom js, which is loaded via MochaUI.Panel's 'require' field.
1272             // MochaUI loads these files asynchronously and thus all required libs may not be available immediately
1273             if (!isLogPanelLoaded) {
1274                 setTimeout(() => {
1275                     showLogTab();
1276                 }, 100);
1277                 return;
1278             }
1280             if (!logTabInitialized) {
1281                 window.qBittorrent.Log.init();
1282                 logTabInitialized = true;
1283             }
1284             else {
1285                 window.qBittorrent.Log.load();
1286             }
1288             $("logTabColumn").removeClass("invisible");
1289             customSyncMainDataInterval = 30000;
1290             hideTransfersTab();
1291             hideSearchTab();
1292             hideRssTab();
1294             LocalPreferences.set("selected_window_tab", "log");
1295         };
1296     })();
1298     const hideLogTab = function() {
1299         $("logTabColumn").addClass("invisible");
1300         MochaUI.Desktop.resizePanels();
1301         window.qBittorrent.Log && window.qBittorrent.Log.unload();
1302     };
1304     const addSearchPanel = function() {
1305         new MochaUI.Panel({
1306             id: "SearchPanel",
1307             title: "Search",
1308             header: false,
1309             padding: {
1310                 top: 0,
1311                 right: 0,
1312                 bottom: 0,
1313                 left: 0
1314             },
1315             loadMethod: "xhr",
1316             contentURL: "views/search.html",
1317             require: {
1318                 js: ["scripts/search.js"],
1319                 onload: () => {
1320                     isSearchPanelLoaded = true;
1321                 },
1322             },
1323             content: "",
1324             column: "searchTabColumn",
1325             height: null
1326         });
1327     };
1329     const addRssPanel = function() {
1330         new MochaUI.Panel({
1331             id: "RssPanel",
1332             title: "Rss",
1333             header: false,
1334             padding: {
1335                 top: 0,
1336                 right: 0,
1337                 bottom: 0,
1338                 left: 0
1339             },
1340             loadMethod: "xhr",
1341             contentURL: "views/rss.html",
1342             content: "",
1343             column: "rssTabColumn",
1344             height: null
1345         });
1346     };
1348     const addLogPanel = function() {
1349         new MochaUI.Panel({
1350             id: "LogPanel",
1351             title: "Log",
1352             header: true,
1353             padding: {
1354                 top: 0,
1355                 right: 0,
1356                 bottom: 0,
1357                 left: 0
1358             },
1359             loadMethod: "xhr",
1360             contentURL: "views/log.html",
1361             require: {
1362                 css: ["css/vanillaSelectBox.css"],
1363                 js: ["scripts/lib/vanillaSelectBox.js"],
1364                 onload: () => {
1365                     isLogPanelLoaded = true;
1366                 },
1367             },
1368             tabsURL: "views/logTabs.html",
1369             tabsOnload: function() {
1370                 MochaUI.initializeTabs("panelTabs");
1372                 $("logMessageLink").addEventListener("click", (e) => {
1373                     window.qBittorrent.Log.setCurrentTab("main");
1374                 });
1376                 $("logPeerLink").addEventListener("click", (e) => {
1377                     window.qBittorrent.Log.setCurrentTab("peer");
1378                 });
1379             },
1380             collapsible: false,
1381             content: "",
1382             column: "logTabColumn",
1383             height: null
1384         });
1385     };
1387     const handleDownloadParam = function() {
1388         // Extract torrent URL from download param in WebUI URL hash
1389         const downloadHash = "#download=";
1390         if (location.hash.indexOf(downloadHash) !== 0)
1391             return;
1393         const url = decodeURIComponent(location.hash.substring(downloadHash.length));
1394         // Remove the processed hash from the URL
1395         history.replaceState("", document.title, (location.pathname + location.search));
1396         showDownloadPage([url]);
1397     };
1399     new MochaUI.Panel({
1400         id: "transferList",
1401         title: "Panel",
1402         header: false,
1403         padding: {
1404             top: 0,
1405             right: 0,
1406             bottom: 0,
1407             left: 0
1408         },
1409         loadMethod: "xhr",
1410         contentURL: "views/transferlist.html",
1411         onContentLoaded: function() {
1412             handleDownloadParam();
1413             updateMainData();
1414         },
1415         column: "mainColumn",
1416         onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
1417             saveColumnSizes();
1418         }),
1419         height: null
1420     });
1421     let prop_h = LocalPreferences.get("properties_height_rel");
1422     if (prop_h !== null)
1423         prop_h = prop_h.toFloat() * Window.getSize().y;
1424     else
1425         prop_h = Window.getSize().y / 2.0;
1426     new MochaUI.Panel({
1427         id: "propertiesPanel",
1428         title: "Panel",
1429         padding: {
1430             top: 0,
1431             right: 0,
1432             bottom: 0,
1433             left: 0
1434         },
1435         contentURL: "views/properties.html",
1436         require: {
1437             js: ["scripts/prop-general.js", "scripts/prop-trackers.js", "scripts/prop-peers.js", "scripts/prop-webseeds.js", "scripts/prop-files.js"],
1438             onload: function() {
1439                 updatePropertiesPanel = function() {
1440                     switch (LocalPreferences.get("selected_properties_tab")) {
1441                         case "propGeneralLink":
1442                             window.qBittorrent.PropGeneral.updateData();
1443                             break;
1444                         case "propTrackersLink":
1445                             window.qBittorrent.PropTrackers.updateData();
1446                             break;
1447                         case "propPeersLink":
1448                             window.qBittorrent.PropPeers.updateData();
1449                             break;
1450                         case "propWebSeedsLink":
1451                             window.qBittorrent.PropWebseeds.updateData();
1452                             break;
1453                         case "propFilesLink":
1454                             window.qBittorrent.PropFiles.updateData();
1455                             break;
1456                     }
1457                 };
1458             }
1459         },
1460         tabsURL: "views/propertiesToolbar.html",
1461         tabsOnload: function() {}, // must be included, otherwise panel won't load properly
1462         onContentLoaded: function() {
1463             this.panelHeaderCollapseBoxEl.classList.add("invisible");
1465             const togglePropertiesPanel = () => {
1466                 this.collapseToggleEl.click();
1467                 LocalPreferences.set("properties_panel_collapsed", this.isCollapsed.toString());
1468             };
1470             const selectTab = (tabID) => {
1471                 const isAlreadySelected = this.panelHeaderEl.getElementById(tabID).classList.contains("selected");
1472                 if (!isAlreadySelected) {
1473                     for (const tab of this.panelHeaderEl.getElementById("propertiesTabs").children)
1474                         tab.classList.toggle("selected", tab.id === tabID);
1476                     const tabContentID = tabID.replace("Link", "");
1477                     for (const tabContent of this.contentEl.children)
1478                         tabContent.classList.toggle("invisible", tabContent.id !== tabContentID);
1480                     LocalPreferences.set("selected_properties_tab", tabID);
1481                 }
1483                 if (isAlreadySelected || this.isCollapsed)
1484                     togglePropertiesPanel();
1485             };
1487             const lastUsedTab = LocalPreferences.get("selected_properties_tab", "propGeneralLink");
1488             selectTab(lastUsedTab);
1490             const startCollapsed = LocalPreferences.get("properties_panel_collapsed", "false") === "true";
1491             if (startCollapsed)
1492                 togglePropertiesPanel();
1494             this.panelHeaderContentEl.addEventListener("click", (e) => {
1495                 const selectedTab = e.target.closest("li");
1496                 if (!selectedTab)
1497                     return;
1499                 selectTab(selectedTab.id);
1500                 updatePropertiesPanel();
1502                 const showFilesFilter = (selectedTab.id === "propFilesLink") && !this.isCollapsed;
1503                 document.getElementById("torrentFilesFilterToolbar").classList.toggle("invisible", !showFilesFilter);
1504             });
1505         },
1506         column: "mainColumn",
1507         height: prop_h
1508     });
1510     // listen for changes to torrentsFilterInput
1511     let torrentsFilterInputTimer = -1;
1512     $("torrentsFilterInput").addEventListener("input", () => {
1513         clearTimeout(torrentsFilterInputTimer);
1514         torrentsFilterInputTimer = setTimeout(() => {
1515             torrentsFilterInputTimer = -1;
1516             torrentsTable.updateTable();
1517         }, window.qBittorrent.Misc.FILTER_INPUT_DELAY);
1518     });
1520     document.getElementById("torrentsFilterToolbar").addEventListener("change", (e) => { torrentsTable.updateTable(); });
1522     $("transfersTabLink").addEventListener("click", () => { showTransfersTab(); });
1523     $("searchTabLink").addEventListener("click", () => { showSearchTab(); });
1524     $("rssTabLink").addEventListener("click", () => { showRssTab(); });
1525     $("logTabLink").addEventListener("click", () => { showLogTab(); });
1526     updateTabDisplay();
1528     const registerDragAndDrop = () => {
1529         $("desktop").addEventListener("dragover", (ev) => {
1530             if (ev.preventDefault)
1531                 ev.preventDefault();
1532         });
1534         $("desktop").addEventListener("dragenter", (ev) => {
1535             if (ev.preventDefault)
1536                 ev.preventDefault();
1537         });
1539         $("desktop").addEventListener("drop", (ev) => {
1540             if (ev.preventDefault)
1541                 ev.preventDefault();
1543             const droppedFiles = ev.dataTransfer.files;
1545             if (droppedFiles.length > 0) {
1546                 // dropped files or folders
1548                 // can't handle folder due to cannot put the filelist (from dropped folder)
1549                 // to <input> `files` field
1550                 for (const item of ev.dataTransfer.items) {
1551                     if (item.webkitGetAsEntry().isDirectory)
1552                         return;
1553                 }
1555                 const id = "uploadPage";
1556                 new MochaUI.Window({
1557                     id: id,
1558                     icon: "images/qbittorrent-tray.svg",
1559                     title: "QBT_TR(Upload local torrent)QBT_TR[CONTEXT=HttpServer]",
1560                     loadMethod: "iframe",
1561                     contentURL: new URI("upload.html").toString(),
1562                     addClass: "windowFrame", // fixes iframe scrolling on iOS Safari
1563                     scrollbars: true,
1564                     maximizable: false,
1565                     paddingVertical: 0,
1566                     paddingHorizontal: 0,
1567                     width: loadWindowWidth(id, 500),
1568                     height: loadWindowHeight(id, 460),
1569                     onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
1570                         saveWindowSize(id);
1571                     }),
1572                     onContentLoaded: () => {
1573                         const fileInput = $(`${id}_iframe`).contentDocument.getElementById("fileselect");
1574                         fileInput.files = droppedFiles;
1575                     }
1576                 });
1577             }
1579             const droppedText = ev.dataTransfer.getData("text");
1580             if (droppedText.length > 0) {
1581                 // dropped text
1583                 const urls = droppedText.split("\n")
1584                     .map((str) => str.trim())
1585                     .filter((str) => {
1586                         const lowercaseStr = str.toLowerCase();
1587                         return lowercaseStr.startsWith("http:")
1588                             || lowercaseStr.startsWith("https:")
1589                             || lowercaseStr.startsWith("magnet:")
1590                             || ((str.length === 40) && !(/[^0-9A-F]/i.test(str))) // v1 hex-encoded SHA-1 info-hash
1591                             || ((str.length === 32) && !(/[^2-7A-Z]/i.test(str))); // v1 Base32 encoded SHA-1 info-hash
1592                     });
1594                 if (urls.length <= 0)
1595                     return;
1597                 const id = "downloadPage";
1598                 const contentURI = new URI("download.html").setData("urls", urls.map(encodeURIComponent).join("|"));
1599                 new MochaUI.Window({
1600                     id: id,
1601                     icon: "images/qbittorrent-tray.svg",
1602                     title: "QBT_TR(Download from URLs)QBT_TR[CONTEXT=downloadFromURL]",
1603                     loadMethod: "iframe",
1604                     contentURL: contentURI.toString(),
1605                     addClass: "windowFrame", // fixes iframe scrolling on iOS Safari
1606                     scrollbars: true,
1607                     maximizable: false,
1608                     closable: true,
1609                     paddingVertical: 0,
1610                     paddingHorizontal: 0,
1611                     width: loadWindowWidth(id, 500),
1612                     height: loadWindowHeight(id, 600),
1613                     onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
1614                         saveWindowSize(id);
1615                     })
1616                 });
1617             }
1618         });
1619     };
1620     registerDragAndDrop();
1622     new Keyboard({
1623         defaultEventType: "keydown",
1624         events: {
1625             "ctrl+a": function(event) {
1626                 if ((event.target.nodeName === "INPUT") || (event.target.nodeName === "TEXTAREA"))
1627                     return;
1628                 if (event.target.isContentEditable)
1629                     return;
1630                 torrentsTable.selectAll();
1631                 event.preventDefault();
1632             },
1633             "delete": function(event) {
1634                 if ((event.target.nodeName === "INPUT") || (event.target.nodeName === "TEXTAREA"))
1635                     return;
1636                 if (event.target.isContentEditable)
1637                     return;
1638                 deleteFN();
1639                 event.preventDefault();
1640             },
1641             "shift+delete": (event) => {
1642                 if ((event.target.nodeName === "INPUT") || (event.target.nodeName === "TEXTAREA"))
1643                     return;
1644                 if (event.target.isContentEditable)
1645                     return;
1646                 deleteFN(true);
1647                 event.preventDefault();
1648             }
1649         }
1650     }).activate();
1653 window.addEventListener("load", () => {
1654     // fetch various data and store it in memory
1655     window.qBittorrent.Cache.buildInfo.init();
1656     window.qBittorrent.Cache.preferences.init();
1657     window.qBittorrent.Cache.qbtVersion.init();
1659     // switch to previously used tab
1660     const previouslyUsedTab = LocalPreferences.get("selected_window_tab", "transfers");
1661     switch (previouslyUsedTab) {
1662         case "search":
1663             if (window.qBittorrent.Client.isShowSearchEngine())
1664                 $("searchTabLink").click();
1665             break;
1666         case "rss":
1667             if (window.qBittorrent.Client.isShowRssReader())
1668                 $("rssTabLink").click();
1669             break;
1670         case "log":
1671             if (window.qBittorrent.Client.isShowLogViewer())
1672                 $("logTabLink").click();
1673             break;
1674         case "transfers":
1675             $("transfersTabLink").click();
1676             break;
1677         default:
1678             console.error(`Unexpected 'selected_window_tab' value: ${previouslyUsedTab}`);
1679             $("transfersTabLink").click();
1680             break;
1681     };