Allow to refresh existing search
[qBittorrent.git] / src / webui / www / private / scripts / search.js
blob600fdcbf78ba157ad28cfe8b5160447a81f3c109
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: "#searchResultsTableDiv tr",
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         searchResultsTable.dynamicTableDiv.addEventListener("dblclick", (e) => { downloadSearchTorrent(); });
129         // listen for changes to searchInNameFilter
130         let searchInNameFilterTimer = -1;
131         $("searchInNameFilter").addEventListener("input", () => {
132             clearTimeout(searchInNameFilterTimer);
133             searchInNameFilterTimer = setTimeout(() => {
134                 searchInNameFilterTimer = -1;
136                 const value = $("searchInNameFilter").value;
137                 searchText.filterPattern = value;
138                 searchFilterChanged();
139             }, window.qBittorrent.Misc.FILTER_INPUT_DELAY);
140         });
142         document.getElementById("SearchPanel").addEventListener("keydown", (event) => {
143             switch (event.key) {
144                 case "Enter": {
145                     event.preventDefault();
146                     event.stopPropagation();
148                     switch (event.target.id) {
149                         case "manageSearchPlugins":
150                             manageSearchPlugins();
151                             break;
152                         case "searchPattern":
153                             document.getElementById("startSearchButton").click();
154                             break;
155                     }
157                     break;
158                 }
159             }
160         });
162         // restore search tabs
163         const searchJobs = JSON.parse(LocalPreferences.get("search_jobs", "[]"));
164         for (const { id, pattern } of searchJobs)
165             createSearchTab(id, pattern);
166     };
168     const numSearchTabs = () => {
169         return $("searchTabs").getElements("li").length;
170     };
172     const getSearchIdFromTab = (tab) => {
173         return Number(tab.id.substring(searchTabIdPrefix.length));
174     };
176     const createSearchTab = (searchId, pattern) => {
177         const newTabId = `${searchTabIdPrefix}${searchId}`;
178         const tabElem = document.createElement("a");
179         tabElem.textContent = pattern;
181         const closeTabElem = document.createElement("img");
182         closeTabElem.alt = "QBT_TR(Close tab)QBT_TR[CONTEXT=SearchWidget]";
183         closeTabElem.title = "QBT_TR(Close tab)QBT_TR[CONTEXT=SearchWidget]";
184         closeTabElem.src = "images/application-exit.svg";
185         closeTabElem.width = "10";
186         closeTabElem.height = "10";
187         closeTabElem.addEventListener("click", function(e) { qBittorrent.Search.closeSearchTab(this); });
189         tabElem.prepend(closeTabElem);
190         tabElem.appendChild(getStatusIconElement("QBT_TR(Searching...)QBT_TR[CONTEXT=SearchJobWidget]", "images/queued.svg"));
192         const listItem = document.createElement("li");
193         listItem.id = newTabId;
194         listItem.classList.add("selected", "searchTab");
195         listItem.addEventListener("click", (e) => {
196             setActiveTab(listItem);
197             document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
198         });
199         listItem.appendChild(tabElem);
200         $("searchTabs").appendChild(listItem);
201         searchResultsTabsContextMenu.addTarget(listItem);
203         // unhide the results elements
204         if (numSearchTabs() >= 1) {
205             $("searchResultsNoSearches").classList.add("invisible");
206             $("searchResultsFilters").classList.remove("invisible");
207             $("searchResultsTableContainer").classList.remove("invisible");
208             $("searchTabsToolbar").classList.remove("invisible");
209         }
211         // select new tab
212         setActiveTab(listItem);
214         searchResultsTable.clear();
215         resetFilters();
217         searchState.set(searchId, {
218             searchPattern: pattern,
219             filterPattern: searchText.filterPattern,
220             seedsFilter: { min: searchSeedsFilter.min, max: searchSeedsFilter.max },
221             sizeFilter: { min: searchSizeFilter.min, minUnit: searchSizeFilter.minUnit, max: searchSizeFilter.max, maxUnit: searchSizeFilter.maxUnit },
222             searchIn: getSearchInTorrentName(),
223             rows: [],
224             rowId: 0,
225             selectedRowIds: [],
226             running: true,
227             loadResultsTimer: -1,
228             sort: { column: searchResultsTable.sortedColumn, reverse: searchResultsTable.reverseSort },
229         });
230         updateSearchResultsData(searchId);
231     };
233     const closeSearchTab = (el) => {
234         const tab = el.closest("li.searchTab");
235         if (!tab)
236             return;
238         const searchId = getSearchIdFromTab(tab);
239         const isTabSelected = tab.classList.contains("selected");
240         const newTabToSelect = isTabSelected ? (tab.nextSibling || tab.previousSibling) : null;
242         const currentSearchId = getSelectedSearchId();
243         const state = searchState.get(currentSearchId);
244         // don't bother sending a stop request if already stopped
245         if (state && state.running)
246             stopSearch(searchId);
248         tab.destroy();
250         fetch("api/v2/search/delete", {
251             method: "POST",
252             body: new URLSearchParams({
253                 id: searchId
254             })
255         });
257         const searchJobs = JSON.parse(LocalPreferences.get("search_jobs", "[]"));
258         const jobIndex = searchJobs.findIndex((job) => job.id === searchId);
259         if (jobIndex >= 0) {
260             searchJobs.splice(jobIndex, 1);
261             LocalPreferences.set("search_jobs", JSON.stringify(searchJobs));
262         }
264         if (numSearchTabs() === 0) {
265             resetSearchState();
266             resetFilters();
268             $("numSearchResultsVisible").textContent = 0;
269             $("numSearchResultsTotal").textContent = 0;
270             $("searchResultsNoSearches").classList.remove("invisible");
271             $("searchResultsFilters").classList.add("invisible");
272             $("searchResultsTableContainer").classList.add("invisible");
273             $("searchTabsToolbar").classList.add("invisible");
274         }
275         else if (isTabSelected && newTabToSelect) {
276             setActiveTab(newTabToSelect);
277             document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
278         }
279     };
281     const saveCurrentTabState = () => {
282         const currentSearchId = getSelectedSearchId();
283         if (!currentSearchId)
284             return;
286         const state = searchState.get(currentSearchId);
287         if (!state)
288             return;
290         state.filterPattern = searchText.filterPattern;
291         state.seedsFilter = {
292             min: searchSeedsFilter.min,
293             max: searchSeedsFilter.max,
294         };
295         state.sizeFilter = {
296             min: searchSizeFilter.min,
297             minUnit: searchSizeFilter.minUnit,
298             max: searchSizeFilter.max,
299             maxUnit: searchSizeFilter.maxUnit,
300         };
301         state.searchIn = getSearchInTorrentName();
303         state.sort = {
304             column: searchResultsTable.sortedColumn,
305             reverse: searchResultsTable.reverseSort,
306         };
308         // we must copy the array to avoid taking a reference to it
309         state.selectedRowIds = [...searchResultsTable.selectedRows];
310     };
312     const setActiveTab = (tab) => {
313         const searchId = getSearchIdFromTab(tab);
314         if (searchId === getSelectedSearchId())
315             return;
317         saveCurrentTabState();
319         MochaUI.selected(tab, "searchTabs");
321         const state = searchState.get(searchId);
322         let rowsToSelect = [];
324         // restore table rows
325         searchResultsTable.clear();
326         if (state) {
327             for (const row of state.rows)
328                 searchResultsTable.updateRowData(row);
330             rowsToSelect = state.selectedRowIds;
332             // restore filters
333             searchText.pattern = state.searchPattern;
334             searchText.filterPattern = state.filterPattern;
335             $("searchInNameFilter").value = state.filterPattern;
337             searchSeedsFilter.min = state.seedsFilter.min;
338             searchSeedsFilter.max = state.seedsFilter.max;
339             $("searchMinSeedsFilter").value = state.seedsFilter.min;
340             $("searchMaxSeedsFilter").value = state.seedsFilter.max;
342             searchSizeFilter.min = state.sizeFilter.min;
343             searchSizeFilter.minUnit = state.sizeFilter.minUnit;
344             searchSizeFilter.max = state.sizeFilter.max;
345             searchSizeFilter.maxUnit = state.sizeFilter.maxUnit;
346             $("searchMinSizeFilter").value = state.sizeFilter.min;
347             $("searchMinSizePrefix").value = state.sizeFilter.minUnit;
348             $("searchMaxSizeFilter").value = state.sizeFilter.max;
349             $("searchMaxSizePrefix").value = state.sizeFilter.maxUnit;
351             const currentSearchPattern = $("searchPattern").value.trim();
352             if (state.running && (state.searchPattern === currentSearchPattern)) {
353                 // allow search to be stopped
354                 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Stop)QBT_TR[CONTEXT=SearchEngineWidget]";
355                 searchPatternChanged = false;
356             }
358             searchResultsTable.setSortedColumn(state.sort.column, state.sort.reverse);
360             $("searchInTorrentName").value = state.searchIn;
361         }
363         // must restore all filters before calling updateTable
364         searchResultsTable.updateTable();
366         // must reselect rows after calling updateTable
367         if (rowsToSelect.length > 0)
368             searchResultsTable.reselectRows(rowsToSelect);
370         $("numSearchResultsVisible").textContent = searchResultsTable.getFilteredAndSortedRows().length;
371         $("numSearchResultsTotal").textContent = searchResultsTable.getRowSize();
372     };
374     const getStatusIconElement = (text, image) => {
375         const statusIcon = document.createElement("img");
376         statusIcon.alt = text;
377         statusIcon.title = text;
378         statusIcon.src = image;
379         statusIcon.className = "statusIcon";
380         statusIcon.width = "12";
381         statusIcon.height = "12";
382         return statusIcon;
383     };
385     const updateStatusIconElement = (searchId, text, image) => {
386         const searchTab = $(`${searchTabIdPrefix}${searchId}`);
387         if (searchTab) {
388             const statusIcon = searchTab.querySelector(".statusIcon");
389             statusIcon.alt = text;
390             statusIcon.title = text;
391             statusIcon.src = image;
392         }
393     };
395     const startSearch = (pattern, category, plugins) => {
396         searchPatternChanged = false;
397         fetch("api/v2/search/start", {
398                 method: "POST",
399                 body: new URLSearchParams({
400                     pattern: pattern,
401                     category: category,
402                     plugins: plugins
403                 })
404             })
405             .then(async (response) => {
406                 if (!response.ok)
407                     return;
409                 const responseJSON = await response.json();
411                 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Stop)QBT_TR[CONTEXT=SearchEngineWidget]";
412                 const searchId = responseJSON.id;
413                 createSearchTab(searchId, pattern);
415                 const searchJobs = JSON.parse(LocalPreferences.get("search_jobs", "[]"));
416                 searchJobs.push({ id: searchId, pattern: pattern });
417                 LocalPreferences.set("search_jobs", JSON.stringify(searchJobs));
418             });
419     };
421     const stopSearch = (searchId) => {
422         fetch("api/v2/search/stop", {
423                 method: "POST",
424                 body: new URLSearchParams({
425                     id: searchId
426                 })
427             })
428             .then((response) => {
429                 if (!response.ok)
430                     return;
432                 resetSearchState(searchId);
433                 // not strictly necessary to do this when the tab is being closed, but there's no harm in it
434                 updateStatusIconElement(searchId, "QBT_TR(Search aborted)QBT_TR[CONTEXT=SearchJobWidget]", "images/task-reject.svg");
435             });
436     };
438     const getSelectedSearchId = () => {
439         const selectedTab = $("searchTabs").querySelector("li.selected");
440         return selectedTab ? getSearchIdFromTab(selectedTab) : null;
441     };
443     const startStopSearch = () => {
444         const currentSearchId = getSelectedSearchId();
445         const state = searchState.get(currentSearchId);
446         const isSearchRunning = state && state.running;
447         if (!isSearchRunning || searchPatternChanged) {
448             const pattern = $("searchPattern").value.trim();
449             const category = $("categorySelect").value;
450             const plugins = $("pluginsSelect").value;
452             if (!pattern || !category || !plugins)
453                 return;
455             searchText.pattern = pattern;
456             startSearch(pattern, category, plugins);
457         }
458         else {
459             stopSearch(currentSearchId);
460         }
461     };
463     const openSearchTorrentDescriptionUrl = () => {
464         for (const rowID of searchResultsTable.selectedRowsIds())
465             window.open(searchResultsTable.getRow(rowID).full_data.descrLink, "_blank");
466     };
468     const copySearchTorrentName = () => {
469         const names = [];
470         searchResultsTable.selectedRowsIds().each((rowId) => {
471             names.push(searchResultsTable.getRow(rowId).full_data.fileName);
472         });
473         return names.join("\n");
474     };
476     const copySearchTorrentDownloadLink = () => {
477         const urls = [];
478         searchResultsTable.selectedRowsIds().each((rowId) => {
479             urls.push(searchResultsTable.getRow(rowId).full_data.fileUrl);
480         });
481         return urls.join("\n");
482     };
484     const copySearchTorrentDescriptionUrl = () => {
485         const urls = [];
486         searchResultsTable.selectedRowsIds().each((rowId) => {
487             urls.push(searchResultsTable.getRow(rowId).full_data.descrLink);
488         });
489         return urls.join("\n");
490     };
492     const downloadSearchTorrent = () => {
493         const urls = [];
494         for (const rowID of searchResultsTable.selectedRowsIds())
495             urls.push(searchResultsTable.getRow(rowID).full_data.fileUrl);
497         // only proceed if at least 1 row was selected
498         if (!urls.length)
499             return;
501         showDownloadPage(urls);
502     };
504     const manageSearchPlugins = () => {
505         const id = "searchPlugins";
506         if (!$(id)) {
507             new MochaUI.Window({
508                 id: id,
509                 title: "QBT_TR(Search plugins)QBT_TR[CONTEXT=PluginSelectDlg]",
510                 icon: "images/qbittorrent-tray.svg",
511                 loadMethod: "xhr",
512                 contentURL: "views/searchplugins.html",
513                 scrollbars: false,
514                 maximizable: false,
515                 paddingVertical: 0,
516                 paddingHorizontal: 0,
517                 width: loadWindowWidth(id, 600),
518                 height: loadWindowHeight(id, 360),
519                 onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
520                     saveWindowSize(id);
521                 }),
522                 onBeforeBuild: () => {
523                     loadSearchPlugins();
524                 },
525                 onClose: () => {
526                     clearTimeout(loadSearchPluginsTimer);
527                     loadSearchPluginsTimer = -1;
528                 }
529             });
530         }
531     };
533     const loadSearchPlugins = () => {
534         getPlugins();
535         loadSearchPluginsTimer = loadSearchPlugins.delay(2000);
536     };
538     const onSearchPatternChanged = () => {
539         const currentSearchId = getSelectedSearchId();
540         const state = searchState.get(currentSearchId);
541         const currentSearchPattern = $("searchPattern").value.trim();
542         // start a new search if pattern has changed, otherwise allow the search to be stopped
543         if (state && (state.searchPattern === currentSearchPattern)) {
544             searchPatternChanged = false;
545             document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Stop)QBT_TR[CONTEXT=SearchEngineWidget]";
546         }
547         else {
548             searchPatternChanged = true;
549             document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
550         }
551     };
553     const categorySelected = () => {
554         selectedCategory = $("categorySelect").value;
555     };
557     const pluginSelected = () => {
558         selectedPlugin = $("pluginsSelect").value;
560         if (selectedPlugin !== prevSelectedPlugin) {
561             prevSelectedPlugin = selectedPlugin;
562             getSearchCategories();
563         }
564     };
566     const reselectCategory = () => {
567         for (let i = 0; i < $("categorySelect").options.length; ++i) {
568             if ($("categorySelect").options[i].get("value") === selectedCategory)
569                 $("categorySelect").options[i].selected = true;
570         }
572         categorySelected();
573     };
575     const reselectPlugin = () => {
576         for (let i = 0; i < $("pluginsSelect").options.length; ++i) {
577             if ($("pluginsSelect").options[i].get("value") === selectedPlugin)
578                 $("pluginsSelect").options[i].selected = true;
579         }
581         pluginSelected();
582     };
584     const resetSearchState = (searchId) => {
585         document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
586         const state = searchState.get(searchId);
587         if (state) {
588             state.running = false;
589             clearTimeout(state.loadResultsTimer);
590             state.loadResultsTimer = -1;
591         }
592     };
594     const getSearchCategories = () => {
595         const populateCategorySelect = (categories) => {
596             const categoryOptions = [];
598             for (const category of categories) {
599                 const option = document.createElement("option");
600                 option.value = category.id;
601                 option.textContent = category.name;
602                 categoryOptions.push(option);
603             };
605             // first category is "All Categories"
606             if (categoryOptions.length > 1) {
607                 // add separator
608                 const option = document.createElement("option");
609                 option.disabled = true;
610                 option.textContent = "──────────";
611                 categoryOptions.splice(1, 0, option);
612             }
614             $("categorySelect").replaceChildren(...categoryOptions);
615         };
617         const selectedPlugin = $("pluginsSelect").value;
619         if ((selectedPlugin === "all") || (selectedPlugin === "enabled")) {
620             const uniqueCategories = {};
621             for (const plugin of searchPlugins) {
622                 if ((selectedPlugin === "enabled") && !plugin.enabled)
623                     continue;
624                 for (const category of plugin.supportedCategories) {
625                     if (uniqueCategories[category.id] === undefined)
626                         uniqueCategories[category.id] = category;
627                 }
628             }
629             // we must sort the ids to maintain consistent order.
630             const categories = Object.keys(uniqueCategories).sort().map(id => uniqueCategories[id]);
631             populateCategorySelect(categories);
632         }
633         else {
634             const plugin = getPlugin(selectedPlugin);
635             const plugins = (plugin === null) ? [] : plugin.supportedCategories;
636             populateCategorySelect(plugins);
637         }
639         reselectCategory();
640     };
642     const getPlugins = () => {
643         fetch("api/v2/search/plugins", {
644                 method: "GET",
645                 cache: "no-store"
646             })
647             .then(async (response) => {
648                 if (!response.ok)
649                     return;
651                 const responseJSON = await response.json();
653                 const createOption = (text, value, disabled = false) => {
654                     const option = document.createElement("option");
655                     if (value !== undefined)
656                         option.value = value;
657                     option.textContent = text;
658                     option.disabled = disabled;
659                     return option;
660                 };
662                 if (prevSearchPluginsResponse !== responseJSON) {
663                     prevSearchPluginsResponse = responseJSON;
664                     searchPlugins.length = 0;
665                     responseJSON.forEach((plugin) => {
666                         searchPlugins.push(plugin);
667                     });
669                     const pluginOptions = [];
670                     pluginOptions.push(createOption("QBT_TR(Only enabled)QBT_TR[CONTEXT=SearchEngineWidget]", "enabled"));
671                     pluginOptions.push(createOption("QBT_TR(All plugins)QBT_TR[CONTEXT=SearchEngineWidget]", "all"));
673                     const searchPluginsEmpty = (searchPlugins.length === 0);
674                     if (!searchPluginsEmpty) {
675                         $("searchResultsNoPlugins").classList.add("invisible");
676                         if (numSearchTabs() === 0)
677                             $("searchResultsNoSearches").classList.remove("invisible");
679                         // sort plugins alphabetically
680                         const allPlugins = searchPlugins.sort((left, right) => {
681                             const leftName = left.fullName;
682                             const rightName = right.fullName;
683                             return window.qBittorrent.Misc.naturalSortCollator.compare(leftName, rightName);
684                         });
686                         allPlugins.each((plugin) => {
687                             if (plugin.enabled === true)
688                                 pluginOptions.push(createOption(plugin.fullName, plugin.name));
689                         });
691                         if (pluginOptions.length > 2)
692                             pluginOptions.splice(2, 0, createOption("──────────", undefined, true));
693                     }
695                     $("pluginsSelect").replaceChildren(...pluginOptions);
697                     $("searchPattern").disabled = searchPluginsEmpty;
698                     $("categorySelect").disabled = searchPluginsEmpty;
699                     $("pluginsSelect").disabled = searchPluginsEmpty;
700                     document.getElementById("startSearchButton").disabled = searchPluginsEmpty;
702                     if (window.qBittorrent.SearchPlugins !== undefined)
703                         window.qBittorrent.SearchPlugins.updateTable();
705                     reselectPlugin();
706                 }
707             });
708     };
710     const getPlugin = (name) => {
711         for (let i = 0; i < searchPlugins.length; ++i) {
712             if (searchPlugins[i].name === name)
713                 return searchPlugins[i];
714         }
716         return null;
717     };
719     const resetFilters = () => {
720         searchText.filterPattern = "";
721         $("searchInNameFilter").value = "";
723         searchSeedsFilter.min = 0;
724         searchSeedsFilter.max = 0;
725         $("searchMinSeedsFilter").value = searchSeedsFilter.min;
726         $("searchMaxSeedsFilter").value = searchSeedsFilter.max;
728         searchSizeFilter.min = 0.00;
729         searchSizeFilter.minUnit = 2; // B = 0, KiB = 1, MiB = 2, GiB = 3, TiB = 4, PiB = 5, EiB = 6
730         searchSizeFilter.max = 0.00;
731         searchSizeFilter.maxUnit = 3;
732         $("searchMinSizeFilter").value = searchSizeFilter.min;
733         $("searchMinSizePrefix").value = searchSizeFilter.minUnit;
734         $("searchMaxSizeFilter").value = searchSizeFilter.max;
735         $("searchMaxSizePrefix").value = searchSizeFilter.maxUnit;
736     };
738     const getSearchInTorrentName = () => {
739         return ($("searchInTorrentName").value === "names") ? "names" : "everywhere";
740     };
742     const searchInTorrentName = () => {
743         LocalPreferences.set("search_in_filter", getSearchInTorrentName());
744         searchFilterChanged();
745     };
747     const searchSeedsFilterChanged = () => {
748         searchSeedsFilter.min = $("searchMinSeedsFilter").value;
749         searchSeedsFilter.max = $("searchMaxSeedsFilter").value;
751         searchFilterChanged();
752     };
754     const searchSizeFilterChanged = () => {
755         searchSizeFilter.min = $("searchMinSizeFilter").value;
756         searchSizeFilter.minUnit = $("searchMinSizePrefix").value;
757         searchSizeFilter.max = $("searchMaxSizeFilter").value;
758         searchSizeFilter.maxUnit = $("searchMaxSizePrefix").value;
760         searchFilterChanged();
761     };
763     const searchSizeFilterPrefixChanged = () => {
764         if ((Number($("searchMinSizeFilter").value) !== 0) || (Number($("searchMaxSizeFilter").value) !== 0))
765             searchSizeFilterChanged();
766     };
768     const searchFilterChanged = () => {
769         searchResultsTable.updateTable();
770         $("numSearchResultsVisible").textContent = searchResultsTable.getFilteredAndSortedRows().length;
771     };
773     const loadSearchResultsData = function(searchId) {
774         const state = searchState.get(searchId);
775         const url = new URL("api/v2/search/results", window.location);
776         url.search = new URLSearchParams({
777             id: searchId,
778             limit: 500,
779             offset: state.rowId
780         });
781         fetch(url, {
782                 method: "GET",
783                 cache: "no-store"
784             })
785             .then(async (response) => {
786                 if (!response.ok) {
787                     if ((response.status === 400) || (response.status === 404)) {
788                         // bad params. search id is invalid
789                         resetSearchState(searchId);
790                         updateStatusIconElement(searchId, "QBT_TR(An error occurred during search...)QBT_TR[CONTEXT=SearchJobWidget]", "images/error.svg");
791                     }
792                     else {
793                         clearTimeout(state.loadResultsTimer);
794                         state.loadResultsTimer = loadSearchResultsData.delay(3000, this, searchId);
795                     }
796                     return;
797                 }
799                 $("error_div").textContent = "";
801                 const state = searchState.get(searchId);
802                 // check if user stopped the search prior to receiving the response
803                 if (!state.running) {
804                     clearTimeout(state.loadResultsTimer);
805                     updateStatusIconElement(searchId, "QBT_TR(Search aborted)QBT_TR[CONTEXT=SearchJobWidget]", "images/task-reject.svg");
806                     return;
807                 }
809                 const responseJSON = await response.json();
810                 if (responseJSON) {
811                     const state = searchState.get(searchId);
812                     const newRows = [];
814                     if (responseJSON.results) {
815                         const results = responseJSON.results;
816                         for (let i = 0; i < results.length; ++i) {
817                             const result = results[i];
818                             const row = {
819                                 rowId: state.rowId,
820                                 descrLink: result.descrLink,
821                                 fileName: result.fileName,
822                                 fileSize: result.fileSize,
823                                 fileUrl: result.fileUrl,
824                                 nbLeechers: result.nbLeechers,
825                                 nbSeeders: result.nbSeeders,
826                                 engineName: result.engineName,
827                                 siteUrl: result.siteUrl,
828                                 pubDate: result.pubDate,
829                             };
831                             newRows.push(row);
832                             state.rows.push(row);
833                             state.rowId += 1;
834                         }
835                     }
837                     // only update table if this search is currently being displayed
838                     if (searchId === getSelectedSearchId()) {
839                         for (const row of newRows)
840                             searchResultsTable.updateRowData(row);
842                         $("numSearchResultsVisible").textContent = searchResultsTable.getFilteredAndSortedRows().length;
843                         $("numSearchResultsTotal").textContent = searchResultsTable.getRowSize();
845                         searchResultsTable.updateTable();
846                     }
848                     if ((responseJSON.status === "Stopped") && (state.rowId >= responseJSON.total)) {
849                         resetSearchState(searchId);
850                         updateStatusIconElement(searchId, "QBT_TR(Search has finished)QBT_TR[CONTEXT=SearchJobWidget]", "images/task-complete.svg");
851                         return;
852                     }
853                 }
855                 clearTimeout(state.loadResultsTimer);
856                 state.loadResultsTimer = loadSearchResultsData.delay(2000, this, searchId);
857             });
858     };
860     const updateSearchResultsData = function(searchId) {
861         const state = searchState.get(searchId);
862         clearTimeout(state.loadResultsTimer);
863         state.loadResultsTimer = loadSearchResultsData.delay(500, this, searchId);
864     };
866     new ClipboardJS(".copySearchDataToClipboard", {
867         text: (trigger) => {
868             switch (trigger.id) {
869                 case "copySearchTorrentName":
870                     return copySearchTorrentName();
871                 case "copySearchTorrentDownloadLink":
872                     return copySearchTorrentDownloadLink();
873                 case "copySearchTorrentDescriptionUrl":
874                     return copySearchTorrentDescriptionUrl();
875                 default:
876                     return "";
877             }
878         }
879     });
881     return exports();
882 })();
883 Object.freeze(window.qBittorrent.Search);