WebUI: Use Map instead of Mootools Hash in Torrents table
[qBittorrent.git] / src / webui / www / private / scripts / search.js
blobb9942b98c00b4bff4fa7dead5fbe5b1b38e7b12b
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 init = function() {
90         // load "Search in" preference from local storage
91         $("searchInTorrentName").value = (LocalPreferences.get("search_in_filter") === "names") ? "names" : "everywhere";
92         const searchResultsTableContextMenu = new window.qBittorrent.ContextMenu.ContextMenu({
93             targets: ".searchTableRow",
94             menu: "searchResultsTableMenu",
95             actions: {
96                 Download: downloadSearchTorrent,
97                 OpenDescriptionUrl: openSearchTorrentDescriptionUrl
98             },
99             offsets: {
100                 x: -15,
101                 y: -53
102             }
103         });
104         searchResultsTable = new window.qBittorrent.DynamicTable.SearchResultsTable();
105         searchResultsTable.setup("searchResultsTableDiv", "searchResultsTableFixedHeaderDiv", searchResultsTableContextMenu);
106         getPlugins();
108         // listen for changes to searchInNameFilter
109         let searchInNameFilterTimer = -1;
110         $("searchInNameFilter").addEventListener("input", () => {
111             clearTimeout(searchInNameFilterTimer);
112             searchInNameFilterTimer = setTimeout(() => {
113                 searchInNameFilterTimer = -1;
115                 const value = $("searchInNameFilter").value;
116                 searchText.filterPattern = value;
117                 searchFilterChanged();
118             }, window.qBittorrent.Misc.FILTER_INPUT_DELAY);
119         });
121         new Keyboard({
122             defaultEventType: "keydown",
123             events: {
124                 "Enter": function(e) {
125                     // accept enter key as a click
126                     e.preventDefault();
127                     e.stopPropagation();
129                     const elem = e.event.srcElement;
130                     if (elem.className.contains("searchInputField")) {
131                         document.getElementById("startSearchButton").click();
132                         return;
133                     }
135                     switch (elem.id) {
136                         case "manageSearchPlugins":
137                             manageSearchPlugins();
138                             break;
139                     }
140                 }
141             }
142         }).activate();
144         // restore search tabs
145         const searchJobs = JSON.parse(LocalPreferences.get("search_jobs", "[]"));
146         for (const { id, pattern } of searchJobs)
147             createSearchTab(id, pattern);
148     };
150     const numSearchTabs = function() {
151         return $("searchTabs").getElements("li").length;
152     };
154     const getSearchIdFromTab = function(tab) {
155         return Number(tab.id.substring(searchTabIdPrefix.length));
156     };
158     const createSearchTab = function(searchId, pattern) {
159         const newTabId = `${searchTabIdPrefix}${searchId}`;
160         const tabElem = new Element("a", {
161             text: pattern,
162         });
164         const closeTabElem = new Element("img", {
165             alt: "QBT_TR(Close tab)QBT_TR[CONTEXT=SearchWidget]",
166             title: "QBT_TR(Close tab)QBT_TR[CONTEXT=SearchWidget]",
167             src: "images/application-exit.svg",
168             width: "8",
169             height: "8",
170             style: "padding-right: 7px; margin-bottom: -1px; margin-left: -5px",
171             onclick: "qBittorrent.Search.closeSearchTab(event, this);",
172         });
173         closeTabElem.inject(tabElem, "top");
175         tabElem.appendChild(getStatusIconElement("QBT_TR(Searching...)QBT_TR[CONTEXT=SearchJobWidget]", "images/queued.svg"));
177         const listItem = document.createElement("li");
178         listItem.id = newTabId;
179         listItem.classList.add("selected");
180         listItem.addEventListener("click", (e) => {
181             setActiveTab(listItem);
182             document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
183         });
184         listItem.appendChild(tabElem);
185         $("searchTabs").appendChild(listItem);
187         // unhide the results elements
188         if (numSearchTabs() >= 1) {
189             $("searchResultsNoSearches").style.display = "none";
190             $("searchResultsFilters").style.display = "block";
191             $("searchResultsTableContainer").style.display = "block";
192             $("searchTabsToolbar").style.display = "block";
193         }
195         // select new tab
196         setActiveTab(listItem);
198         searchResultsTable.clear();
199         resetFilters();
201         searchState.set(searchId, {
202             searchPattern: pattern,
203             filterPattern: searchText.filterPattern,
204             seedsFilter: { min: searchSeedsFilter.min, max: searchSeedsFilter.max },
205             sizeFilter: { min: searchSizeFilter.min, minUnit: searchSizeFilter.minUnit, max: searchSizeFilter.max, maxUnit: searchSizeFilter.maxUnit },
206             searchIn: getSearchInTorrentName(),
207             rows: [],
208             rowId: 0,
209             selectedRowIds: [],
210             running: true,
211             loadResultsTimer: -1,
212             sort: { column: searchResultsTable.sortedColumn, reverse: searchResultsTable.reverseSort },
213         });
214         updateSearchResultsData(searchId);
215     };
217     const closeSearchTab = function(e, el) {
218         e.stopPropagation();
220         const tab = el.parentElement.parentElement;
221         const searchId = getSearchIdFromTab(tab);
222         const isTabSelected = tab.hasClass("selected");
223         const newTabToSelect = isTabSelected ? (tab.nextSibling || tab.previousSibling) : null;
225         const currentSearchId = getSelectedSearchId();
226         const state = searchState.get(currentSearchId);
227         // don't bother sending a stop request if already stopped
228         if (state && state.running)
229             stopSearch(searchId);
231         tab.destroy();
233         new Request({
234             url: new URI("api/v2/search/delete"),
235             method: "post",
236             data: {
237                 id: searchId
238             },
239         }).send();
241         const searchJobs = JSON.parse(LocalPreferences.get("search_jobs", "[]"));
242         const jobIndex = searchJobs.findIndex((job) => job.id === searchId);
243         if (jobIndex >= 0) {
244             searchJobs.splice(jobIndex, 1);
245             LocalPreferences.set("search_jobs", JSON.stringify(searchJobs));
246         }
248         if (numSearchTabs() === 0) {
249             resetSearchState();
250             resetFilters();
252             $("numSearchResultsVisible").textContent = 0;
253             $("numSearchResultsTotal").textContent = 0;
254             $("searchResultsNoSearches").style.display = "block";
255             $("searchResultsFilters").style.display = "none";
256             $("searchResultsTableContainer").style.display = "none";
257             $("searchTabsToolbar").style.display = "none";
258         }
259         else if (isTabSelected && newTabToSelect) {
260             setActiveTab(newTabToSelect);
261             document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
262         }
263     };
265     const saveCurrentTabState = function() {
266         const currentSearchId = getSelectedSearchId();
267         if (!currentSearchId)
268             return;
270         const state = searchState.get(currentSearchId);
271         if (!state)
272             return;
274         state.filterPattern = searchText.filterPattern;
275         state.seedsFilter = {
276             min: searchSeedsFilter.min,
277             max: searchSeedsFilter.max,
278         };
279         state.sizeFilter = {
280             min: searchSizeFilter.min,
281             minUnit: searchSizeFilter.minUnit,
282             max: searchSizeFilter.max,
283             maxUnit: searchSizeFilter.maxUnit,
284         };
285         state.searchIn = getSearchInTorrentName();
287         state.sort = {
288             column: searchResultsTable.sortedColumn,
289             reverse: searchResultsTable.reverseSort,
290         };
292         // we must copy the array to avoid taking a reference to it
293         state.selectedRowIds = [...searchResultsTable.selectedRows];
294     };
296     const setActiveTab = function(tab) {
297         const searchId = getSearchIdFromTab(tab);
298         if (searchId === getSelectedSearchId())
299             return;
301         saveCurrentTabState();
303         MochaUI.selected(tab, "searchTabs");
305         const state = searchState.get(searchId);
306         let rowsToSelect = [];
308         // restore table rows
309         searchResultsTable.clear();
310         if (state) {
311             for (const row of state.rows)
312                 searchResultsTable.updateRowData(row);
314             rowsToSelect = state.selectedRowIds;
316             // restore filters
317             searchText.pattern = state.searchPattern;
318             searchText.filterPattern = state.filterPattern;
319             $("searchInNameFilter").value = state.filterPattern;
321             searchSeedsFilter.min = state.seedsFilter.min;
322             searchSeedsFilter.max = state.seedsFilter.max;
323             $("searchMinSeedsFilter").value = state.seedsFilter.min;
324             $("searchMaxSeedsFilter").value = state.seedsFilter.max;
326             searchSizeFilter.min = state.sizeFilter.min;
327             searchSizeFilter.minUnit = state.sizeFilter.minUnit;
328             searchSizeFilter.max = state.sizeFilter.max;
329             searchSizeFilter.maxUnit = state.sizeFilter.maxUnit;
330             $("searchMinSizeFilter").value = state.sizeFilter.min;
331             $("searchMinSizePrefix").value = state.sizeFilter.minUnit;
332             $("searchMaxSizeFilter").value = state.sizeFilter.max;
333             $("searchMaxSizePrefix").value = state.sizeFilter.maxUnit;
335             const currentSearchPattern = $("searchPattern").value.trim();
336             if (state.running && (state.searchPattern === currentSearchPattern)) {
337                 // allow search to be stopped
338                 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Stop)QBT_TR[CONTEXT=SearchEngineWidget]";
339                 searchPatternChanged = false;
340             }
342             searchResultsTable.setSortedColumn(state.sort.column, state.sort.reverse);
344             $("searchInTorrentName").value = state.searchIn;
345         }
347         // must restore all filters before calling updateTable
348         searchResultsTable.updateTable();
350         // must reselect rows after calling updateTable
351         if (rowsToSelect.length > 0)
352             searchResultsTable.reselectRows(rowsToSelect);
354         $("numSearchResultsVisible").textContent = searchResultsTable.getFilteredAndSortedRows().length;
355         $("numSearchResultsTotal").textContent = searchResultsTable.getRowIds().length;
357         setupSearchTableEvents(true);
358     };
360     const getStatusIconElement = function(text, image) {
361         return new Element("img", {
362             alt: text,
363             title: text,
364             src: image,
365             class: "statusIcon",
366             width: "10",
367             height: "10",
368             style: "margin-bottom: -2px; margin-left: 7px",
369         });
370     };
372     const updateStatusIconElement = function(searchId, text, image) {
373         const searchTab = $(`${searchTabIdPrefix}${searchId}`);
374         if (searchTab) {
375             const statusIcon = searchTab.getElement(".statusIcon");
376             statusIcon.alt = text;
377             statusIcon.title = text;
378             statusIcon.src = image;
379         }
380     };
382     const startSearch = function(pattern, category, plugins) {
383         searchPatternChanged = false;
385         const url = new URI("api/v2/search/start");
386         new Request.JSON({
387             url: url,
388             method: "post",
389             data: {
390                 pattern: pattern,
391                 category: category,
392                 plugins: plugins
393             },
394             onSuccess: (response) => {
395                 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Stop)QBT_TR[CONTEXT=SearchEngineWidget]";
396                 const searchId = response.id;
397                 createSearchTab(searchId, pattern);
399                 const searchJobs = JSON.parse(LocalPreferences.get("search_jobs", "[]"));
400                 searchJobs.push({ id: searchId, pattern: pattern });
401                 LocalPreferences.set("search_jobs", JSON.stringify(searchJobs));
402             }
403         }).send();
404     };
406     const stopSearch = function(searchId) {
407         const url = new URI("api/v2/search/stop");
408         new Request({
409             url: url,
410             method: "post",
411             data: {
412                 id: searchId
413             },
414             onSuccess: function(response) {
415                 resetSearchState(searchId);
416                 // not strictly necessary to do this when the tab is being closed, but there's no harm in it
417                 updateStatusIconElement(searchId, "QBT_TR(Search aborted)QBT_TR[CONTEXT=SearchJobWidget]", "images/task-reject.svg");
418             }
419         }).send();
420     };
422     const getSelectedSearchId = function() {
423         const selectedTab = $("searchTabs").getElement("li.selected");
424         return selectedTab ? getSearchIdFromTab(selectedTab) : null;
425     };
427     const startStopSearch = function() {
428         const currentSearchId = getSelectedSearchId();
429         const state = searchState.get(currentSearchId);
430         const isSearchRunning = state && state.running;
431         if (!isSearchRunning || searchPatternChanged) {
432             const pattern = $("searchPattern").value.trim();
433             const category = $("categorySelect").value;
434             const plugins = $("pluginsSelect").value;
436             if (!pattern || !category || !plugins)
437                 return;
439             searchText.pattern = pattern;
440             startSearch(pattern, category, plugins);
441         }
442         else {
443             stopSearch(currentSearchId);
444         }
445     };
447     const openSearchTorrentDescriptionUrl = function() {
448         searchResultsTable.selectedRowsIds().each((rowId) => {
449             window.open(searchResultsTable.rows.get(rowId).full_data.descrLink, "_blank");
450         });
451     };
453     const copySearchTorrentName = function() {
454         const names = [];
455         searchResultsTable.selectedRowsIds().each((rowId) => {
456             names.push(searchResultsTable.rows.get(rowId).full_data.fileName);
457         });
458         return names.join("\n");
459     };
461     const copySearchTorrentDownloadLink = function() {
462         const urls = [];
463         searchResultsTable.selectedRowsIds().each((rowId) => {
464             urls.push(searchResultsTable.rows.get(rowId).full_data.fileUrl);
465         });
466         return urls.join("\n");
467     };
469     const copySearchTorrentDescriptionUrl = function() {
470         const urls = [];
471         searchResultsTable.selectedRowsIds().each((rowId) => {
472             urls.push(searchResultsTable.rows.get(rowId).full_data.descrLink);
473         });
474         return urls.join("\n");
475     };
477     const downloadSearchTorrent = function() {
478         const urls = [];
479         searchResultsTable.selectedRowsIds().each((rowId) => {
480             urls.push(searchResultsTable.rows.get(rowId).full_data.fileUrl);
481         });
483         // only proceed if at least 1 row was selected
484         if (!urls.length)
485             return;
487         showDownloadPage(urls);
488     };
490     const manageSearchPlugins = function() {
491         const id = "searchPlugins";
492         if (!$(id)) {
493             new MochaUI.Window({
494                 id: id,
495                 title: "QBT_TR(Search plugins)QBT_TR[CONTEXT=PluginSelectDlg]",
496                 icon: "images/qbittorrent-tray.svg",
497                 loadMethod: "xhr",
498                 contentURL: "views/searchplugins.html",
499                 scrollbars: false,
500                 maximizable: false,
501                 paddingVertical: 0,
502                 paddingHorizontal: 0,
503                 width: loadWindowWidth(id, 600),
504                 height: loadWindowHeight(id, 360),
505                 onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
506                     saveWindowSize(id);
507                 }),
508                 onBeforeBuild: function() {
509                     loadSearchPlugins();
510                 },
511                 onClose: function() {
512                     clearTimeout(loadSearchPluginsTimer);
513                     loadSearchPluginsTimer = -1;
514                 }
515             });
516         }
517     };
519     const loadSearchPlugins = function() {
520         getPlugins();
521         loadSearchPluginsTimer = loadSearchPlugins.delay(2000);
522     };
524     const onSearchPatternChanged = function() {
525         const currentSearchId = getSelectedSearchId();
526         const state = searchState.get(currentSearchId);
527         const currentSearchPattern = $("searchPattern").value.trim();
528         // start a new search if pattern has changed, otherwise allow the search to be stopped
529         if (state && (state.searchPattern === currentSearchPattern)) {
530             searchPatternChanged = false;
531             document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Stop)QBT_TR[CONTEXT=SearchEngineWidget]";
532         }
533         else {
534             searchPatternChanged = true;
535             document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
536         }
537     };
539     const categorySelected = function() {
540         selectedCategory = $("categorySelect").value;
541     };
543     const pluginSelected = function() {
544         selectedPlugin = $("pluginsSelect").value;
546         if (selectedPlugin !== prevSelectedPlugin) {
547             prevSelectedPlugin = selectedPlugin;
548             getSearchCategories();
549         }
550     };
552     const reselectCategory = function() {
553         for (let i = 0; i < $("categorySelect").options.length; ++i) {
554             if ($("categorySelect").options[i].get("value") === selectedCategory)
555                 $("categorySelect").options[i].selected = true;
556         }
558         categorySelected();
559     };
561     const reselectPlugin = function() {
562         for (let i = 0; i < $("pluginsSelect").options.length; ++i) {
563             if ($("pluginsSelect").options[i].get("value") === selectedPlugin)
564                 $("pluginsSelect").options[i].selected = true;
565         }
567         pluginSelected();
568     };
570     const resetSearchState = function(searchId) {
571         document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
572         const state = searchState.get(searchId);
573         if (state) {
574             state.running = false;
575             clearTimeout(state.loadResultsTimer);
576             state.loadResultsTimer = -1;
577         }
578     };
580     const getSearchCategories = () => {
581         const populateCategorySelect = (categories) => {
582             const categoryOptions = [];
584             for (const category of categories) {
585                 const option = document.createElement("option");
586                 option.value = category.id;
587                 option.textContent = category.name;
588                 categoryOptions.push(option);
589             };
591             // first category is "All Categories"
592             if (categoryOptions.length > 1) {
593                 // add separator
594                 const option = document.createElement("option");
595                 option.disabled = true;
596                 option.textContent = "──────────";
597                 categoryOptions.splice(1, 0, option);
598             }
600             $("categorySelect").replaceChildren(...categoryOptions);
601         };
603         const selectedPlugin = $("pluginsSelect").value;
605         if ((selectedPlugin === "all") || (selectedPlugin === "enabled")) {
606             const uniqueCategories = {};
607             for (const plugin of searchPlugins) {
608                 if ((selectedPlugin === "enabled") && !plugin.enabled)
609                     continue;
610                 for (const category of plugin.supportedCategories) {
611                     if (uniqueCategories[category.id] === undefined)
612                         uniqueCategories[category.id] = category;
613                 }
614             }
615             // we must sort the ids to maintain consistent order.
616             const categories = Object.keys(uniqueCategories).sort().map(id => uniqueCategories[id]);
617             populateCategorySelect(categories);
618         }
619         else {
620             const plugin = getPlugin(selectedPlugin);
621             const plugins = (plugin === null) ? [] : plugin.supportedCategories;
622             populateCategorySelect(plugins);
623         }
625         reselectCategory();
626     };
628     const getPlugins = function() {
629         new Request.JSON({
630             url: new URI("api/v2/search/plugins"),
631             method: "get",
632             noCache: true,
633             onSuccess: (response) => {
634                 const createOption = (text, value, disabled = false) => {
635                     const option = document.createElement("option");
636                     if (value !== undefined)
637                         option.value = value;
638                     option.textContent = text;
639                     option.disabled = disabled;
640                     return option;
641                 };
643                 if (response !== prevSearchPluginsResponse) {
644                     prevSearchPluginsResponse = response;
645                     searchPlugins.length = 0;
646                     response.forEach((plugin) => {
647                         searchPlugins.push(plugin);
648                     });
650                     const pluginOptions = [];
651                     pluginOptions.push(createOption("QBT_TR(Only enabled)QBT_TR[CONTEXT=SearchEngineWidget]", "enabled"));
652                     pluginOptions.push(createOption("QBT_TR(All plugins)QBT_TR[CONTEXT=SearchEngineWidget]", "all"));
654                     const searchPluginsEmpty = (searchPlugins.length === 0);
655                     if (!searchPluginsEmpty) {
656                         $("searchResultsNoPlugins").style.display = "none";
657                         if (numSearchTabs() === 0)
658                             $("searchResultsNoSearches").style.display = "block";
660                         // sort plugins alphabetically
661                         const allPlugins = searchPlugins.sort((left, right) => {
662                             const leftName = left.fullName;
663                             const rightName = right.fullName;
664                             return window.qBittorrent.Misc.naturalSortCollator.compare(leftName, rightName);
665                         });
667                         allPlugins.each((plugin) => {
668                             if (plugin.enabled === true)
669                                 pluginOptions.push(createOption(plugin.fullName, plugin.name));
670                         });
672                         if (pluginOptions.length > 2)
673                             pluginOptions.splice(2, 0, createOption("──────────", undefined, true));
674                     }
676                     $("pluginsSelect").replaceChildren(...pluginOptions);
678                     $("searchPattern").disabled = searchPluginsEmpty;
679                     $("categorySelect").disabled = searchPluginsEmpty;
680                     $("pluginsSelect").disabled = searchPluginsEmpty;
681                     document.getElementById("startSearchButton").disabled = searchPluginsEmpty;
683                     if (window.qBittorrent.SearchPlugins !== undefined)
684                         window.qBittorrent.SearchPlugins.updateTable();
686                     reselectPlugin();
687                 }
688             }
689         }).send();
690     };
692     const getPlugin = function(name) {
693         for (let i = 0; i < searchPlugins.length; ++i) {
694             if (searchPlugins[i].name === name)
695                 return searchPlugins[i];
696         }
698         return null;
699     };
701     const resetFilters = function() {
702         searchText.filterPattern = "";
703         $("searchInNameFilter").value = "";
705         searchSeedsFilter.min = 0;
706         searchSeedsFilter.max = 0;
707         $("searchMinSeedsFilter").value = searchSeedsFilter.min;
708         $("searchMaxSeedsFilter").value = searchSeedsFilter.max;
710         searchSizeFilter.min = 0.00;
711         searchSizeFilter.minUnit = 2; // B = 0, KiB = 1, MiB = 2, GiB = 3, TiB = 4, PiB = 5, EiB = 6
712         searchSizeFilter.max = 0.00;
713         searchSizeFilter.maxUnit = 3;
714         $("searchMinSizeFilter").value = searchSizeFilter.min;
715         $("searchMinSizePrefix").value = searchSizeFilter.minUnit;
716         $("searchMaxSizeFilter").value = searchSizeFilter.max;
717         $("searchMaxSizePrefix").value = searchSizeFilter.maxUnit;
718     };
720     const getSearchInTorrentName = function() {
721         return ($("searchInTorrentName").value === "names") ? "names" : "everywhere";
722     };
724     const searchInTorrentName = function() {
725         LocalPreferences.set("search_in_filter", getSearchInTorrentName());
726         searchFilterChanged();
727     };
729     const searchSeedsFilterChanged = function() {
730         searchSeedsFilter.min = $("searchMinSeedsFilter").value;
731         searchSeedsFilter.max = $("searchMaxSeedsFilter").value;
733         searchFilterChanged();
734     };
736     const searchSizeFilterChanged = function() {
737         searchSizeFilter.min = $("searchMinSizeFilter").value;
738         searchSizeFilter.minUnit = $("searchMinSizePrefix").value;
739         searchSizeFilter.max = $("searchMaxSizeFilter").value;
740         searchSizeFilter.maxUnit = $("searchMaxSizePrefix").value;
742         searchFilterChanged();
743     };
745     const searchSizeFilterPrefixChanged = function() {
746         if ((Number($("searchMinSizeFilter").value) !== 0) || (Number($("searchMaxSizeFilter").value) !== 0))
747             searchSizeFilterChanged();
748     };
750     const searchFilterChanged = function() {
751         searchResultsTable.updateTable();
752         $("numSearchResultsVisible").textContent = searchResultsTable.getFilteredAndSortedRows().length;
753     };
755     const setupSearchTableEvents = function(enable) {
756         const clickHandler = (e) => { downloadSearchTorrent(); };
757         if (enable) {
758             $$(".searchTableRow").each((target) => {
759                 target.addEventListener("dblclick", clickHandler);
760             });
761         }
762         else {
763             $$(".searchTableRow").each((target) => {
764                 target.removeEventListener("dblclick", clickHandler);
765             });
766         }
767     };
769     const loadSearchResultsData = function(searchId) {
770         const state = searchState.get(searchId);
772         const maxResults = 500;
773         const url = new URI("api/v2/search/results");
774         new Request.JSON({
775             url: url,
776             method: "get",
777             noCache: true,
778             data: {
779                 id: searchId,
780                 limit: maxResults,
781                 offset: state.rowId
782             },
783             onFailure: function(response) {
784                 if ((response.status === 400) || (response.status === 404)) {
785                     // bad params. search id is invalid
786                     resetSearchState(searchId);
787                     updateStatusIconElement(searchId, "QBT_TR(An error occurred during search...)QBT_TR[CONTEXT=SearchJobWidget]", "images/error.svg");
788                 }
789                 else {
790                     clearTimeout(state.loadResultsTimer);
791                     state.loadResultsTimer = loadSearchResultsData.delay(3000, this, searchId);
792                 }
793             },
794             onSuccess: function(response) {
795                 $("error_div").textContent = "";
797                 const state = searchState.get(searchId);
798                 // check if user stopped the search prior to receiving the response
799                 if (!state.running) {
800                     clearTimeout(state.loadResultsTimer);
801                     updateStatusIconElement(searchId, "QBT_TR(Search aborted)QBT_TR[CONTEXT=SearchJobWidget]", "images/task-reject.svg");
802                     return;
803                 }
805                 if (response) {
806                     setupSearchTableEvents(false);
808                     const state = searchState.get(searchId);
809                     const newRows = [];
811                     if (response.results) {
812                         const results = response.results;
813                         for (let i = 0; i < results.length; ++i) {
814                             const result = results[i];
815                             const row = {
816                                 rowId: state.rowId,
817                                 descrLink: result.descrLink,
818                                 fileName: result.fileName,
819                                 fileSize: result.fileSize,
820                                 fileUrl: result.fileUrl,
821                                 nbLeechers: result.nbLeechers,
822                                 nbSeeders: result.nbSeeders,
823                                 siteUrl: result.siteUrl,
824                                 pubDate: result.pubDate,
825                             };
827                             newRows.push(row);
828                             state.rows.push(row);
829                             state.rowId += 1;
830                         }
831                     }
833                     // only update table if this search is currently being displayed
834                     if (searchId === getSelectedSearchId()) {
835                         for (const row of newRows)
836                             searchResultsTable.updateRowData(row);
838                         $("numSearchResultsVisible").textContent = searchResultsTable.getFilteredAndSortedRows().length;
839                         $("numSearchResultsTotal").textContent = searchResultsTable.getRowIds().length;
841                         searchResultsTable.updateTable();
842                     }
844                     setupSearchTableEvents(true);
846                     if ((response.status === "Stopped") && (state.rowId >= response.total)) {
847                         resetSearchState(searchId);
848                         updateStatusIconElement(searchId, "QBT_TR(Search has finished)QBT_TR[CONTEXT=SearchJobWidget]", "images/task-complete.svg");
849                         return;
850                     }
851                 }
853                 clearTimeout(state.loadResultsTimer);
854                 state.loadResultsTimer = loadSearchResultsData.delay(2000, this, searchId);
855             }
856         }).send();
857     };
859     const updateSearchResultsData = function(searchId) {
860         const state = searchState.get(searchId);
861         clearTimeout(state.loadResultsTimer);
862         state.loadResultsTimer = loadSearchResultsData.delay(500, this, searchId);
863     };
865     new ClipboardJS(".copySearchDataToClipboard", {
866         text: function(trigger) {
867             switch (trigger.id) {
868                 case "copySearchTorrentName":
869                     return copySearchTorrentName();
870                 case "copySearchTorrentDownloadLink":
871                     return copySearchTorrentDownloadLink();
872                 case "copySearchTorrentDescriptionUrl":
873                     return copySearchTorrentDescriptionUrl();
874                 default:
875                     return "";
876             }
877         }
878     });
880     return exports();
881 })();
882 Object.freeze(window.qBittorrent.Search);