Display External IP Address in status bar
[qBittorrent.git] / src / webui / www / private / scripts / search.js
blobeb29a5dc1f20ebc556d05cd10ee25028412342a2
1 /*
2  * MIT License
3  * Copyright (C) 2024 Thomas Piccirello
4  *
5  * Permission is hereby granted, free of charge, to any person obtaining a copy
6  * of this software and associated documentation files (the "Software"), to deal
7  * in the Software without restriction, including without limitation the rights
8  * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9  * copies of the Software, and to permit persons to whom the Software is
10  * furnished to do so, subject to the following conditions:
11  *
12  * The above copyright notice and this permission notice shall be included in
13  * all copies or substantial portions of the Software.
14  *
15  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18  * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20  * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21  * THE SOFTWARE.
22  */
24 "use strict";
26 window.qBittorrent ??= {};
27 window.qBittorrent.Search ??= (() => {
28     const exports = () => {
29         return {
30             startStopSearch: startStopSearch,
31             manageSearchPlugins: manageSearchPlugins,
32             searchPlugins: searchPlugins,
33             searchText: searchText,
34             searchSeedsFilter: searchSeedsFilter,
35             searchSizeFilter: searchSizeFilter,
36             init: init,
37             getPlugin: getPlugin,
38             searchInTorrentName: searchInTorrentName,
39             onSearchPatternChanged: onSearchPatternChanged,
40             categorySelected: categorySelected,
41             pluginSelected: pluginSelected,
42             searchSeedsFilterChanged: searchSeedsFilterChanged,
43             searchSizeFilterChanged: searchSizeFilterChanged,
44             searchSizeFilterPrefixChanged: searchSizeFilterPrefixChanged,
45             closeSearchTab: closeSearchTab,
46         };
47     };
49     const searchTabIdPrefix = "Search-";
50     let loadSearchPluginsTimer = -1;
51     const searchPlugins = [];
52     let prevSearchPluginsResponse;
53     let selectedCategory = "QBT_TR(All categories)QBT_TR[CONTEXT=SearchEngineWidget]";
54     let selectedPlugin = "enabled";
55     let prevSelectedPlugin;
56     // whether the current search pattern differs from the pattern that the active search was performed with
57     let searchPatternChanged = false;
59     let searchResultsTable;
60     /** @type Map<number, {
61      * searchPattern: string,
62      * filterPattern: string,
63      * seedsFilter: {min: number, max: number},
64      * sizeFilter: {min: number, minUnit: number, max: number, maxUnit: number},
65      * searchIn: string,
66      * rows: [],
67      * rowId: number,
68      * selectedRowIds: number[],
69      * running: boolean,
70      * loadResultsTimer: Timer,
71      * sort: {column: string, reverse: string},
72      * }> **/
73     const searchState = new Map();
74     const searchText = {
75         pattern: "",
76         filterPattern: ""
77     };
78     const searchSeedsFilter = {
79         min: 0,
80         max: 0
81     };
82     const searchSizeFilter = {
83         min: 0.00,
84         minUnit: 2, // B = 0, KiB = 1, MiB = 2, GiB = 3, TiB = 4, PiB = 5, EiB = 6
85         max: 0.00,
86         maxUnit: 3
87     };
89     const searchResultsTabsContextMenu = new window.qBittorrent.ContextMenu.ContextMenu({
90         targets: ".searchTab",
91         menu: "searchResultsTabsMenu",
92         actions: {
93             closeTab: (tab) => { closeSearchTab(tab); },
94             closeAllTabs: () => {
95                 for (const tab of document.querySelectorAll("#searchTabs .searchTab"))
96                     closeSearchTab(tab);
97             }
98         },
99         offsets: {
100             x: -15,
101             y: -53
102         },
103         onShow: function() {
104             setActiveTab(this.options.element);
105         }
106     });
108     const init = () => {
109         // load "Search in" preference from local storage
110         $("searchInTorrentName").value = (LocalPreferences.get("search_in_filter") === "names") ? "names" : "everywhere";
111         const searchResultsTableContextMenu = new window.qBittorrent.ContextMenu.ContextMenu({
112             targets: ".searchTableRow",
113             menu: "searchResultsTableMenu",
114             actions: {
115                 Download: downloadSearchTorrent,
116                 OpenDescriptionUrl: openSearchTorrentDescriptionUrl
117             },
118             offsets: {
119                 x: 0,
120                 y: -60
121             }
122         });
123         searchResultsTable = new window.qBittorrent.DynamicTable.SearchResultsTable();
124         searchResultsTable.setup("searchResultsTableDiv", "searchResultsTableFixedHeaderDiv", searchResultsTableContextMenu);
125         getPlugins();
127         // listen for changes to searchInNameFilter
128         let searchInNameFilterTimer = -1;
129         $("searchInNameFilter").addEventListener("input", () => {
130             clearTimeout(searchInNameFilterTimer);
131             searchInNameFilterTimer = setTimeout(() => {
132                 searchInNameFilterTimer = -1;
134                 const value = $("searchInNameFilter").value;
135                 searchText.filterPattern = value;
136                 searchFilterChanged();
137             }, window.qBittorrent.Misc.FILTER_INPUT_DELAY);
138         });
140         new Keyboard({
141             defaultEventType: "keydown",
142             events: {
143                 "Enter": function(e) {
144                     // accept enter key as a click
145                     e.preventDefault();
146                     e.stopPropagation();
148                     const elem = e.event.srcElement;
149                     if (elem.className.contains("searchInputField")) {
150                         document.getElementById("startSearchButton").click();
151                         return;
152                     }
154                     switch (elem.id) {
155                         case "manageSearchPlugins":
156                             manageSearchPlugins();
157                             break;
158                     }
159                 }
160             }
161         }).activate();
163         // restore search tabs
164         const searchJobs = JSON.parse(LocalPreferences.get("search_jobs", "[]"));
165         for (const { id, pattern } of searchJobs)
166             createSearchTab(id, pattern);
167     };
169     const numSearchTabs = () => {
170         return $("searchTabs").getElements("li").length;
171     };
173     const getSearchIdFromTab = (tab) => {
174         return Number(tab.id.substring(searchTabIdPrefix.length));
175     };
177     const createSearchTab = (searchId, pattern) => {
178         const newTabId = `${searchTabIdPrefix}${searchId}`;
179         const tabElem = new Element("a", {
180             text: pattern,
181         });
183         const closeTabElem = new Element("img", {
184             alt: "QBT_TR(Close tab)QBT_TR[CONTEXT=SearchWidget]",
185             title: "QBT_TR(Close tab)QBT_TR[CONTEXT=SearchWidget]",
186             src: "images/application-exit.svg",
187             width: "10",
188             height: "10",
189             onclick: "qBittorrent.Search.closeSearchTab(this);",
190         });
191         closeTabElem.inject(tabElem, "top");
193         tabElem.appendChild(getStatusIconElement("QBT_TR(Searching...)QBT_TR[CONTEXT=SearchJobWidget]", "images/queued.svg"));
195         const listItem = document.createElement("li");
196         listItem.id = newTabId;
197         listItem.classList.add("selected", "searchTab");
198         listItem.addEventListener("click", (e) => {
199             setActiveTab(listItem);
200             document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
201         });
202         listItem.appendChild(tabElem);
203         $("searchTabs").appendChild(listItem);
204         searchResultsTabsContextMenu.addTarget(listItem);
206         // unhide the results elements
207         if (numSearchTabs() >= 1) {
208             $("searchResultsNoSearches").classList.add("invisible");
209             $("searchResultsFilters").classList.remove("invisible");
210             $("searchResultsTableContainer").classList.remove("invisible");
211             $("searchTabsToolbar").classList.remove("invisible");
212         }
214         // select new tab
215         setActiveTab(listItem);
217         searchResultsTable.clear();
218         resetFilters();
220         searchState.set(searchId, {
221             searchPattern: pattern,
222             filterPattern: searchText.filterPattern,
223             seedsFilter: { min: searchSeedsFilter.min, max: searchSeedsFilter.max },
224             sizeFilter: { min: searchSizeFilter.min, minUnit: searchSizeFilter.minUnit, max: searchSizeFilter.max, maxUnit: searchSizeFilter.maxUnit },
225             searchIn: getSearchInTorrentName(),
226             rows: [],
227             rowId: 0,
228             selectedRowIds: [],
229             running: true,
230             loadResultsTimer: -1,
231             sort: { column: searchResultsTable.sortedColumn, reverse: searchResultsTable.reverseSort },
232         });
233         updateSearchResultsData(searchId);
234     };
236     const closeSearchTab = (el) => {
237         const tab = el.closest("li.searchTab");
238         if (!tab)
239             return;
241         const searchId = getSearchIdFromTab(tab);
242         const isTabSelected = tab.hasClass("selected");
243         const newTabToSelect = isTabSelected ? (tab.nextSibling || tab.previousSibling) : null;
245         const currentSearchId = getSelectedSearchId();
246         const state = searchState.get(currentSearchId);
247         // don't bother sending a stop request if already stopped
248         if (state && state.running)
249             stopSearch(searchId);
251         tab.destroy();
253         new Request({
254             url: new URI("api/v2/search/delete"),
255             method: "post",
256             data: {
257                 id: searchId
258             },
259         }).send();
261         const searchJobs = JSON.parse(LocalPreferences.get("search_jobs", "[]"));
262         const jobIndex = searchJobs.findIndex((job) => job.id === searchId);
263         if (jobIndex >= 0) {
264             searchJobs.splice(jobIndex, 1);
265             LocalPreferences.set("search_jobs", JSON.stringify(searchJobs));
266         }
268         if (numSearchTabs() === 0) {
269             resetSearchState();
270             resetFilters();
272             $("numSearchResultsVisible").textContent = 0;
273             $("numSearchResultsTotal").textContent = 0;
274             $("searchResultsNoSearches").classList.remove("invisible");
275             $("searchResultsFilters").classList.add("invisible");
276             $("searchResultsTableContainer").classList.add("invisible");
277             $("searchTabsToolbar").classList.add("invisible");
278         }
279         else if (isTabSelected && newTabToSelect) {
280             setActiveTab(newTabToSelect);
281             document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
282         }
283     };
285     const saveCurrentTabState = () => {
286         const currentSearchId = getSelectedSearchId();
287         if (!currentSearchId)
288             return;
290         const state = searchState.get(currentSearchId);
291         if (!state)
292             return;
294         state.filterPattern = searchText.filterPattern;
295         state.seedsFilter = {
296             min: searchSeedsFilter.min,
297             max: searchSeedsFilter.max,
298         };
299         state.sizeFilter = {
300             min: searchSizeFilter.min,
301             minUnit: searchSizeFilter.minUnit,
302             max: searchSizeFilter.max,
303             maxUnit: searchSizeFilter.maxUnit,
304         };
305         state.searchIn = getSearchInTorrentName();
307         state.sort = {
308             column: searchResultsTable.sortedColumn,
309             reverse: searchResultsTable.reverseSort,
310         };
312         // we must copy the array to avoid taking a reference to it
313         state.selectedRowIds = [...searchResultsTable.selectedRows];
314     };
316     const setActiveTab = (tab) => {
317         const searchId = getSearchIdFromTab(tab);
318         if (searchId === getSelectedSearchId())
319             return;
321         saveCurrentTabState();
323         MochaUI.selected(tab, "searchTabs");
325         const state = searchState.get(searchId);
326         let rowsToSelect = [];
328         // restore table rows
329         searchResultsTable.clear();
330         if (state) {
331             for (const row of state.rows)
332                 searchResultsTable.updateRowData(row);
334             rowsToSelect = state.selectedRowIds;
336             // restore filters
337             searchText.pattern = state.searchPattern;
338             searchText.filterPattern = state.filterPattern;
339             $("searchInNameFilter").value = state.filterPattern;
341             searchSeedsFilter.min = state.seedsFilter.min;
342             searchSeedsFilter.max = state.seedsFilter.max;
343             $("searchMinSeedsFilter").value = state.seedsFilter.min;
344             $("searchMaxSeedsFilter").value = state.seedsFilter.max;
346             searchSizeFilter.min = state.sizeFilter.min;
347             searchSizeFilter.minUnit = state.sizeFilter.minUnit;
348             searchSizeFilter.max = state.sizeFilter.max;
349             searchSizeFilter.maxUnit = state.sizeFilter.maxUnit;
350             $("searchMinSizeFilter").value = state.sizeFilter.min;
351             $("searchMinSizePrefix").value = state.sizeFilter.minUnit;
352             $("searchMaxSizeFilter").value = state.sizeFilter.max;
353             $("searchMaxSizePrefix").value = state.sizeFilter.maxUnit;
355             const currentSearchPattern = $("searchPattern").value.trim();
356             if (state.running && (state.searchPattern === currentSearchPattern)) {
357                 // allow search to be stopped
358                 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Stop)QBT_TR[CONTEXT=SearchEngineWidget]";
359                 searchPatternChanged = false;
360             }
362             searchResultsTable.setSortedColumn(state.sort.column, state.sort.reverse);
364             $("searchInTorrentName").value = state.searchIn;
365         }
367         // must restore all filters before calling updateTable
368         searchResultsTable.updateTable();
370         // must reselect rows after calling updateTable
371         if (rowsToSelect.length > 0)
372             searchResultsTable.reselectRows(rowsToSelect);
374         $("numSearchResultsVisible").textContent = searchResultsTable.getFilteredAndSortedRows().length;
375         $("numSearchResultsTotal").textContent = searchResultsTable.getRowSize();
377         setupSearchTableEvents(true);
378     };
380     const getStatusIconElement = (text, image) => {
381         return new Element("img", {
382             alt: text,
383             title: text,
384             src: image,
385             class: "statusIcon",
386             width: "12",
387             height: "12",
388         });
389     };
391     const updateStatusIconElement = (searchId, text, image) => {
392         const searchTab = $(`${searchTabIdPrefix}${searchId}`);
393         if (searchTab) {
394             const statusIcon = searchTab.getElement(".statusIcon");
395             statusIcon.alt = text;
396             statusIcon.title = text;
397             statusIcon.src = image;
398         }
399     };
401     const startSearch = (pattern, category, plugins) => {
402         searchPatternChanged = false;
404         const url = new URI("api/v2/search/start");
405         new Request.JSON({
406             url: url,
407             method: "post",
408             data: {
409                 pattern: pattern,
410                 category: category,
411                 plugins: plugins
412             },
413             onSuccess: (response) => {
414                 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Stop)QBT_TR[CONTEXT=SearchEngineWidget]";
415                 const searchId = response.id;
416                 createSearchTab(searchId, pattern);
418                 const searchJobs = JSON.parse(LocalPreferences.get("search_jobs", "[]"));
419                 searchJobs.push({ id: searchId, pattern: pattern });
420                 LocalPreferences.set("search_jobs", JSON.stringify(searchJobs));
421             }
422         }).send();
423     };
425     const stopSearch = (searchId) => {
426         const url = new URI("api/v2/search/stop");
427         new Request({
428             url: url,
429             method: "post",
430             data: {
431                 id: searchId
432             },
433             onSuccess: (response) => {
434                 resetSearchState(searchId);
435                 // not strictly necessary to do this when the tab is being closed, but there's no harm in it
436                 updateStatusIconElement(searchId, "QBT_TR(Search aborted)QBT_TR[CONTEXT=SearchJobWidget]", "images/task-reject.svg");
437             }
438         }).send();
439     };
441     const getSelectedSearchId = () => {
442         const selectedTab = $("searchTabs").getElement("li.selected");
443         return selectedTab ? getSearchIdFromTab(selectedTab) : null;
444     };
446     const startStopSearch = () => {
447         const currentSearchId = getSelectedSearchId();
448         const state = searchState.get(currentSearchId);
449         const isSearchRunning = state && state.running;
450         if (!isSearchRunning || searchPatternChanged) {
451             const pattern = $("searchPattern").value.trim();
452             const category = $("categorySelect").value;
453             const plugins = $("pluginsSelect").value;
455             if (!pattern || !category || !plugins)
456                 return;
458             searchText.pattern = pattern;
459             startSearch(pattern, category, plugins);
460         }
461         else {
462             stopSearch(currentSearchId);
463         }
464     };
466     const openSearchTorrentDescriptionUrl = () => {
467         for (const rowID of searchResultsTable.selectedRowsIds())
468             window.open(searchResultsTable.getRow(rowID).full_data.descrLink, "_blank");
469     };
471     const copySearchTorrentName = () => {
472         const names = [];
473         searchResultsTable.selectedRowsIds().each((rowId) => {
474             names.push(searchResultsTable.getRow(rowId).full_data.fileName);
475         });
476         return names.join("\n");
477     };
479     const copySearchTorrentDownloadLink = () => {
480         const urls = [];
481         searchResultsTable.selectedRowsIds().each((rowId) => {
482             urls.push(searchResultsTable.getRow(rowId).full_data.fileUrl);
483         });
484         return urls.join("\n");
485     };
487     const copySearchTorrentDescriptionUrl = () => {
488         const urls = [];
489         searchResultsTable.selectedRowsIds().each((rowId) => {
490             urls.push(searchResultsTable.getRow(rowId).full_data.descrLink);
491         });
492         return urls.join("\n");
493     };
495     const downloadSearchTorrent = () => {
496         const urls = [];
497         for (const rowID of searchResultsTable.selectedRowsIds())
498             urls.push(searchResultsTable.getRow(rowID).full_data.fileUrl);
500         // only proceed if at least 1 row was selected
501         if (!urls.length)
502             return;
504         showDownloadPage(urls);
505     };
507     const manageSearchPlugins = () => {
508         const id = "searchPlugins";
509         if (!$(id)) {
510             new MochaUI.Window({
511                 id: id,
512                 title: "QBT_TR(Search plugins)QBT_TR[CONTEXT=PluginSelectDlg]",
513                 icon: "images/qbittorrent-tray.svg",
514                 loadMethod: "xhr",
515                 contentURL: "views/searchplugins.html",
516                 scrollbars: false,
517                 maximizable: false,
518                 paddingVertical: 0,
519                 paddingHorizontal: 0,
520                 width: loadWindowWidth(id, 600),
521                 height: loadWindowHeight(id, 360),
522                 onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
523                     saveWindowSize(id);
524                 }),
525                 onBeforeBuild: () => {
526                     loadSearchPlugins();
527                 },
528                 onClose: () => {
529                     clearTimeout(loadSearchPluginsTimer);
530                     loadSearchPluginsTimer = -1;
531                 }
532             });
533         }
534     };
536     const loadSearchPlugins = () => {
537         getPlugins();
538         loadSearchPluginsTimer = loadSearchPlugins.delay(2000);
539     };
541     const onSearchPatternChanged = () => {
542         const currentSearchId = getSelectedSearchId();
543         const state = searchState.get(currentSearchId);
544         const currentSearchPattern = $("searchPattern").value.trim();
545         // start a new search if pattern has changed, otherwise allow the search to be stopped
546         if (state && (state.searchPattern === currentSearchPattern)) {
547             searchPatternChanged = false;
548             document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Stop)QBT_TR[CONTEXT=SearchEngineWidget]";
549         }
550         else {
551             searchPatternChanged = true;
552             document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
553         }
554     };
556     const categorySelected = () => {
557         selectedCategory = $("categorySelect").value;
558     };
560     const pluginSelected = () => {
561         selectedPlugin = $("pluginsSelect").value;
563         if (selectedPlugin !== prevSelectedPlugin) {
564             prevSelectedPlugin = selectedPlugin;
565             getSearchCategories();
566         }
567     };
569     const reselectCategory = () => {
570         for (let i = 0; i < $("categorySelect").options.length; ++i) {
571             if ($("categorySelect").options[i].get("value") === selectedCategory)
572                 $("categorySelect").options[i].selected = true;
573         }
575         categorySelected();
576     };
578     const reselectPlugin = () => {
579         for (let i = 0; i < $("pluginsSelect").options.length; ++i) {
580             if ($("pluginsSelect").options[i].get("value") === selectedPlugin)
581                 $("pluginsSelect").options[i].selected = true;
582         }
584         pluginSelected();
585     };
587     const resetSearchState = (searchId) => {
588         document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
589         const state = searchState.get(searchId);
590         if (state) {
591             state.running = false;
592             clearTimeout(state.loadResultsTimer);
593             state.loadResultsTimer = -1;
594         }
595     };
597     const getSearchCategories = () => {
598         const populateCategorySelect = (categories) => {
599             const categoryOptions = [];
601             for (const category of categories) {
602                 const option = document.createElement("option");
603                 option.value = category.id;
604                 option.textContent = category.name;
605                 categoryOptions.push(option);
606             };
608             // first category is "All Categories"
609             if (categoryOptions.length > 1) {
610                 // add separator
611                 const option = document.createElement("option");
612                 option.disabled = true;
613                 option.textContent = "──────────";
614                 categoryOptions.splice(1, 0, option);
615             }
617             $("categorySelect").replaceChildren(...categoryOptions);
618         };
620         const selectedPlugin = $("pluginsSelect").value;
622         if ((selectedPlugin === "all") || (selectedPlugin === "enabled")) {
623             const uniqueCategories = {};
624             for (const plugin of searchPlugins) {
625                 if ((selectedPlugin === "enabled") && !plugin.enabled)
626                     continue;
627                 for (const category of plugin.supportedCategories) {
628                     if (uniqueCategories[category.id] === undefined)
629                         uniqueCategories[category.id] = category;
630                 }
631             }
632             // we must sort the ids to maintain consistent order.
633             const categories = Object.keys(uniqueCategories).sort().map(id => uniqueCategories[id]);
634             populateCategorySelect(categories);
635         }
636         else {
637             const plugin = getPlugin(selectedPlugin);
638             const plugins = (plugin === null) ? [] : plugin.supportedCategories;
639             populateCategorySelect(plugins);
640         }
642         reselectCategory();
643     };
645     const getPlugins = () => {
646         new Request.JSON({
647             url: new URI("api/v2/search/plugins"),
648             method: "get",
649             noCache: true,
650             onSuccess: (response) => {
651                 const createOption = (text, value, disabled = false) => {
652                     const option = document.createElement("option");
653                     if (value !== undefined)
654                         option.value = value;
655                     option.textContent = text;
656                     option.disabled = disabled;
657                     return option;
658                 };
660                 if (response !== prevSearchPluginsResponse) {
661                     prevSearchPluginsResponse = response;
662                     searchPlugins.length = 0;
663                     response.forEach((plugin) => {
664                         searchPlugins.push(plugin);
665                     });
667                     const pluginOptions = [];
668                     pluginOptions.push(createOption("QBT_TR(Only enabled)QBT_TR[CONTEXT=SearchEngineWidget]", "enabled"));
669                     pluginOptions.push(createOption("QBT_TR(All plugins)QBT_TR[CONTEXT=SearchEngineWidget]", "all"));
671                     const searchPluginsEmpty = (searchPlugins.length === 0);
672                     if (!searchPluginsEmpty) {
673                         $("searchResultsNoPlugins").classList.add("invisible");
674                         if (numSearchTabs() === 0)
675                             $("searchResultsNoSearches").classList.remove("invisible");
677                         // sort plugins alphabetically
678                         const allPlugins = searchPlugins.sort((left, right) => {
679                             const leftName = left.fullName;
680                             const rightName = right.fullName;
681                             return window.qBittorrent.Misc.naturalSortCollator.compare(leftName, rightName);
682                         });
684                         allPlugins.each((plugin) => {
685                             if (plugin.enabled === true)
686                                 pluginOptions.push(createOption(plugin.fullName, plugin.name));
687                         });
689                         if (pluginOptions.length > 2)
690                             pluginOptions.splice(2, 0, createOption("──────────", undefined, true));
691                     }
693                     $("pluginsSelect").replaceChildren(...pluginOptions);
695                     $("searchPattern").disabled = searchPluginsEmpty;
696                     $("categorySelect").disabled = searchPluginsEmpty;
697                     $("pluginsSelect").disabled = searchPluginsEmpty;
698                     document.getElementById("startSearchButton").disabled = searchPluginsEmpty;
700                     if (window.qBittorrent.SearchPlugins !== undefined)
701                         window.qBittorrent.SearchPlugins.updateTable();
703                     reselectPlugin();
704                 }
705             }
706         }).send();
707     };
709     const getPlugin = (name) => {
710         for (let i = 0; i < searchPlugins.length; ++i) {
711             if (searchPlugins[i].name === name)
712                 return searchPlugins[i];
713         }
715         return null;
716     };
718     const resetFilters = () => {
719         searchText.filterPattern = "";
720         $("searchInNameFilter").value = "";
722         searchSeedsFilter.min = 0;
723         searchSeedsFilter.max = 0;
724         $("searchMinSeedsFilter").value = searchSeedsFilter.min;
725         $("searchMaxSeedsFilter").value = searchSeedsFilter.max;
727         searchSizeFilter.min = 0.00;
728         searchSizeFilter.minUnit = 2; // B = 0, KiB = 1, MiB = 2, GiB = 3, TiB = 4, PiB = 5, EiB = 6
729         searchSizeFilter.max = 0.00;
730         searchSizeFilter.maxUnit = 3;
731         $("searchMinSizeFilter").value = searchSizeFilter.min;
732         $("searchMinSizePrefix").value = searchSizeFilter.minUnit;
733         $("searchMaxSizeFilter").value = searchSizeFilter.max;
734         $("searchMaxSizePrefix").value = searchSizeFilter.maxUnit;
735     };
737     const getSearchInTorrentName = () => {
738         return ($("searchInTorrentName").value === "names") ? "names" : "everywhere";
739     };
741     const searchInTorrentName = () => {
742         LocalPreferences.set("search_in_filter", getSearchInTorrentName());
743         searchFilterChanged();
744     };
746     const searchSeedsFilterChanged = () => {
747         searchSeedsFilter.min = $("searchMinSeedsFilter").value;
748         searchSeedsFilter.max = $("searchMaxSeedsFilter").value;
750         searchFilterChanged();
751     };
753     const searchSizeFilterChanged = () => {
754         searchSizeFilter.min = $("searchMinSizeFilter").value;
755         searchSizeFilter.minUnit = $("searchMinSizePrefix").value;
756         searchSizeFilter.max = $("searchMaxSizeFilter").value;
757         searchSizeFilter.maxUnit = $("searchMaxSizePrefix").value;
759         searchFilterChanged();
760     };
762     const searchSizeFilterPrefixChanged = () => {
763         if ((Number($("searchMinSizeFilter").value) !== 0) || (Number($("searchMaxSizeFilter").value) !== 0))
764             searchSizeFilterChanged();
765     };
767     const searchFilterChanged = () => {
768         searchResultsTable.updateTable();
769         $("numSearchResultsVisible").textContent = searchResultsTable.getFilteredAndSortedRows().length;
770     };
772     const setupSearchTableEvents = (enable) => {
773         const clickHandler = (e) => { downloadSearchTorrent(); };
774         if (enable) {
775             $$(".searchTableRow").each((target) => {
776                 target.addEventListener("dblclick", clickHandler);
777             });
778         }
779         else {
780             $$(".searchTableRow").each((target) => {
781                 target.removeEventListener("dblclick", clickHandler);
782             });
783         }
784     };
786     const loadSearchResultsData = function(searchId) {
787         const state = searchState.get(searchId);
789         const maxResults = 500;
790         const url = new URI("api/v2/search/results");
791         new Request.JSON({
792             url: url,
793             method: "get",
794             noCache: true,
795             data: {
796                 id: searchId,
797                 limit: maxResults,
798                 offset: state.rowId
799             },
800             onFailure: function(response) {
801                 if ((response.status === 400) || (response.status === 404)) {
802                     // bad params. search id is invalid
803                     resetSearchState(searchId);
804                     updateStatusIconElement(searchId, "QBT_TR(An error occurred during search...)QBT_TR[CONTEXT=SearchJobWidget]", "images/error.svg");
805                 }
806                 else {
807                     clearTimeout(state.loadResultsTimer);
808                     state.loadResultsTimer = loadSearchResultsData.delay(3000, this, searchId);
809                 }
810             },
811             onSuccess: function(response) {
812                 $("error_div").textContent = "";
814                 const state = searchState.get(searchId);
815                 // check if user stopped the search prior to receiving the response
816                 if (!state.running) {
817                     clearTimeout(state.loadResultsTimer);
818                     updateStatusIconElement(searchId, "QBT_TR(Search aborted)QBT_TR[CONTEXT=SearchJobWidget]", "images/task-reject.svg");
819                     return;
820                 }
822                 if (response) {
823                     setupSearchTableEvents(false);
825                     const state = searchState.get(searchId);
826                     const newRows = [];
828                     if (response.results) {
829                         const results = response.results;
830                         for (let i = 0; i < results.length; ++i) {
831                             const result = results[i];
832                             const row = {
833                                 rowId: state.rowId,
834                                 descrLink: result.descrLink,
835                                 fileName: result.fileName,
836                                 fileSize: result.fileSize,
837                                 fileUrl: result.fileUrl,
838                                 nbLeechers: result.nbLeechers,
839                                 nbSeeders: result.nbSeeders,
840                                 engineName: result.engineName,
841                                 siteUrl: result.siteUrl,
842                                 pubDate: result.pubDate,
843                             };
845                             newRows.push(row);
846                             state.rows.push(row);
847                             state.rowId += 1;
848                         }
849                     }
851                     // only update table if this search is currently being displayed
852                     if (searchId === getSelectedSearchId()) {
853                         for (const row of newRows)
854                             searchResultsTable.updateRowData(row);
856                         $("numSearchResultsVisible").textContent = searchResultsTable.getFilteredAndSortedRows().length;
857                         $("numSearchResultsTotal").textContent = searchResultsTable.getRowSize();
859                         searchResultsTable.updateTable();
860                     }
862                     setupSearchTableEvents(true);
864                     if ((response.status === "Stopped") && (state.rowId >= response.total)) {
865                         resetSearchState(searchId);
866                         updateStatusIconElement(searchId, "QBT_TR(Search has finished)QBT_TR[CONTEXT=SearchJobWidget]", "images/task-complete.svg");
867                         return;
868                     }
869                 }
871                 clearTimeout(state.loadResultsTimer);
872                 state.loadResultsTimer = loadSearchResultsData.delay(2000, this, searchId);
873             }
874         }).send();
875     };
877     const updateSearchResultsData = function(searchId) {
878         const state = searchState.get(searchId);
879         clearTimeout(state.loadResultsTimer);
880         state.loadResultsTimer = loadSearchResultsData.delay(500, this, searchId);
881     };
883     new ClipboardJS(".copySearchDataToClipboard", {
884         text: (trigger) => {
885             switch (trigger.id) {
886                 case "copySearchTorrentName":
887                     return copySearchTorrentName();
888                 case "copySearchTorrentDownloadLink":
889                     return copySearchTorrentDownloadLink();
890                 case "copySearchTorrentDescriptionUrl":
891                     return copySearchTorrentDescriptionUrl();
892                 default:
893                     return "";
894             }
895         }
896     });
898     return exports();
899 })();
900 Object.freeze(window.qBittorrent.Search);