Enable customizing the save statistics time interval
[qBittorrent.git] / src / webui / www / private / scripts / dynamicTable.js
blob395e818143c5ac1ee460df366fdff01e779e539d
1 /*
2  * MIT License
3  * Copyright (c) 2008 Ishan Arora <ishan@qbittorrent.org> & Christophe Dumez <chris@qbittorrent.org>
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 /**************************************************************
26     Script      : Dynamic Table
27     Version     : 0.5
28     Authors     : Ishan Arora & Christophe Dumez
29     Desc        : Programmable sortable table
30     Licence     : Open Source MIT Licence
32  **************************************************************/
34 "use strict";
36 window.qBittorrent ??= {};
37 window.qBittorrent.DynamicTable ??= (() => {
38     const exports = () => {
39         return {
40             TorrentsTable: TorrentsTable,
41             TorrentPeersTable: TorrentPeersTable,
42             SearchResultsTable: SearchResultsTable,
43             SearchPluginsTable: SearchPluginsTable,
44             TorrentTrackersTable: TorrentTrackersTable,
45             BulkRenameTorrentFilesTable: BulkRenameTorrentFilesTable,
46             TorrentFilesTable: TorrentFilesTable,
47             LogMessageTable: LogMessageTable,
48             LogPeerTable: LogPeerTable,
49             RssFeedTable: RssFeedTable,
50             RssArticleTable: RssArticleTable,
51             RssDownloaderRulesTable: RssDownloaderRulesTable,
52             RssDownloaderFeedSelectionTable: RssDownloaderFeedSelectionTable,
53             RssDownloaderArticlesTable: RssDownloaderArticlesTable
54         };
55     };
57     const compareNumbers = (val1, val2) => {
58         if (val1 < val2)
59             return -1;
60         if (val1 > val2)
61             return 1;
62         return 0;
63     };
65     let DynamicTableHeaderContextMenuClass = null;
66     let ProgressColumnWidth = -1;
68     const DynamicTable = new Class({
70         initialize: function() {},
72         setup: function(dynamicTableDivId, dynamicTableFixedHeaderDivId, contextMenu) {
73             this.dynamicTableDivId = dynamicTableDivId;
74             this.dynamicTableFixedHeaderDivId = dynamicTableFixedHeaderDivId;
75             this.fixedTableHeader = $(dynamicTableFixedHeaderDivId).getElements("tr")[0];
76             this.hiddenTableHeader = $(dynamicTableDivId).getElements("tr")[0];
77             this.tableBody = $(dynamicTableDivId).getElements("tbody")[0];
78             this.rows = new Hash();
79             this.selectedRows = [];
80             this.columns = [];
81             this.contextMenu = contextMenu;
82             this.sortedColumn = LocalPreferences.get("sorted_column_" + this.dynamicTableDivId, 0);
83             this.reverseSort = LocalPreferences.get("reverse_sort_" + this.dynamicTableDivId, "0");
84             this.initColumns();
85             this.loadColumnsOrder();
86             this.updateTableHeaders();
87             this.setupCommonEvents();
88             this.setupHeaderEvents();
89             this.setupHeaderMenu();
90             this.setSortedColumnIcon(this.sortedColumn, null, (this.reverseSort === "1"));
91             this.setupAltRow();
92         },
94         setupCommonEvents: function() {
95             const tableDiv = $(this.dynamicTableDivId);
96             const tableFixedHeaderDiv = $(this.dynamicTableFixedHeaderDivId);
98             const tableElement = tableFixedHeaderDiv.querySelector("table");
99             tableDiv.addEventListener("scroll", () => {
100                 tableElement.style.left = `${-tableDiv.scrollLeft}px`;
101             });
103             // if the table exists within a panel
104             const parentPanel = tableDiv.getParent(".panel");
105             if (parentPanel) {
106                 const resizeFn = (entries) => {
107                     const panel = entries[0].target;
108                     let h = panel.getBoundingClientRect().height - tableFixedHeaderDiv.getBoundingClientRect().height;
109                     tableDiv.style.height = `${h}px`;
111                     // Workaround due to inaccurate calculation of elements heights by browser
112                     let n = 2;
114                     // is panel vertical scrollbar visible or does panel content not fit?
115                     while (((panel.clientWidth !== panel.offsetWidth) || (panel.clientHeight !== panel.scrollHeight)) && (n > 0)) {
116                         --n;
117                         h -= 0.5;
118                         tableDiv.style.height = `${h}px`;
119                     }
120                 };
122                 const resizeDebouncer = window.qBittorrent.Misc.createDebounceHandler(100, (entries) => {
123                     resizeFn(entries);
124                 });
126                 const resizeObserver = new ResizeObserver(resizeDebouncer);
127                 resizeObserver.observe(parentPanel, { box: "border-box" });
128             }
129         },
131         setupHeaderEvents: function() {
132             this.currentHeaderAction = "";
133             this.canResize = false;
135             const resetElementBorderStyle = function(el, side) {
136                 if ((side === "left") || (side !== "right"))
137                     el.style.borderLeft = "";
138                 if ((side === "right") || (side !== "left"))
139                     el.style.borderRight = "";
140             };
142             const mouseMoveFn = function(e) {
143                 const brect = e.target.getBoundingClientRect();
144                 const mouseXRelative = e.clientX - brect.left;
145                 if (this.currentHeaderAction === "") {
146                     if ((brect.width - mouseXRelative) < 5) {
147                         this.resizeTh = e.target;
148                         this.canResize = true;
149                         e.target.getParent("tr").style.cursor = "col-resize";
150                     }
151                     else if ((mouseXRelative < 5) && e.target.getPrevious('[class=""]')) {
152                         this.resizeTh = e.target.getPrevious('[class=""]');
153                         this.canResize = true;
154                         e.target.getParent("tr").style.cursor = "col-resize";
155                     }
156                     else {
157                         this.canResize = false;
158                         e.target.getParent("tr").style.cursor = "";
159                     }
160                 }
161                 if (this.currentHeaderAction === "drag") {
162                     const previousVisibleSibling = e.target.getPrevious('[class=""]');
163                     let borderChangeElement = previousVisibleSibling;
164                     let changeBorderSide = "right";
166                     if (mouseXRelative > (brect.width / 2)) {
167                         borderChangeElement = e.target;
168                         this.dropSide = "right";
169                     }
170                     else {
171                         this.dropSide = "left";
172                     }
174                     e.target.getParent("tr").style.cursor = "move";
176                     if (!previousVisibleSibling) { // right most column
177                         borderChangeElement = e.target;
179                         if (mouseXRelative <= (brect.width / 2))
180                             changeBorderSide = "left";
181                     }
183                     const borderStyle = "initial solid #e60";
184                     if (changeBorderSide === "left")
185                         borderChangeElement.style.borderLeft = borderStyle;
186                     else
187                         borderChangeElement.style.borderRight = borderStyle;
189                     resetElementBorderStyle(borderChangeElement, ((changeBorderSide === "right") ? "left" : "right"));
191                     borderChangeElement.getSiblings('[class=""]').each((el) => {
192                         resetElementBorderStyle(el);
193                     });
194                 }
195                 this.lastHoverTh = e.target;
196                 this.lastClientX = e.clientX;
197             }.bind(this);
199             const mouseOutFn = function(e) {
200                 resetElementBorderStyle(e.target);
201             }.bind(this);
203             const onBeforeStart = function(el) {
204                 this.clickedTh = el;
205                 this.currentHeaderAction = "start";
206                 this.dragMovement = false;
207                 this.dragStartX = this.lastClientX;
208             }.bind(this);
210             const onStart = function(el, event) {
211                 if (this.canResize) {
212                     this.currentHeaderAction = "resize";
213                     this.startWidth = parseInt(this.resizeTh.style.width, 10);
214                 }
215                 else {
216                     this.currentHeaderAction = "drag";
217                     el.style.backgroundColor = "#C1D5E7";
218                 }
219             }.bind(this);
221             const onDrag = function(el, event) {
222                 if (this.currentHeaderAction === "resize") {
223                     let width = this.startWidth + (event.event.pageX - this.dragStartX);
224                     if (width < 16)
225                         width = 16;
226                     this.columns[this.resizeTh.columnName].width = width;
227                     this.updateColumn(this.resizeTh.columnName);
228                 }
229             }.bind(this);
231             const onComplete = function(el, event) {
232                 resetElementBorderStyle(this.lastHoverTh);
233                 el.style.backgroundColor = "";
234                 if (this.currentHeaderAction === "resize")
235                     LocalPreferences.set("column_" + this.resizeTh.columnName + "_width_" + this.dynamicTableDivId, this.columns[this.resizeTh.columnName].width);
236                 if ((this.currentHeaderAction === "drag") && (el !== this.lastHoverTh)) {
237                     this.saveColumnsOrder();
238                     const val = LocalPreferences.get("columns_order_" + this.dynamicTableDivId).split(",");
239                     val.erase(el.columnName);
240                     let pos = val.indexOf(this.lastHoverTh.columnName);
241                     if (this.dropSide === "right")
242                         ++pos;
243                     val.splice(pos, 0, el.columnName);
244                     LocalPreferences.set("columns_order_" + this.dynamicTableDivId, val.join(","));
245                     this.loadColumnsOrder();
246                     this.updateTableHeaders();
247                     while (this.tableBody.firstChild)
248                         this.tableBody.removeChild(this.tableBody.firstChild);
249                     this.updateTable(true);
250                 }
251                 if (this.currentHeaderAction === "drag") {
252                     resetElementBorderStyle(el);
253                     el.getSiblings('[class=""]').each((el) => {
254                         resetElementBorderStyle(el);
255                     });
256                 }
257                 this.currentHeaderAction = "";
258             }.bind(this);
260             const onCancel = function(el) {
261                 this.currentHeaderAction = "";
262                 this.setSortedColumn(el.columnName);
263             }.bind(this);
265             const onTouch = function(e) {
266                 const column = e.target.columnName;
267                 this.currentHeaderAction = "";
268                 this.setSortedColumn(column);
269             }.bind(this);
271             const ths = this.fixedTableHeader.getElements("th");
273             for (let i = 0; i < ths.length; ++i) {
274                 const th = ths[i];
275                 th.addEventListener("mousemove", mouseMoveFn);
276                 th.addEventListener("mouseout", mouseOutFn);
277                 th.addEventListener("touchend", onTouch, { passive: true });
278                 th.makeResizable({
279                     modifiers: {
280                         x: "",
281                         y: ""
282                     },
283                     onBeforeStart: onBeforeStart,
284                     onStart: onStart,
285                     onDrag: onDrag,
286                     onComplete: onComplete,
287                     onCancel: onCancel
288                 });
289             }
290         },
292         setupDynamicTableHeaderContextMenuClass: function() {
293             if (!DynamicTableHeaderContextMenuClass) {
294                 DynamicTableHeaderContextMenuClass = new Class({
295                     Extends: window.qBittorrent.ContextMenu.ContextMenu,
296                     updateMenuItems: function() {
297                         for (let i = 0; i < this.dynamicTable.columns.length; ++i) {
298                             if (this.dynamicTable.columns[i].caption === "")
299                                 continue;
300                             if (this.dynamicTable.columns[i].visible !== "0")
301                                 this.setItemChecked(this.dynamicTable.columns[i].name, true);
302                             else
303                                 this.setItemChecked(this.dynamicTable.columns[i].name, false);
304                         }
305                     }
306                 });
307             }
308         },
310         showColumn: function(columnName, show) {
311             this.columns[columnName].visible = show ? "1" : "0";
312             LocalPreferences.set("column_" + columnName + "_visible_" + this.dynamicTableDivId, show ? "1" : "0");
313             this.updateColumn(columnName);
314         },
316         setupHeaderMenu: function() {
317             this.setupDynamicTableHeaderContextMenuClass();
319             const menuId = this.dynamicTableDivId + "_headerMenu";
321             // reuse menu if already exists
322             const ul = $(menuId) ?? new Element("ul", {
323                 id: menuId,
324                 class: "contextMenu scrollableMenu"
325             });
327             const createLi = function(columnName, text) {
328                 const anchor = document.createElement("a");
329                 anchor.href = `#${columnName}`;
330                 anchor.textContent = text;
332                 const img = document.createElement("img");
333                 img.src = "images/checked-completed.svg";
334                 anchor.prepend(img);
336                 const listItem = document.createElement("li");
337                 listItem.appendChild(anchor);
339                 return listItem;
340             };
342             const actions = {};
344             const onMenuItemClicked = function(element, ref, action) {
345                 this.showColumn(action, this.columns[action].visible === "0");
346             }.bind(this);
348             // recreate child nodes when reusing (enables the context menu to work correctly)
349             if (ul.hasChildNodes()) {
350                 while (ul.firstChild)
351                     ul.removeChild(ul.lastChild);
352             }
354             for (let i = 0; i < this.columns.length; ++i) {
355                 const text = this.columns[i].caption;
356                 if (text === "")
357                     continue;
358                 ul.appendChild(createLi(this.columns[i].name, text));
359                 actions[this.columns[i].name] = onMenuItemClicked;
360             }
362             ul.inject(document.body);
364             this.headerContextMenu = new DynamicTableHeaderContextMenuClass({
365                 targets: "#" + this.dynamicTableFixedHeaderDivId + " tr",
366                 actions: actions,
367                 menu: menuId,
368                 offsets: {
369                     x: -15,
370                     y: 2
371                 }
372             });
374             this.headerContextMenu.dynamicTable = this;
375         },
377         initColumns: function() {},
379         newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
380             const column = {};
381             column["name"] = name;
382             column["title"] = name;
383             column["visible"] = LocalPreferences.get("column_" + name + "_visible_" + this.dynamicTableDivId, defaultVisible ? "1" : "0");
384             column["force_hide"] = false;
385             column["caption"] = caption;
386             column["style"] = style;
387             column["width"] = LocalPreferences.get("column_" + name + "_width_" + this.dynamicTableDivId, defaultWidth);
388             column["dataProperties"] = [name];
389             column["getRowValue"] = function(row, pos) {
390                 if (pos === undefined)
391                     pos = 0;
392                 return row["full_data"][this.dataProperties[pos]];
393             };
394             column["compareRows"] = function(row1, row2) {
395                 const value1 = this.getRowValue(row1);
396                 const value2 = this.getRowValue(row2);
397                 if ((typeof(value1) === "number") && (typeof(value2) === "number"))
398                     return compareNumbers(value1, value2);
399                 return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
400             };
401             column["updateTd"] = function(td, row) {
402                 const value = this.getRowValue(row);
403                 td.textContent = value;
404                 td.title = value;
405             };
406             column["onResize"] = null;
407             this.columns.push(column);
408             this.columns[name] = column;
410             this.hiddenTableHeader.appendChild(new Element("th"));
411             this.fixedTableHeader.appendChild(new Element("th"));
412         },
414         loadColumnsOrder: function() {
415             const columnsOrder = [];
416             const val = LocalPreferences.get("columns_order_" + this.dynamicTableDivId);
417             if ((val === null) || (val === undefined))
418                 return;
419             val.split(",").forEach((v) => {
420                 if ((v in this.columns) && (!columnsOrder.contains(v)))
421                     columnsOrder.push(v);
422             });
424             for (let i = 0; i < this.columns.length; ++i) {
425                 if (!columnsOrder.contains(this.columns[i].name))
426                     columnsOrder.push(this.columns[i].name);
427             }
429             for (let i = 0; i < this.columns.length; ++i)
430                 this.columns[i] = this.columns[columnsOrder[i]];
431         },
433         saveColumnsOrder: function() {
434             let val = "";
435             for (let i = 0; i < this.columns.length; ++i) {
436                 if (i > 0)
437                     val += ",";
438                 val += this.columns[i].name;
439             }
440             LocalPreferences.set("columns_order_" + this.dynamicTableDivId, val);
441         },
443         updateTableHeaders: function() {
444             this.updateHeader(this.hiddenTableHeader);
445             this.updateHeader(this.fixedTableHeader);
446         },
448         updateHeader: function(header) {
449             const ths = header.getElements("th");
451             for (let i = 0; i < ths.length; ++i) {
452                 const th = ths[i];
453                 th._this = this;
454                 th.title = this.columns[i].caption;
455                 th.textContent = this.columns[i].caption;
456                 th.setAttribute("style", "width: " + this.columns[i].width + "px;" + this.columns[i].style);
457                 th.columnName = this.columns[i].name;
458                 th.addClass("column_" + th.columnName);
459                 if ((this.columns[i].visible === "0") || this.columns[i].force_hide)
460                     th.addClass("invisible");
461                 else
462                     th.removeClass("invisible");
463             }
464         },
466         getColumnPos: function(columnName) {
467             for (let i = 0; i < this.columns.length; ++i) {
468                 if (this.columns[i].name === columnName)
469                     return i;
470             }
471             return -1;
472         },
474         updateColumn: function(columnName) {
475             const pos = this.getColumnPos(columnName);
476             const visible = ((this.columns[pos].visible !== "0") && !this.columns[pos].force_hide);
477             const ths = this.hiddenTableHeader.getElements("th");
478             const fths = this.fixedTableHeader.getElements("th");
479             const trs = this.tableBody.getElements("tr");
480             const style = "width: " + this.columns[pos].width + "px;" + this.columns[pos].style;
482             ths[pos].setAttribute("style", style);
483             fths[pos].setAttribute("style", style);
485             if (visible) {
486                 ths[pos].removeClass("invisible");
487                 fths[pos].removeClass("invisible");
488                 for (let i = 0; i < trs.length; ++i)
489                     trs[i].getElements("td")[pos].removeClass("invisible");
490             }
491             else {
492                 ths[pos].addClass("invisible");
493                 fths[pos].addClass("invisible");
494                 for (let j = 0; j < trs.length; ++j)
495                     trs[j].getElements("td")[pos].addClass("invisible");
496             }
497             if (this.columns[pos].onResize !== null)
498                 this.columns[pos].onResize(columnName);
499         },
501         getSortedColumn: function() {
502             return LocalPreferences.get("sorted_column_" + this.dynamicTableDivId);
503         },
505         /**
506          * @param {string} column name to sort by
507          * @param {string|null} reverse defaults to implementation-specific behavior when not specified. Should only be passed when restoring previous state.
508          */
509         setSortedColumn: function(column, reverse = null) {
510             if (column !== this.sortedColumn) {
511                 const oldColumn = this.sortedColumn;
512                 this.sortedColumn = column;
513                 this.reverseSort = reverse ?? "0";
514                 this.setSortedColumnIcon(column, oldColumn, false);
515             }
516             else {
517                 // Toggle sort order
518                 this.reverseSort = reverse ?? (this.reverseSort === "0" ? "1" : "0");
519                 this.setSortedColumnIcon(column, null, (this.reverseSort === "1"));
520             }
521             LocalPreferences.set("sorted_column_" + this.dynamicTableDivId, column);
522             LocalPreferences.set("reverse_sort_" + this.dynamicTableDivId, this.reverseSort);
523             this.updateTable(false);
524         },
526         setSortedColumnIcon: function(newColumn, oldColumn, isReverse) {
527             const getCol = function(headerDivId, colName) {
528                 const colElem = $$("#" + headerDivId + " .column_" + colName);
529                 if (colElem.length === 1)
530                     return colElem[0];
531                 return null;
532             };
534             const colElem = getCol(this.dynamicTableFixedHeaderDivId, newColumn);
535             if (colElem !== null) {
536                 colElem.addClass("sorted");
537                 if (isReverse)
538                     colElem.addClass("reverse");
539                 else
540                     colElem.removeClass("reverse");
541             }
542             const oldColElem = getCol(this.dynamicTableFixedHeaderDivId, oldColumn);
543             if (oldColElem !== null) {
544                 oldColElem.removeClass("sorted");
545                 oldColElem.removeClass("reverse");
546             }
547         },
549         getSelectedRowId: function() {
550             if (this.selectedRows.length > 0)
551                 return this.selectedRows[0];
552             return "";
553         },
555         isRowSelected: function(rowId) {
556             return this.selectedRows.contains(rowId);
557         },
559         setupAltRow: function() {
560             const useAltRowColors = (LocalPreferences.get("use_alt_row_colors", "true") === "true");
561             if (useAltRowColors)
562                 document.getElementById(this.dynamicTableDivId).classList.add("altRowColors");
563         },
565         selectAll: function() {
566             this.deselectAll();
568             const trs = this.tableBody.getElements("tr");
569             for (let i = 0; i < trs.length; ++i) {
570                 const tr = trs[i];
571                 this.selectedRows.push(tr.rowId);
572                 if (!tr.hasClass("selected"))
573                     tr.addClass("selected");
574             }
575         },
577         deselectAll: function() {
578             this.selectedRows.empty();
579         },
581         selectRow: function(rowId) {
582             this.selectedRows.push(rowId);
583             this.setRowClass();
584             this.onSelectedRowChanged();
585         },
587         deselectRow: function(rowId) {
588             this.selectedRows.erase(rowId);
589             this.setRowClass();
590             this.onSelectedRowChanged();
591         },
593         selectRows: function(rowId1, rowId2) {
594             this.deselectAll();
595             if (rowId1 === rowId2) {
596                 this.selectRow(rowId1);
597                 return;
598             }
600             let select = false;
601             const that = this;
602             this.tableBody.getElements("tr").each((tr) => {
603                 if ((tr.rowId === rowId1) || (tr.rowId === rowId2)) {
604                     select = !select;
605                     that.selectedRows.push(tr.rowId);
606                 }
607                 else if (select) {
608                     that.selectedRows.push(tr.rowId);
609                 }
610             });
611             this.setRowClass();
612             this.onSelectedRowChanged();
613         },
615         reselectRows: function(rowIds) {
616             this.deselectAll();
617             this.selectedRows = rowIds.slice();
618             this.tableBody.getElements("tr").each((tr) => {
619                 if (rowIds.includes(tr.rowId))
620                     tr.addClass("selected");
621             });
622         },
624         setRowClass: function() {
625             const that = this;
626             this.tableBody.getElements("tr").each((tr) => {
627                 if (that.isRowSelected(tr.rowId))
628                     tr.addClass("selected");
629                 else
630                     tr.removeClass("selected");
631             });
632         },
634         onSelectedRowChanged: function() {},
636         updateRowData: function(data) {
637             // ensure rowId is a string
638             const rowId = `${data["rowId"]}`;
639             let row;
641             if (!this.rows.has(rowId)) {
642                 row = {
643                     "full_data": {},
644                     "rowId": rowId
645                 };
646                 this.rows.set(rowId, row);
647             }
648             else {
649                 row = this.rows.get(rowId);
650             }
652             row["data"] = data;
653             for (const x in data) {
654                 if (!Object.hasOwn(data, x))
655                     continue;
656                 row["full_data"][x] = data[x];
657             }
658         },
660         getRow: function(rowId) {
661             return this.rows.get(rowId);
662         },
664         getFilteredAndSortedRows: function() {
665             const filteredRows = [];
667             const rows = this.rows.getValues();
669             for (let i = 0; i < rows.length; ++i) {
670                 filteredRows.push(rows[i]);
671                 filteredRows[rows[i].rowId] = rows[i];
672             }
674             filteredRows.sort((row1, row2) => {
675                 const column = this.columns[this.sortedColumn];
676                 const res = column.compareRows(row1, row2);
677                 if (this.reverseSort === "0")
678                     return res;
679                 else
680                     return -res;
681             });
682             return filteredRows;
683         },
685         getTrByRowId: function(rowId) {
686             const trs = this.tableBody.getElements("tr");
687             for (let i = 0; i < trs.length; ++i) {
688                 if (trs[i].rowId === rowId)
689                     return trs[i];
690             }
691             return null;
692         },
694         updateTable: function(fullUpdate = false) {
695             const rows = this.getFilteredAndSortedRows();
697             for (let i = 0; i < this.selectedRows.length; ++i) {
698                 if (!(this.selectedRows[i] in rows)) {
699                     this.selectedRows.splice(i, 1);
700                     --i;
701                 }
702             }
704             const trs = this.tableBody.getElements("tr");
706             for (let rowPos = 0; rowPos < rows.length; ++rowPos) {
707                 const rowId = rows[rowPos]["rowId"];
708                 let tr_found = false;
709                 for (let j = rowPos; j < trs.length; ++j) {
710                     if (trs[j]["rowId"] === rowId) {
711                         tr_found = true;
712                         if (rowPos === j)
713                             break;
714                         trs[j].inject(trs[rowPos], "before");
715                         const tmpTr = trs[j];
716                         trs.splice(j, 1);
717                         trs.splice(rowPos, 0, tmpTr);
718                         break;
719                     }
720                 }
721                 if (tr_found) { // row already exists in the table
722                     this.updateRow(trs[rowPos], fullUpdate);
723                 }
724                 else { // else create a new row in the table
725                     const tr = new Element("tr");
726                     // set tabindex so element receives keydown events
727                     // more info: https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event
728                     tr.tabindex = "-1";
730                     const rowId = rows[rowPos]["rowId"];
731                     tr.setAttribute("data-row-id", rowId);
732                     tr["rowId"] = rowId;
734                     tr._this = this;
735                     tr.addEventListener("contextmenu", function(e) {
736                         if (!this._this.isRowSelected(this.rowId)) {
737                             this._this.deselectAll();
738                             this._this.selectRow(this.rowId);
739                         }
740                         return true;
741                     });
742                     tr.addEventListener("click", function(e) {
743                         e.preventDefault();
744                         e.stopPropagation();
746                         if (e.ctrlKey || e.metaKey) {
747                             // CTRL/CMD âŒ˜ key was pressed
748                             if (this._this.isRowSelected(this.rowId))
749                                 this._this.deselectRow(this.rowId);
750                             else
751                                 this._this.selectRow(this.rowId);
752                         }
753                         else if (e.shiftKey && (this._this.selectedRows.length === 1)) {
754                             // Shift key was pressed
755                             this._this.selectRows(this._this.getSelectedRowId(), this.rowId);
756                         }
757                         else {
758                             // Simple selection
759                             this._this.deselectAll();
760                             this._this.selectRow(this.rowId);
761                         }
762                         return false;
763                     });
764                     tr.addEventListener("touchstart", function(e) {
765                         if (!this._this.isRowSelected(this.rowId)) {
766                             this._this.deselectAll();
767                             this._this.selectRow(this.rowId);
768                         }
769                     }, { passive: true });
770                     tr.addEventListener("keydown", function(event) {
771                         switch (event.key) {
772                             case "up":
773                                 this._this.selectPreviousRow();
774                                 return false;
775                             case "down":
776                                 this._this.selectNextRow();
777                                 return false;
778                         }
779                     });
781                     this.setupTr(tr);
783                     for (let k = 0; k < this.columns.length; ++k) {
784                         const td = new Element("td");
785                         if ((this.columns[k].visible === "0") || this.columns[k].force_hide)
786                             td.addClass("invisible");
787                         td.injectInside(tr);
788                     }
790                     // Insert
791                     if (rowPos >= trs.length) {
792                         tr.inject(this.tableBody);
793                         trs.push(tr);
794                     }
795                     else {
796                         tr.inject(trs[rowPos], "before");
797                         trs.splice(rowPos, 0, tr);
798                     }
800                     // Update context menu
801                     if (this.contextMenu)
802                         this.contextMenu.addTarget(tr);
804                     this.updateRow(tr, true);
805                 }
806             }
808             const rowPos = rows.length;
810             while ((rowPos < trs.length) && (trs.length > 0))
811                 trs.pop().destroy();
812         },
814         setupTr: function(tr) {},
816         updateRow: function(tr, fullUpdate) {
817             const row = this.rows.get(tr.rowId);
818             const data = row[fullUpdate ? "full_data" : "data"];
820             const tds = tr.getElements("td");
821             for (let i = 0; i < this.columns.length; ++i) {
822                 if (Object.hasOwn(data, this.columns[i].dataProperties[0]))
823                     this.columns[i].updateTd(tds[i], row);
824             }
825             row["data"] = {};
826         },
828         removeRow: function(rowId) {
829             this.selectedRows.erase(rowId);
830             if (this.rows.has(rowId))
831                 this.rows.erase(rowId);
832             const tr = this.getTrByRowId(rowId);
833             if (tr !== null)
834                 tr.destroy();
835         },
837         clear: function() {
838             this.deselectAll();
839             this.rows.empty();
840             const trs = this.tableBody.getElements("tr");
841             while (trs.length > 0)
842                 trs.pop().destroy();
843         },
845         selectedRowsIds: function() {
846             return this.selectedRows.slice();
847         },
849         getRowIds: function() {
850             return this.rows.getKeys();
851         },
853         selectNextRow: function() {
854             const visibleRows = $(this.dynamicTableDivId).getElements("tbody tr").filter(e => e.style.display !== "none");
855             const selectedRowId = this.getSelectedRowId();
857             let selectedIndex = -1;
858             for (let i = 0; i < visibleRows.length; ++i) {
859                 const row = visibleRows[i];
860                 if (row.getAttribute("data-row-id") === selectedRowId) {
861                     selectedIndex = i;
862                     break;
863                 }
864             }
866             const isLastRowSelected = (selectedIndex >= (visibleRows.length - 1));
867             if (!isLastRowSelected) {
868                 this.deselectAll();
870                 const newRow = visibleRows[selectedIndex + 1];
871                 this.selectRow(newRow.getAttribute("data-row-id"));
872             }
873         },
875         selectPreviousRow: function() {
876             const visibleRows = $(this.dynamicTableDivId).getElements("tbody tr").filter(e => e.style.display !== "none");
877             const selectedRowId = this.getSelectedRowId();
879             let selectedIndex = -1;
880             for (let i = 0; i < visibleRows.length; ++i) {
881                 const row = visibleRows[i];
882                 if (row.getAttribute("data-row-id") === selectedRowId) {
883                     selectedIndex = i;
884                     break;
885                 }
886             }
888             const isFirstRowSelected = selectedIndex <= 0;
889             if (!isFirstRowSelected) {
890                 this.deselectAll();
892                 const newRow = visibleRows[selectedIndex - 1];
893                 this.selectRow(newRow.getAttribute("data-row-id"));
894             }
895         },
896     });
898     const TorrentsTable = new Class({
899         Extends: DynamicTable,
901         initColumns: function() {
902             this.newColumn("priority", "", "#", 30, true);
903             this.newColumn("state_icon", "cursor: default", "", 22, true);
904             this.newColumn("name", "", "QBT_TR(Name)QBT_TR[CONTEXT=TransferListModel]", 200, true);
905             this.newColumn("size", "", "QBT_TR(Size)QBT_TR[CONTEXT=TransferListModel]", 100, true);
906             this.newColumn("total_size", "", "QBT_TR(Total Size)QBT_TR[CONTEXT=TransferListModel]", 100, false);
907             this.newColumn("progress", "", "QBT_TR(Progress)QBT_TR[CONTEXT=TransferListModel]", 85, true);
908             this.newColumn("status", "", "QBT_TR(Status)QBT_TR[CONTEXT=TransferListModel]", 100, true);
909             this.newColumn("num_seeds", "", "QBT_TR(Seeds)QBT_TR[CONTEXT=TransferListModel]", 100, true);
910             this.newColumn("num_leechs", "", "QBT_TR(Peers)QBT_TR[CONTEXT=TransferListModel]", 100, true);
911             this.newColumn("dlspeed", "", "QBT_TR(Down Speed)QBT_TR[CONTEXT=TransferListModel]", 100, true);
912             this.newColumn("upspeed", "", "QBT_TR(Up Speed)QBT_TR[CONTEXT=TransferListModel]", 100, true);
913             this.newColumn("eta", "", "QBT_TR(ETA)QBT_TR[CONTEXT=TransferListModel]", 100, true);
914             this.newColumn("ratio", "", "QBT_TR(Ratio)QBT_TR[CONTEXT=TransferListModel]", 100, true);
915             this.newColumn("popularity", "", "QBT_TR(Popularity)QBT_TR[CONTEXT=TransferListModel]", 100, true);
916             this.newColumn("category", "", "QBT_TR(Category)QBT_TR[CONTEXT=TransferListModel]", 100, true);
917             this.newColumn("tags", "", "QBT_TR(Tags)QBT_TR[CONTEXT=TransferListModel]", 100, true);
918             this.newColumn("added_on", "", "QBT_TR(Added On)QBT_TR[CONTEXT=TransferListModel]", 100, true);
919             this.newColumn("completion_on", "", "QBT_TR(Completed On)QBT_TR[CONTEXT=TransferListModel]", 100, false);
920             this.newColumn("tracker", "", "QBT_TR(Tracker)QBT_TR[CONTEXT=TransferListModel]", 100, false);
921             this.newColumn("dl_limit", "", "QBT_TR(Down Limit)QBT_TR[CONTEXT=TransferListModel]", 100, false);
922             this.newColumn("up_limit", "", "QBT_TR(Up Limit)QBT_TR[CONTEXT=TransferListModel]", 100, false);
923             this.newColumn("downloaded", "", "QBT_TR(Downloaded)QBT_TR[CONTEXT=TransferListModel]", 100, false);
924             this.newColumn("uploaded", "", "QBT_TR(Uploaded)QBT_TR[CONTEXT=TransferListModel]", 100, false);
925             this.newColumn("downloaded_session", "", "QBT_TR(Session Download)QBT_TR[CONTEXT=TransferListModel]", 100, false);
926             this.newColumn("uploaded_session", "", "QBT_TR(Session Upload)QBT_TR[CONTEXT=TransferListModel]", 100, false);
927             this.newColumn("amount_left", "", "QBT_TR(Remaining)QBT_TR[CONTEXT=TransferListModel]", 100, false);
928             this.newColumn("time_active", "", "QBT_TR(Time Active)QBT_TR[CONTEXT=TransferListModel]", 100, false);
929             this.newColumn("save_path", "", "QBT_TR(Save path)QBT_TR[CONTEXT=TransferListModel]", 100, false);
930             this.newColumn("completed", "", "QBT_TR(Completed)QBT_TR[CONTEXT=TransferListModel]", 100, false);
931             this.newColumn("max_ratio", "", "QBT_TR(Ratio Limit)QBT_TR[CONTEXT=TransferListModel]", 100, false);
932             this.newColumn("seen_complete", "", "QBT_TR(Last Seen Complete)QBT_TR[CONTEXT=TransferListModel]", 100, false);
933             this.newColumn("last_activity", "", "QBT_TR(Last Activity)QBT_TR[CONTEXT=TransferListModel]", 100, false);
934             this.newColumn("availability", "", "QBT_TR(Availability)QBT_TR[CONTEXT=TransferListModel]", 100, false);
935             this.newColumn("download_path", "", "QBT_TR(Incomplete Save Path)QBT_TR[CONTEXT=TransferListModel]", 100, false);
936             this.newColumn("infohash_v1", "", "QBT_TR(Info Hash v1)QBT_TR[CONTEXT=TransferListModel]", 100, false);
937             this.newColumn("infohash_v2", "", "QBT_TR(Info Hash v2)QBT_TR[CONTEXT=TransferListModel]", 100, false);
938             this.newColumn("reannounce", "", "QBT_TR(Reannounce In)QBT_TR[CONTEXT=TransferListModel]", 100, false);
939             this.newColumn("private", "", "QBT_TR(Private)QBT_TR[CONTEXT=TransferListModel]", 100, false);
941             this.columns["state_icon"].onclick = "";
942             this.columns["state_icon"].dataProperties[0] = "state";
944             this.columns["num_seeds"].dataProperties.push("num_complete");
945             this.columns["num_leechs"].dataProperties.push("num_incomplete");
946             this.columns["time_active"].dataProperties.push("seeding_time");
948             this.initColumnsFunctions();
949         },
951         initColumnsFunctions: function() {
953             // state_icon
954             this.columns["state_icon"].updateTd = function(td, row) {
955                 let state = this.getRowValue(row);
956                 let img_path;
957                 // normalize states
958                 switch (state) {
959                     case "forcedDL":
960                     case "metaDL":
961                     case "forcedMetaDL":
962                     case "downloading":
963                         state = "downloading";
964                         img_path = "images/downloading.svg";
965                         break;
966                     case "forcedUP":
967                     case "uploading":
968                         state = "uploading";
969                         img_path = "images/upload.svg";
970                         break;
971                     case "stalledUP":
972                         state = "stalledUP";
973                         img_path = "images/stalledUP.svg";
974                         break;
975                     case "stalledDL":
976                         state = "stalledDL";
977                         img_path = "images/stalledDL.svg";
978                         break;
979                     case "stoppedDL":
980                         state = "torrent-stop";
981                         img_path = "images/stopped.svg";
982                         break;
983                     case "stoppedUP":
984                         state = "checked-completed";
985                         img_path = "images/checked-completed.svg";
986                         break;
987                     case "queuedDL":
988                     case "queuedUP":
989                         state = "queued";
990                         img_path = "images/queued.svg";
991                         break;
992                     case "checkingDL":
993                     case "checkingUP":
994                     case "queuedForChecking":
995                     case "checkingResumeData":
996                         state = "force-recheck";
997                         img_path = "images/force-recheck.svg";
998                         break;
999                     case "moving":
1000                         state = "moving";
1001                         img_path = "images/set-location.svg";
1002                         break;
1003                     case "error":
1004                     case "unknown":
1005                     case "missingFiles":
1006                         state = "error";
1007                         img_path = "images/error.svg";
1008                         break;
1009                     default:
1010                         break; // do nothing
1011                 }
1013                 if (td.getChildren("img").length > 0) {
1014                     const img = td.getChildren("img")[0];
1015                     if (!img.src.includes(img_path)) {
1016                         img.src = img_path;
1017                         img.title = state;
1018                     }
1019                 }
1020                 else {
1021                     td.adopt(new Element("img", {
1022                         "src": img_path,
1023                         "class": "stateIcon",
1024                         "title": state
1025                     }));
1026                 }
1027             };
1029             // status
1030             this.columns["status"].updateTd = function(td, row) {
1031                 const state = this.getRowValue(row);
1032                 if (!state)
1033                     return;
1035                 let status;
1036                 switch (state) {
1037                     case "downloading":
1038                         status = "QBT_TR(Downloading)QBT_TR[CONTEXT=TransferListDelegate]";
1039                         break;
1040                     case "stalledDL":
1041                         status = "QBT_TR(Stalled)QBT_TR[CONTEXT=TransferListDelegate]";
1042                         break;
1043                     case "metaDL":
1044                         status = "QBT_TR(Downloading metadata)QBT_TR[CONTEXT=TransferListDelegate]";
1045                         break;
1046                     case "forcedMetaDL":
1047                         status = "QBT_TR([F] Downloading metadata)QBT_TR[CONTEXT=TransferListDelegate]";
1048                         break;
1049                     case "forcedDL":
1050                         status = "QBT_TR([F] Downloading)QBT_TR[CONTEXT=TransferListDelegate]";
1051                         break;
1052                     case "uploading":
1053                     case "stalledUP":
1054                         status = "QBT_TR(Seeding)QBT_TR[CONTEXT=TransferListDelegate]";
1055                         break;
1056                     case "forcedUP":
1057                         status = "QBT_TR([F] Seeding)QBT_TR[CONTEXT=TransferListDelegate]";
1058                         break;
1059                     case "queuedDL":
1060                     case "queuedUP":
1061                         status = "QBT_TR(Queued)QBT_TR[CONTEXT=TransferListDelegate]";
1062                         break;
1063                     case "checkingDL":
1064                     case "checkingUP":
1065                         status = "QBT_TR(Checking)QBT_TR[CONTEXT=TransferListDelegate]";
1066                         break;
1067                     case "queuedForChecking":
1068                         status = "QBT_TR(Queued for checking)QBT_TR[CONTEXT=TransferListDelegate]";
1069                         break;
1070                     case "checkingResumeData":
1071                         status = "QBT_TR(Checking resume data)QBT_TR[CONTEXT=TransferListDelegate]";
1072                         break;
1073                     case "stoppedDL":
1074                         status = "QBT_TR(Stopped)QBT_TR[CONTEXT=TransferListDelegate]";
1075                         break;
1076                     case "stoppedUP":
1077                         status = "QBT_TR(Completed)QBT_TR[CONTEXT=TransferListDelegate]";
1078                         break;
1079                     case "moving":
1080                         status = "QBT_TR(Moving)QBT_TR[CONTEXT=TransferListDelegate]";
1081                         break;
1082                     case "missingFiles":
1083                         status = "QBT_TR(Missing Files)QBT_TR[CONTEXT=TransferListDelegate]";
1084                         break;
1085                     case "error":
1086                         status = "QBT_TR(Errored)QBT_TR[CONTEXT=TransferListDelegate]";
1087                         break;
1088                     default:
1089                         status = "QBT_TR(Unknown)QBT_TR[CONTEXT=HttpServer]";
1090                 }
1092                 td.textContent = status;
1093                 td.title = status;
1094             };
1096             // priority
1097             this.columns["priority"].updateTd = function(td, row) {
1098                 const queuePos = this.getRowValue(row);
1099                 const formattedQueuePos = (queuePos < 1) ? "*" : queuePos;
1100                 td.textContent = formattedQueuePos;
1101                 td.title = formattedQueuePos;
1102             };
1104             this.columns["priority"].compareRows = function(row1, row2) {
1105                 let row1_val = this.getRowValue(row1);
1106                 let row2_val = this.getRowValue(row2);
1107                 if (row1_val < 1)
1108                     row1_val = 1000000;
1109                 if (row2_val < 1)
1110                     row2_val = 1000000;
1111                 return compareNumbers(row1_val, row2_val);
1112             };
1114             // name, category, tags
1115             this.columns["name"].compareRows = function(row1, row2) {
1116                 const row1Val = this.getRowValue(row1);
1117                 const row2Val = this.getRowValue(row2);
1118                 return row1Val.localeCompare(row2Val, undefined, { numeric: true, sensitivity: "base" });
1119             };
1120             this.columns["category"].compareRows = this.columns["name"].compareRows;
1121             this.columns["tags"].compareRows = this.columns["name"].compareRows;
1123             // size, total_size
1124             this.columns["size"].updateTd = function(td, row) {
1125                 const size = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false);
1126                 td.textContent = size;
1127                 td.title = size;
1128             };
1129             this.columns["total_size"].updateTd = this.columns["size"].updateTd;
1131             // progress
1132             this.columns["progress"].updateTd = function(td, row) {
1133                 const progress = this.getRowValue(row);
1134                 let progressFormatted = (progress * 100).round(1);
1135                 if ((progressFormatted === 100.0) && (progress !== 1.0))
1136                     progressFormatted = 99.9;
1138                 if (td.getChildren("div").length > 0) {
1139                     const div = td.getChildren("div")[0];
1140                     if (td.resized) {
1141                         td.resized = false;
1142                         div.setWidth(ProgressColumnWidth - 5);
1143                     }
1144                     if (div.getValue() !== progressFormatted)
1145                         div.setValue(progressFormatted);
1146                 }
1147                 else {
1148                     if (ProgressColumnWidth < 0)
1149                         ProgressColumnWidth = td.offsetWidth;
1150                     td.adopt(new window.qBittorrent.ProgressBar.ProgressBar(progressFormatted.toFloat(), {
1151                         "width": ProgressColumnWidth - 5
1152                     }));
1153                     td.resized = false;
1154                 }
1155             };
1157             this.columns["progress"].onResize = function(columnName) {
1158                 const pos = this.getColumnPos(columnName);
1159                 const trs = this.tableBody.getElements("tr");
1160                 ProgressColumnWidth = -1;
1161                 for (let i = 0; i < trs.length; ++i) {
1162                     const td = trs[i].getElements("td")[pos];
1163                     if (ProgressColumnWidth < 0)
1164                         ProgressColumnWidth = td.offsetWidth;
1165                     td.resized = true;
1166                     this.columns[columnName].updateTd(td, this.rows.get(trs[i].rowId));
1167                 }
1168             }.bind(this);
1170             // num_seeds
1171             this.columns["num_seeds"].updateTd = function(td, row) {
1172                 const num_seeds = this.getRowValue(row, 0);
1173                 const num_complete = this.getRowValue(row, 1);
1174                 let value = num_seeds;
1175                 if (num_complete !== -1)
1176                     value += " (" + num_complete + ")";
1177                 td.textContent = value;
1178                 td.title = value;
1179             };
1180             this.columns["num_seeds"].compareRows = function(row1, row2) {
1181                 const num_seeds1 = this.getRowValue(row1, 0);
1182                 const num_complete1 = this.getRowValue(row1, 1);
1184                 const num_seeds2 = this.getRowValue(row2, 0);
1185                 const num_complete2 = this.getRowValue(row2, 1);
1187                 const result = compareNumbers(num_complete1, num_complete2);
1188                 if (result !== 0)
1189                     return result;
1190                 return compareNumbers(num_seeds1, num_seeds2);
1191             };
1193             // num_leechs
1194             this.columns["num_leechs"].updateTd = this.columns["num_seeds"].updateTd;
1195             this.columns["num_leechs"].compareRows = this.columns["num_seeds"].compareRows;
1197             // dlspeed
1198             this.columns["dlspeed"].updateTd = function(td, row) {
1199                 const speed = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), true);
1200                 td.textContent = speed;
1201                 td.title = speed;
1202             };
1204             // upspeed
1205             this.columns["upspeed"].updateTd = this.columns["dlspeed"].updateTd;
1207             // eta
1208             this.columns["eta"].updateTd = function(td, row) {
1209                 const eta = window.qBittorrent.Misc.friendlyDuration(this.getRowValue(row), window.qBittorrent.Misc.MAX_ETA);
1210                 td.textContent = eta;
1211                 td.title = eta;
1212             };
1214             // ratio
1215             this.columns["ratio"].updateTd = function(td, row) {
1216                 const ratio = this.getRowValue(row);
1217                 const string = (ratio === -1) ? "∞" : window.qBittorrent.Misc.toFixedPointString(ratio, 2);
1218                 td.textContent = string;
1219                 td.title = string;
1220             };
1222             // popularity
1223             this.columns["popularity"].updateTd = function(td, row) {
1224                 const value = this.getRowValue(row);
1225                 const popularity = (value === -1) ? "∞" : window.qBittorrent.Misc.toFixedPointString(value, 2);
1226                 td.textContent = popularity;
1227                 td.title = popularity;
1228             };
1230             // added on
1231             this.columns["added_on"].updateTd = function(td, row) {
1232                 const date = new Date(this.getRowValue(row) * 1000).toLocaleString();
1233                 td.textContent = date;
1234                 td.title = date;
1235             };
1237             // completion_on
1238             this.columns["completion_on"].updateTd = function(td, row) {
1239                 const val = this.getRowValue(row);
1240                 if ((val === 0xffffffff) || (val < 0)) {
1241                     td.textContent = "";
1242                     td.title = "";
1243                 }
1244                 else {
1245                     const date = new Date(this.getRowValue(row) * 1000).toLocaleString();
1246                     td.textContent = date;
1247                     td.title = date;
1248                 }
1249             };
1251             // tracker
1252             this.columns["tracker"].updateTd = function(td, row) {
1253                 const value = this.getRowValue(row);
1254                 const tracker = displayFullURLTrackerColumn ? value : window.qBittorrent.Misc.getHost(value);
1255                 td.textContent = tracker;
1256                 td.title = value;
1257             };
1259             //  dl_limit, up_limit
1260             this.columns["dl_limit"].updateTd = function(td, row) {
1261                 const speed = this.getRowValue(row);
1262                 if (speed === 0) {
1263                     td.textContent = "∞";
1264                     td.title = "∞";
1265                 }
1266                 else {
1267                     const formattedSpeed = window.qBittorrent.Misc.friendlyUnit(speed, true);
1268                     td.textContent = formattedSpeed;
1269                     td.title = formattedSpeed;
1270                 }
1271             };
1273             this.columns["up_limit"].updateTd = this.columns["dl_limit"].updateTd;
1275             // downloaded, uploaded, downloaded_session, uploaded_session, amount_left
1276             this.columns["downloaded"].updateTd = this.columns["size"].updateTd;
1277             this.columns["uploaded"].updateTd = this.columns["size"].updateTd;
1278             this.columns["downloaded_session"].updateTd = this.columns["size"].updateTd;
1279             this.columns["uploaded_session"].updateTd = this.columns["size"].updateTd;
1280             this.columns["amount_left"].updateTd = this.columns["size"].updateTd;
1282             // time active
1283             this.columns["time_active"].updateTd = function(td, row) {
1284                 const activeTime = this.getRowValue(row, 0);
1285                 const seedingTime = this.getRowValue(row, 1);
1286                 const time = (seedingTime > 0)
1287                     ? ("QBT_TR(%1 (seeded for %2))QBT_TR[CONTEXT=TransferListDelegate]"
1288                         .replace("%1", window.qBittorrent.Misc.friendlyDuration(activeTime))
1289                         .replace("%2", window.qBittorrent.Misc.friendlyDuration(seedingTime)))
1290                     : window.qBittorrent.Misc.friendlyDuration(activeTime);
1291                 td.textContent = time;
1292                 td.title = time;
1293             };
1295             // completed
1296             this.columns["completed"].updateTd = this.columns["size"].updateTd;
1298             // max_ratio
1299             this.columns["max_ratio"].updateTd = this.columns["ratio"].updateTd;
1301             // seen_complete
1302             this.columns["seen_complete"].updateTd = this.columns["completion_on"].updateTd;
1304             // last_activity
1305             this.columns["last_activity"].updateTd = function(td, row) {
1306                 const val = this.getRowValue(row);
1307                 if (val < 1) {
1308                     td.textContent = "∞";
1309                     td.title = "∞";
1310                 }
1311                 else {
1312                     const formattedVal = "QBT_TR(%1 ago)QBT_TR[CONTEXT=TransferListDelegate]".replace("%1", window.qBittorrent.Misc.friendlyDuration((new Date() / 1000) - val));
1313                     td.textContent = formattedVal;
1314                     td.title = formattedVal;
1315                 }
1316             };
1318             // availability
1319             this.columns["availability"].updateTd = function(td, row) {
1320                 const value = window.qBittorrent.Misc.toFixedPointString(this.getRowValue(row), 3);
1321                 td.textContent = value;
1322                 td.title = value;
1323             };
1325             // infohash_v1
1326             this.columns["infohash_v1"].updateTd = function(td, row) {
1327                 const sourceInfohashV1 = this.getRowValue(row);
1328                 const infohashV1 = (sourceInfohashV1 !== "") ? sourceInfohashV1 : "QBT_TR(N/A)QBT_TR[CONTEXT=TransferListDelegate]";
1329                 td.textContent = infohashV1;
1330                 td.title = infohashV1;
1331             };
1333             // infohash_v2
1334             this.columns["infohash_v2"].updateTd = function(td, row) {
1335                 const sourceInfohashV2 = this.getRowValue(row);
1336                 const infohashV2 = (sourceInfohashV2 !== "") ? sourceInfohashV2 : "QBT_TR(N/A)QBT_TR[CONTEXT=TransferListDelegate]";
1337                 td.textContent = infohashV2;
1338                 td.title = infohashV2;
1339             };
1341             // reannounce
1342             this.columns["reannounce"].updateTd = function(td, row) {
1343                 const time = window.qBittorrent.Misc.friendlyDuration(this.getRowValue(row));
1344                 td.textContent = time;
1345                 td.title = time;
1346             };
1348             // private
1349             this.columns["private"].updateTd = function(td, row) {
1350                 const hasMetadata = row["full_data"].has_metadata;
1351                 const isPrivate = this.getRowValue(row);
1352                 const string = hasMetadata
1353                     ? (isPrivate
1354                         ? "QBT_TR(Yes)QBT_TR[CONTEXT=PropertiesWidget]"
1355                         : "QBT_TR(No)QBT_TR[CONTEXT=PropertiesWidget]")
1356                     : "QBT_TR(N/A)QBT_TR[CONTEXT=PropertiesWidget]";
1357                 td.textContent = string;
1358                 td.title = string;
1359             };
1360         },
1362         applyFilter: function(row, filterName, categoryHash, tagHash, trackerHash, filterTerms) {
1363             const state = row["full_data"].state;
1364             let inactive = false;
1366             switch (filterName) {
1367                 case "downloading":
1368                     if ((state !== "downloading") && !state.includes("DL"))
1369                         return false;
1370                     break;
1371                 case "seeding":
1372                     if ((state !== "uploading") && (state !== "forcedUP") && (state !== "stalledUP") && (state !== "queuedUP") && (state !== "checkingUP"))
1373                         return false;
1374                     break;
1375                 case "completed":
1376                     if ((state !== "uploading") && !state.includes("UP"))
1377                         return false;
1378                     break;
1379                 case "stopped":
1380                     if (!state.includes("stopped"))
1381                         return false;
1382                     break;
1383                 case "running":
1384                     if (state.includes("stopped"))
1385                         return false;
1386                     break;
1387                 case "stalled":
1388                     if ((state !== "stalledUP") && (state !== "stalledDL"))
1389                         return false;
1390                     break;
1391                 case "stalled_uploading":
1392                     if (state !== "stalledUP")
1393                         return false;
1394                     break;
1395                 case "stalled_downloading":
1396                     if (state !== "stalledDL")
1397                         return false;
1398                     break;
1399                 case "inactive":
1400                     inactive = true;
1401                     // fallthrough
1402                 case "active": {
1403                     let r;
1404                     if (state === "stalledDL")
1405                         r = (row["full_data"].upspeed > 0);
1406                     else
1407                         r = (state === "metaDL") || (state === "forcedMetaDL") || (state === "downloading") || (state === "forcedDL") || (state === "uploading") || (state === "forcedUP");
1408                     if (r === inactive)
1409                         return false;
1410                     break;
1411                 }
1412                 case "checking":
1413                     if ((state !== "checkingUP") && (state !== "checkingDL") && (state !== "checkingResumeData"))
1414                         return false;
1415                     break;
1416                 case "moving":
1417                     if (state !== "moving")
1418                         return false;
1419                     break;
1420                 case "errored":
1421                     if ((state !== "error") && (state !== "unknown") && (state !== "missingFiles"))
1422                         return false;
1423                     break;
1424             }
1426             switch (categoryHash) {
1427                 case CATEGORIES_ALL:
1428                     break; // do nothing
1429                 case CATEGORIES_UNCATEGORIZED:
1430                     if (row["full_data"].category.length !== 0)
1431                         return false;
1432                     break; // do nothing
1433                 default:
1434                     if (!useSubcategories) {
1435                         if (categoryHash !== window.qBittorrent.Misc.genHash(row["full_data"].category))
1436                             return false;
1437                     }
1438                     else {
1439                         const selectedCategory = category_list.get(categoryHash);
1440                         if (selectedCategory !== undefined) {
1441                             const selectedCategoryName = selectedCategory.name + "/";
1442                             const torrentCategoryName = row["full_data"].category + "/";
1443                             if (!torrentCategoryName.startsWith(selectedCategoryName))
1444                                 return false;
1445                         }
1446                     }
1447                     break;
1448             }
1450             switch (tagHash) {
1451                 case TAGS_ALL:
1452                     break; // do nothing
1454                 case TAGS_UNTAGGED:
1455                     if (row["full_data"].tags.length !== 0)
1456                         return false;
1457                     break; // do nothing
1459                 default: {
1460                     const tagHashes = row["full_data"].tags.split(", ").map(tag => window.qBittorrent.Misc.genHash(tag));
1461                     if (!tagHashes.contains(tagHash))
1462                         return false;
1463                     break;
1464                 }
1465             }
1467             switch (trackerHash) {
1468                 case TRACKERS_ALL:
1469                     break; // do nothing
1470                 case TRACKERS_TRACKERLESS:
1471                     if (row["full_data"].trackers_count !== 0)
1472                         return false;
1473                     break;
1474                 default: {
1475                     const tracker = trackerList.get(trackerHash);
1476                     if (tracker) {
1477                         let found = false;
1478                         for (const torrents of tracker.trackerTorrentMap.values()) {
1479                             if (torrents.includes(row["full_data"].rowId)) {
1480                                 found = true;
1481                                 break;
1482                             }
1483                         }
1484                         if (!found)
1485                             return false;
1486                     }
1487                     break;
1488                 }
1489             }
1491             if ((filterTerms !== undefined) && (filterTerms !== null)) {
1492                 const filterBy = document.getElementById("torrentsFilterSelect").value;
1493                 const textToSearch = row["full_data"][filterBy].toLowerCase();
1494                 if (filterTerms instanceof RegExp) {
1495                     if (!filterTerms.test(textToSearch))
1496                         return false;
1497                 }
1498                 else {
1499                     if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(textToSearch, filterTerms))
1500                         return false;
1501                 }
1502             }
1504             return true;
1505         },
1507         getFilteredTorrentsNumber: function(filterName, categoryHash, tagHash, trackerHash) {
1508             let cnt = 0;
1509             const rows = this.rows.getValues();
1511             for (let i = 0; i < rows.length; ++i) {
1512                 if (this.applyFilter(rows[i], filterName, categoryHash, tagHash, trackerHash, null))
1513                     ++cnt;
1514             }
1515             return cnt;
1516         },
1518         getFilteredTorrentsHashes: function(filterName, categoryHash, tagHash, trackerHash) {
1519             const rowsHashes = [];
1520             const rows = this.rows.getValues();
1522             for (let i = 0; i < rows.length; ++i) {
1523                 if (this.applyFilter(rows[i], filterName, categoryHash, tagHash, trackerHash, null))
1524                     rowsHashes.push(rows[i]["rowId"]);
1525             }
1527             return rowsHashes;
1528         },
1530         getFilteredAndSortedRows: function() {
1531             const filteredRows = [];
1533             const useRegex = $("torrentsFilterRegexBox").checked;
1534             const filterText = $("torrentsFilterInput").value.trim().toLowerCase();
1535             let filterTerms;
1536             try {
1537                 filterTerms = (filterText.length > 0)
1538                     ? (useRegex ? new RegExp(filterText) : filterText.split(" "))
1539                     : null;
1540             }
1541             catch (e) { // SyntaxError: Invalid regex pattern
1542                 return filteredRows;
1543             }
1545             const rows = this.rows.getValues();
1546             for (let i = 0; i < rows.length; ++i) {
1547                 if (this.applyFilter(rows[i], selectedStatus, selectedCategory, selectedTag, selectedTracker, filterTerms)) {
1548                     filteredRows.push(rows[i]);
1549                     filteredRows[rows[i].rowId] = rows[i];
1550                 }
1551             }
1553             filteredRows.sort((row1, row2) => {
1554                 const column = this.columns[this.sortedColumn];
1555                 const res = column.compareRows(row1, row2);
1556                 if (this.reverseSort === "0")
1557                     return res;
1558                 else
1559                     return -res;
1560             });
1561             return filteredRows;
1562         },
1564         setupTr: function(tr) {
1565             tr.addEventListener("dblclick", function(e) {
1566                 e.preventDefault();
1567                 e.stopPropagation();
1569                 this._this.deselectAll();
1570                 this._this.selectRow(this.rowId);
1571                 const row = this._this.rows.get(this.rowId);
1572                 const state = row["full_data"].state;
1574                 const prefKey =
1575                     (state !== "uploading")
1576                     && (state !== "stoppedUP")
1577                     && (state !== "forcedUP")
1578                     && (state !== "stalledUP")
1579                     && (state !== "queuedUP")
1580                     && (state !== "checkingUP")
1581                     ? "dblclick_download"
1582                     : "dblclick_complete";
1584                 if (LocalPreferences.get(prefKey, "1") !== "1")
1585                     return true;
1587                 if (state.includes("stopped"))
1588                     startFN();
1589                 else
1590                     stopFN();
1591                 return true;
1592             });
1593             tr.addClass("torrentsTableContextMenuTarget");
1594         },
1596         getCurrentTorrentID: function() {
1597             return this.getSelectedRowId();
1598         },
1600         onSelectedRowChanged: function() {
1601             updatePropertiesPanel();
1602         }
1603     });
1605     const TorrentPeersTable = new Class({
1606         Extends: DynamicTable,
1608         initColumns: function() {
1609             this.newColumn("country", "", "QBT_TR(Country/Region)QBT_TR[CONTEXT=PeerListWidget]", 22, true);
1610             this.newColumn("ip", "", "QBT_TR(IP)QBT_TR[CONTEXT=PeerListWidget]", 80, true);
1611             this.newColumn("port", "", "QBT_TR(Port)QBT_TR[CONTEXT=PeerListWidget]", 35, true);
1612             this.newColumn("connection", "", "QBT_TR(Connection)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1613             this.newColumn("flags", "", "QBT_TR(Flags)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1614             this.newColumn("client", "", "QBT_TR(Client)QBT_TR[CONTEXT=PeerListWidget]", 140, true);
1615             this.newColumn("peer_id_client", "", "QBT_TR(Peer ID Client)QBT_TR[CONTEXT=PeerListWidget]", 60, false);
1616             this.newColumn("progress", "", "QBT_TR(Progress)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1617             this.newColumn("dl_speed", "", "QBT_TR(Down Speed)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1618             this.newColumn("up_speed", "", "QBT_TR(Up Speed)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1619             this.newColumn("downloaded", "", "QBT_TR(Downloaded)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1620             this.newColumn("uploaded", "", "QBT_TR(Uploaded)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1621             this.newColumn("relevance", "", "QBT_TR(Relevance)QBT_TR[CONTEXT=PeerListWidget]", 30, true);
1622             this.newColumn("files", "", "QBT_TR(Files)QBT_TR[CONTEXT=PeerListWidget]", 100, true);
1624             this.columns["country"].dataProperties.push("country_code");
1625             this.columns["flags"].dataProperties.push("flags_desc");
1626             this.initColumnsFunctions();
1627         },
1629         initColumnsFunctions: function() {
1631             // country
1632             this.columns["country"].updateTd = function(td, row) {
1633                 const country = this.getRowValue(row, 0);
1634                 const country_code = this.getRowValue(row, 1);
1636                 let span = td.firstElementChild;
1637                 if (span === null) {
1638                     span = document.createElement("span");
1639                     span.classList.add("flags");
1640                     td.append(span);
1641                 }
1643                 span.style.backgroundImage = `url('images/flags/${country_code ?? "xx"}.svg')`;
1644                 span.textContent = country;
1645                 td.title = country;
1646             };
1648             // ip
1649             this.columns["ip"].compareRows = function(row1, row2) {
1650                 const ip1 = this.getRowValue(row1);
1651                 const ip2 = this.getRowValue(row2);
1653                 const a = ip1.split(".");
1654                 const b = ip2.split(".");
1656                 for (let i = 0; i < 4; ++i) {
1657                     if (a[i] !== b[i])
1658                         return a[i] - b[i];
1659                 }
1661                 return 0;
1662             };
1664             // flags
1665             this.columns["flags"].updateTd = function(td, row) {
1666                 td.textContent = this.getRowValue(row, 0);
1667                 td.title = this.getRowValue(row, 1);
1668             };
1670             // progress
1671             this.columns["progress"].updateTd = function(td, row) {
1672                 const progress = this.getRowValue(row);
1673                 let progressFormatted = (progress * 100).round(1);
1674                 if ((progressFormatted === 100.0) && (progress !== 1.0))
1675                     progressFormatted = 99.9;
1676                 progressFormatted += "%";
1677                 td.textContent = progressFormatted;
1678                 td.title = progressFormatted;
1679             };
1681             // dl_speed, up_speed
1682             this.columns["dl_speed"].updateTd = function(td, row) {
1683                 const speed = this.getRowValue(row);
1684                 if (speed === 0) {
1685                     td.textContent = "";
1686                     td.title = "";
1687                 }
1688                 else {
1689                     const formattedSpeed = window.qBittorrent.Misc.friendlyUnit(speed, true);
1690                     td.textContent = formattedSpeed;
1691                     td.title = formattedSpeed;
1692                 }
1693             };
1694             this.columns["up_speed"].updateTd = this.columns["dl_speed"].updateTd;
1696             // downloaded, uploaded
1697             this.columns["downloaded"].updateTd = function(td, row) {
1698                 const downloaded = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false);
1699                 td.textContent = downloaded;
1700                 td.title = downloaded;
1701             };
1702             this.columns["uploaded"].updateTd = this.columns["downloaded"].updateTd;
1704             // relevance
1705             this.columns["relevance"].updateTd = this.columns["progress"].updateTd;
1707             // files
1708             this.columns["files"].updateTd = function(td, row) {
1709                 const value = this.getRowValue(row, 0);
1710                 td.textContent = value.replace(/\n/g, ";");
1711                 td.title = value;
1712             };
1714         }
1715     });
1717     const SearchResultsTable = new Class({
1718         Extends: DynamicTable,
1720         initColumns: function() {
1721             this.newColumn("fileName", "", "QBT_TR(Name)QBT_TR[CONTEXT=SearchResultsTable]", 500, true);
1722             this.newColumn("fileSize", "", "QBT_TR(Size)QBT_TR[CONTEXT=SearchResultsTable]", 100, true);
1723             this.newColumn("nbSeeders", "", "QBT_TR(Seeders)QBT_TR[CONTEXT=SearchResultsTable]", 100, true);
1724             this.newColumn("nbLeechers", "", "QBT_TR(Leechers)QBT_TR[CONTEXT=SearchResultsTable]", 100, true);
1725             this.newColumn("siteUrl", "", "QBT_TR(Search engine)QBT_TR[CONTEXT=SearchResultsTable]", 250, true);
1726             this.newColumn("pubDate", "", "QBT_TR(Published On)QBT_TR[CONTEXT=SearchResultsTable]", 200, true);
1728             this.initColumnsFunctions();
1729         },
1731         initColumnsFunctions: function() {
1732             const displaySize = function(td, row) {
1733                 const size = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false);
1734                 td.textContent = size;
1735                 td.title = size;
1736             };
1737             const displayNum = function(td, row) {
1738                 const value = this.getRowValue(row);
1739                 const formattedValue = (value === "-1") ? "Unknown" : value;
1740                 td.textContent = formattedValue;
1741                 td.title = formattedValue;
1742             };
1743             const displayDate = function(td, row) {
1744                 const value = this.getRowValue(row) * 1000;
1745                 const formattedValue = (isNaN(value) || (value <= 0)) ? "" : (new Date(value).toLocaleString());
1746                 td.textContent = formattedValue;
1747                 td.title = formattedValue;
1748             };
1750             this.columns["fileSize"].updateTd = displaySize;
1751             this.columns["nbSeeders"].updateTd = displayNum;
1752             this.columns["nbLeechers"].updateTd = displayNum;
1753             this.columns["pubDate"].updateTd = displayDate;
1754         },
1756         getFilteredAndSortedRows: function() {
1757             const getSizeFilters = function() {
1758                 let minSize = (window.qBittorrent.Search.searchSizeFilter.min > 0.00) ? (window.qBittorrent.Search.searchSizeFilter.min * Math.pow(1024, window.qBittorrent.Search.searchSizeFilter.minUnit)) : 0.00;
1759                 let maxSize = (window.qBittorrent.Search.searchSizeFilter.max > 0.00) ? (window.qBittorrent.Search.searchSizeFilter.max * Math.pow(1024, window.qBittorrent.Search.searchSizeFilter.maxUnit)) : 0.00;
1761                 if ((minSize > maxSize) && (maxSize > 0.00)) {
1762                     const tmp = minSize;
1763                     minSize = maxSize;
1764                     maxSize = tmp;
1765                 }
1767                 return {
1768                     min: minSize,
1769                     max: maxSize
1770                 };
1771             };
1773             const getSeedsFilters = function() {
1774                 let minSeeds = (window.qBittorrent.Search.searchSeedsFilter.min > 0) ? window.qBittorrent.Search.searchSeedsFilter.min : 0;
1775                 let maxSeeds = (window.qBittorrent.Search.searchSeedsFilter.max > 0) ? window.qBittorrent.Search.searchSeedsFilter.max : 0;
1777                 if ((minSeeds > maxSeeds) && (maxSeeds > 0)) {
1778                     const tmp = minSeeds;
1779                     minSeeds = maxSeeds;
1780                     maxSeeds = tmp;
1781                 }
1783                 return {
1784                     min: minSeeds,
1785                     max: maxSeeds
1786                 };
1787             };
1789             let filteredRows = [];
1790             const rows = this.rows.getValues();
1791             const searchTerms = window.qBittorrent.Search.searchText.pattern.toLowerCase().split(" ");
1792             const filterTerms = window.qBittorrent.Search.searchText.filterPattern.toLowerCase().split(" ");
1793             const sizeFilters = getSizeFilters();
1794             const seedsFilters = getSeedsFilters();
1795             const searchInTorrentName = $("searchInTorrentName").value === "names";
1797             if (searchInTorrentName || (filterTerms.length > 0) || (window.qBittorrent.Search.searchSizeFilter.min > 0.00) || (window.qBittorrent.Search.searchSizeFilter.max > 0.00)) {
1798                 for (let i = 0; i < rows.length; ++i) {
1799                     const row = rows[i];
1801                     if (searchInTorrentName && !window.qBittorrent.Misc.containsAllTerms(row.full_data.fileName, searchTerms))
1802                         continue;
1803                     if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(row.full_data.fileName, filterTerms))
1804                         continue;
1805                     if ((sizeFilters.min > 0.00) && (row.full_data.fileSize < sizeFilters.min))
1806                         continue;
1807                     if ((sizeFilters.max > 0.00) && (row.full_data.fileSize > sizeFilters.max))
1808                         continue;
1809                     if ((seedsFilters.min > 0) && (row.full_data.nbSeeders < seedsFilters.min))
1810                         continue;
1811                     if ((seedsFilters.max > 0) && (row.full_data.nbSeeders > seedsFilters.max))
1812                         continue;
1814                     filteredRows.push(row);
1815                 }
1816             }
1817             else {
1818                 filteredRows = rows;
1819             }
1821             filteredRows.sort((row1, row2) => {
1822                 const column = this.columns[this.sortedColumn];
1823                 const res = column.compareRows(row1, row2);
1824                 if (this.reverseSort === "0")
1825                     return res;
1826                 else
1827                     return -res;
1828             });
1830             return filteredRows;
1831         },
1833         setupTr: function(tr) {
1834             tr.addClass("searchTableRow");
1835         }
1836     });
1838     const SearchPluginsTable = new Class({
1839         Extends: DynamicTable,
1841         initColumns: function() {
1842             this.newColumn("fullName", "", "QBT_TR(Name)QBT_TR[CONTEXT=SearchPluginsTable]", 175, true);
1843             this.newColumn("version", "", "QBT_TR(Version)QBT_TR[CONTEXT=SearchPluginsTable]", 100, true);
1844             this.newColumn("url", "", "QBT_TR(Url)QBT_TR[CONTEXT=SearchPluginsTable]", 175, true);
1845             this.newColumn("enabled", "", "QBT_TR(Enabled)QBT_TR[CONTEXT=SearchPluginsTable]", 100, true);
1847             this.initColumnsFunctions();
1848         },
1850         initColumnsFunctions: function() {
1851             this.columns["enabled"].updateTd = function(td, row) {
1852                 const value = this.getRowValue(row);
1853                 if (value) {
1854                     td.textContent = "QBT_TR(Yes)QBT_TR[CONTEXT=SearchPluginsTable]";
1855                     td.title = "QBT_TR(Yes)QBT_TR[CONTEXT=SearchPluginsTable]";
1856                     td.getParent("tr").addClass("green");
1857                     td.getParent("tr").removeClass("red");
1858                 }
1859                 else {
1860                     td.textContent = "QBT_TR(No)QBT_TR[CONTEXT=SearchPluginsTable]";
1861                     td.title = "QBT_TR(No)QBT_TR[CONTEXT=SearchPluginsTable]";
1862                     td.getParent("tr").addClass("red");
1863                     td.getParent("tr").removeClass("green");
1864                 }
1865             };
1866         },
1868         setupTr: function(tr) {
1869             tr.addClass("searchPluginsTableRow");
1870         }
1871     });
1873     const TorrentTrackersTable = new Class({
1874         Extends: DynamicTable,
1876         initColumns: function() {
1877             this.newColumn("tier", "", "QBT_TR(Tier)QBT_TR[CONTEXT=TrackerListWidget]", 35, true);
1878             this.newColumn("url", "", "QBT_TR(URL)QBT_TR[CONTEXT=TrackerListWidget]", 250, true);
1879             this.newColumn("status", "", "QBT_TR(Status)QBT_TR[CONTEXT=TrackerListWidget]", 125, true);
1880             this.newColumn("peers", "", "QBT_TR(Peers)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
1881             this.newColumn("seeds", "", "QBT_TR(Seeds)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
1882             this.newColumn("leeches", "", "QBT_TR(Leeches)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
1883             this.newColumn("downloaded", "", "QBT_TR(Times Downloaded)QBT_TR[CONTEXT=TrackerListWidget]", 100, true);
1884             this.newColumn("message", "", "QBT_TR(Message)QBT_TR[CONTEXT=TrackerListWidget]", 250, true);
1885         },
1886     });
1888     const BulkRenameTorrentFilesTable = new Class({
1889         Extends: DynamicTable,
1891         filterTerms: [],
1892         prevFilterTerms: [],
1893         prevRowsString: null,
1894         prevFilteredRows: [],
1895         prevSortedColumn: null,
1896         prevReverseSort: null,
1897         fileTree: new window.qBittorrent.FileTree.FileTree(),
1899         populateTable: function(root) {
1900             this.fileTree.setRoot(root);
1901             root.children.each((node) => {
1902                 this._addNodeToTable(node, 0);
1903             });
1904         },
1906         _addNodeToTable: function(node, depth) {
1907             node.depth = depth;
1909             if (node.isFolder) {
1910                 const data = {
1911                     rowId: node.rowId,
1912                     fileId: -1,
1913                     checked: node.checked,
1914                     path: node.path,
1915                     original: node.original,
1916                     renamed: node.renamed
1917                 };
1919                 node.data = data;
1920                 node.full_data = data;
1921                 this.updateRowData(data);
1922             }
1923             else {
1924                 node.data.rowId = node.rowId;
1925                 node.full_data = node.data;
1926                 this.updateRowData(node.data);
1927             }
1929             node.children.each((child) => {
1930                 this._addNodeToTable(child, depth + 1);
1931             });
1932         },
1934         getRoot: function() {
1935             return this.fileTree.getRoot();
1936         },
1938         getNode: function(rowId) {
1939             return this.fileTree.getNode(rowId);
1940         },
1942         getRow: function(node) {
1943             const rowId = this.fileTree.getRowId(node);
1944             return this.rows.get(rowId);
1945         },
1947         getSelectedRows: function() {
1948             const nodes = this.fileTree.toArray();
1950             return nodes.filter(x => x.checked === 0);
1951         },
1953         initColumns: function() {
1954             // Blocks saving header width (because window width isn't saved)
1955             LocalPreferences.remove("column_" + "checked" + "_width_" + this.dynamicTableDivId);
1956             LocalPreferences.remove("column_" + "original" + "_width_" + this.dynamicTableDivId);
1957             LocalPreferences.remove("column_" + "renamed" + "_width_" + this.dynamicTableDivId);
1958             this.newColumn("checked", "", "", 50, true);
1959             this.newColumn("original", "", "QBT_TR(Original)QBT_TR[CONTEXT=TrackerListWidget]", 270, true);
1960             this.newColumn("renamed", "", "QBT_TR(Renamed)QBT_TR[CONTEXT=TrackerListWidget]", 220, true);
1962             this.initColumnsFunctions();
1963         },
1965         /**
1966          * Toggles the global checkbox and all checkboxes underneath
1967          */
1968         toggleGlobalCheckbox: function() {
1969             const checkbox = $("rootMultiRename_cb");
1970             const checkboxes = $$("input.RenamingCB");
1972             for (let i = 0; i < checkboxes.length; ++i) {
1973                 const node = this.getNode(i);
1975                 if (checkbox.checked || checkbox.indeterminate) {
1976                     const cb = checkboxes[i];
1977                     cb.checked = true;
1978                     cb.indeterminate = false;
1979                     cb.state = "checked";
1980                     node.checked = 0;
1981                     node.full_data.checked = node.checked;
1982                 }
1983                 else {
1984                     const cb = checkboxes[i];
1985                     cb.checked = false;
1986                     cb.indeterminate = false;
1987                     cb.state = "unchecked";
1988                     node.checked = 1;
1989                     node.full_data.checked = node.checked;
1990                 }
1991             }
1993             this.updateGlobalCheckbox();
1994         },
1996         toggleNodeTreeCheckbox: function(rowId, checkState) {
1997             const node = this.getNode(rowId);
1998             node.checked = checkState;
1999             node.full_data.checked = checkState;
2000             const checkbox = $(`cbRename${rowId}`);
2001             checkbox.checked = node.checked === 0;
2002             checkbox.state = checkbox.checked ? "checked" : "unchecked";
2004             for (let i = 0; i < node.children.length; ++i)
2005                 this.toggleNodeTreeCheckbox(node.children[i].rowId, checkState);
2006         },
2008         updateGlobalCheckbox: function() {
2009             const checkbox = $("rootMultiRename_cb");
2010             const checkboxes = $$("input.RenamingCB");
2011             const isAllChecked = function() {
2012                 for (let i = 0; i < checkboxes.length; ++i) {
2013                     if (!checkboxes[i].checked)
2014                         return false;
2015                 }
2016                 return true;
2017             };
2018             const isAllUnchecked = function() {
2019                 for (let i = 0; i < checkboxes.length; ++i) {
2020                     if (checkboxes[i].checked)
2021                         return false;
2022                 }
2023                 return true;
2024             };
2025             if (isAllChecked()) {
2026                 checkbox.state = "checked";
2027                 checkbox.indeterminate = false;
2028                 checkbox.checked = true;
2029             }
2030             else if (isAllUnchecked()) {
2031                 checkbox.state = "unchecked";
2032                 checkbox.indeterminate = false;
2033                 checkbox.checked = false;
2034             }
2035             else {
2036                 checkbox.state = "partial";
2037                 checkbox.indeterminate = true;
2038                 checkbox.checked = false;
2039             }
2040         },
2042         initColumnsFunctions: function() {
2043             const that = this;
2045             // checked
2046             this.columns["checked"].updateTd = function(td, row) {
2047                 const id = row.rowId;
2048                 const value = this.getRowValue(row);
2050                 const treeImg = new Element("img", {
2051                     src: "images/L.gif",
2052                     styles: {
2053                         "margin-bottom": -2
2054                     }
2055                 });
2056                 const checkbox = new Element("input");
2057                 checkbox.type = "checkbox";
2058                 checkbox.id = "cbRename" + id;
2059                 checkbox.setAttribute("data-id", id);
2060                 checkbox.className = "RenamingCB";
2061                 checkbox.addEventListener("click", (e) => {
2062                     const node = that.getNode(id);
2063                     node.checked = e.target.checked ? 0 : 1;
2064                     node.full_data.checked = node.checked;
2065                     that.updateGlobalCheckbox();
2066                     that.onRowSelectionChange(node);
2067                     e.stopPropagation();
2068                 });
2069                 checkbox.checked = (value === 0);
2070                 checkbox.state = checkbox.checked ? "checked" : "unchecked";
2071                 checkbox.indeterminate = false;
2072                 td.adopt(treeImg, checkbox);
2073             };
2075             // original
2076             this.columns["original"].updateTd = function(td, row) {
2077                 const id = row.rowId;
2078                 const fileNameId = "filesTablefileName" + id;
2079                 const node = that.getNode(id);
2081                 if (node.isFolder) {
2082                     const value = this.getRowValue(row);
2083                     const dirImgId = "renameTableDirImg" + id;
2084                     if ($(dirImgId)) {
2085                         // just update file name
2086                         $(fileNameId).textContent = value;
2087                     }
2088                     else {
2089                         const span = new Element("span", {
2090                             text: value,
2091                             id: fileNameId
2092                         });
2093                         const dirImg = new Element("img", {
2094                             src: "images/directory.svg",
2095                             styles: {
2096                                 "width": 15,
2097                                 "padding-right": 5,
2098                                 "margin-bottom": -3,
2099                                 "margin-left": (node.depth * 20)
2100                             },
2101                             id: dirImgId
2102                         });
2103                         td.replaceChildren(dirImg, span);
2104                     }
2105                 }
2106                 else { // is file
2107                     const value = this.getRowValue(row);
2108                     const span = new Element("span", {
2109                         text: value,
2110                         id: fileNameId,
2111                         styles: {
2112                             "margin-left": ((node.depth + 1) * 20)
2113                         }
2114                     });
2115                     td.replaceChildren(span);
2116                 }
2117             };
2119             // renamed
2120             this.columns["renamed"].updateTd = function(td, row) {
2121                 const id = row.rowId;
2122                 const fileNameRenamedId = "filesTablefileRenamed" + id;
2123                 const value = this.getRowValue(row);
2125                 const span = new Element("span", {
2126                     text: value,
2127                     id: fileNameRenamedId,
2128                 });
2129                 td.replaceChildren(span);
2130             };
2131         },
2133         onRowSelectionChange: function(row) {},
2135         selectRow: function() {
2136             return;
2137         },
2139         reselectRows: function(rowIds) {
2140             const that = this;
2141             this.deselectAll();
2142             this.tableBody.getElements("tr").each((tr) => {
2143                 if (rowIds.includes(tr.rowId)) {
2144                     const node = that.getNode(tr.rowId);
2145                     node.checked = 0;
2146                     node.full_data.checked = 0;
2148                     const checkbox = tr.children[0].getElement("input");
2149                     checkbox.state = "checked";
2150                     checkbox.indeterminate = false;
2151                     checkbox.checked = true;
2152                 }
2153             });
2155             this.updateGlobalCheckbox();
2156         },
2158         _sortNodesByColumn: function(nodes, column) {
2159             nodes.sort((row1, row2) => {
2160                 // list folders before files when sorting by name
2161                 if (column.name === "original") {
2162                     const node1 = this.getNode(row1.data.rowId);
2163                     const node2 = this.getNode(row2.data.rowId);
2164                     if (node1.isFolder && !node2.isFolder)
2165                         return -1;
2166                     if (node2.isFolder && !node1.isFolder)
2167                         return 1;
2168                 }
2170                 const res = column.compareRows(row1, row2);
2171                 return (this.reverseSort === "0") ? res : -res;
2172             });
2174             nodes.each((node) => {
2175                 if (node.children.length > 0)
2176                     this._sortNodesByColumn(node.children, column);
2177             });
2178         },
2180         _filterNodes: function(node, filterTerms, filteredRows) {
2181             if (node.isFolder) {
2182                 const childAdded = node.children.reduce((acc, child) => {
2183                     // we must execute the function before ORing w/ acc or we'll stop checking child nodes after the first successful match
2184                     return (this._filterNodes(child, filterTerms, filteredRows) || acc);
2185                 }, false);
2187                 if (childAdded) {
2188                     const row = this.getRow(node);
2189                     filteredRows.push(row);
2190                     return true;
2191                 }
2192             }
2194             if (window.qBittorrent.Misc.containsAllTerms(node.original, filterTerms)) {
2195                 const row = this.getRow(node);
2196                 filteredRows.push(row);
2197                 return true;
2198             }
2200             return false;
2201         },
2203         setFilter: function(text) {
2204             const filterTerms = text.trim().toLowerCase().split(" ");
2205             if ((filterTerms.length === 1) && (filterTerms[0] === ""))
2206                 this.filterTerms = [];
2207             else
2208                 this.filterTerms = filterTerms;
2209         },
2211         getFilteredAndSortedRows: function() {
2212             if (this.getRoot() === null)
2213                 return [];
2215             const generateRowsSignature = function(rows) {
2216                 const rowsData = rows.map((row) => {
2217                     return row.full_data;
2218                 });
2219                 return JSON.stringify(rowsData);
2220             };
2222             const getFilteredRows = function() {
2223                 if (this.filterTerms.length === 0) {
2224                     const nodeArray = this.fileTree.toArray();
2225                     const filteredRows = nodeArray.map((node) => {
2226                         return this.getRow(node);
2227                     });
2228                     return filteredRows;
2229                 }
2231                 const filteredRows = [];
2232                 this.getRoot().children.each((child) => {
2233                     this._filterNodes(child, this.filterTerms, filteredRows);
2234                 });
2235                 filteredRows.reverse();
2236                 return filteredRows;
2237             }.bind(this);
2239             const hasRowsChanged = function(rowsString, prevRowsStringString) {
2240                 const rowsChanged = (rowsString !== prevRowsStringString);
2241                 const isFilterTermsChanged = this.filterTerms.reduce((acc, term, index) => {
2242                     return (acc || (term !== this.prevFilterTerms[index]));
2243                 }, false);
2244                 const isFilterChanged = ((this.filterTerms.length !== this.prevFilterTerms.length)
2245                     || ((this.filterTerms.length > 0) && isFilterTermsChanged));
2246                 const isSortedColumnChanged = (this.prevSortedColumn !== this.sortedColumn);
2247                 const isReverseSortChanged = (this.prevReverseSort !== this.reverseSort);
2249                 return (rowsChanged || isFilterChanged || isSortedColumnChanged || isReverseSortChanged);
2250             }.bind(this);
2252             const rowsString = generateRowsSignature(this.rows);
2253             if (!hasRowsChanged(rowsString, this.prevRowsString))
2254                 return this.prevFilteredRows;
2256             // sort, then filter
2257             const column = this.columns[this.sortedColumn];
2258             this._sortNodesByColumn(this.getRoot().children, column);
2259             const filteredRows = getFilteredRows();
2261             this.prevFilterTerms = this.filterTerms;
2262             this.prevRowsString = rowsString;
2263             this.prevFilteredRows = filteredRows;
2264             this.prevSortedColumn = this.sortedColumn;
2265             this.prevReverseSort = this.reverseSort;
2266             return filteredRows;
2267         },
2269         setIgnored: function(rowId, ignore) {
2270             const row = this.rows.get(rowId);
2271             if (ignore)
2272                 row.full_data.remaining = 0;
2273             else
2274                 row.full_data.remaining = (row.full_data.size * (1.0 - (row.full_data.progress / 100)));
2275         },
2277         setupTr: function(tr) {
2278             tr.addEventListener("keydown", function(event) {
2279                 switch (event.key) {
2280                     case "left":
2281                         qBittorrent.PropFiles.collapseFolder(this._this.getSelectedRowId());
2282                         return false;
2283                     case "right":
2284                         qBittorrent.PropFiles.expandFolder(this._this.getSelectedRowId());
2285                         return false;
2286                 }
2287             });
2288         }
2289     });
2291     const TorrentFilesTable = new Class({
2292         Extends: DynamicTable,
2294         filterTerms: [],
2295         prevFilterTerms: [],
2296         prevRowsString: null,
2297         prevFilteredRows: [],
2298         prevSortedColumn: null,
2299         prevReverseSort: null,
2300         fileTree: new window.qBittorrent.FileTree.FileTree(),
2302         populateTable: function(root) {
2303             this.fileTree.setRoot(root);
2304             root.children.each((node) => {
2305                 this._addNodeToTable(node, 0);
2306             });
2307         },
2309         _addNodeToTable: function(node, depth) {
2310             node.depth = depth;
2312             if (node.isFolder) {
2313                 const data = {
2314                     rowId: node.rowId,
2315                     size: node.size,
2316                     checked: node.checked,
2317                     remaining: node.remaining,
2318                     progress: node.progress,
2319                     priority: window.qBittorrent.PropFiles.normalizePriority(node.priority),
2320                     availability: node.availability,
2321                     fileId: -1,
2322                     name: node.name
2323                 };
2325                 node.data = data;
2326                 node.full_data = data;
2327                 this.updateRowData(data);
2328             }
2329             else {
2330                 node.data.rowId = node.rowId;
2331                 node.full_data = node.data;
2332                 this.updateRowData(node.data);
2333             }
2335             node.children.each((child) => {
2336                 this._addNodeToTable(child, depth + 1);
2337             });
2338         },
2340         getRoot: function() {
2341             return this.fileTree.getRoot();
2342         },
2344         getNode: function(rowId) {
2345             return this.fileTree.getNode(rowId);
2346         },
2348         getRow: function(node) {
2349             const rowId = this.fileTree.getRowId(node);
2350             return this.rows.get(rowId);
2351         },
2353         initColumns: function() {
2354             this.newColumn("checked", "", "", 50, true);
2355             this.newColumn("name", "", "QBT_TR(Name)QBT_TR[CONTEXT=TrackerListWidget]", 300, true);
2356             this.newColumn("size", "", "QBT_TR(Total Size)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
2357             this.newColumn("progress", "", "QBT_TR(Progress)QBT_TR[CONTEXT=TrackerListWidget]", 100, true);
2358             this.newColumn("priority", "", "QBT_TR(Download Priority)QBT_TR[CONTEXT=TrackerListWidget]", 150, true);
2359             this.newColumn("remaining", "", "QBT_TR(Remaining)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
2360             this.newColumn("availability", "", "QBT_TR(Availability)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
2362             this.initColumnsFunctions();
2363         },
2365         initColumnsFunctions: function() {
2366             const that = this;
2367             const displaySize = function(td, row) {
2368                 const size = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false);
2369                 td.textContent = size;
2370                 td.title = size;
2371             };
2372             const displayPercentage = function(td, row) {
2373                 const value = window.qBittorrent.Misc.friendlyPercentage(this.getRowValue(row));
2374                 td.textContent = value;
2375                 td.title = value;
2376             };
2378             // checked
2379             this.columns["checked"].updateTd = function(td, row) {
2380                 const id = row.rowId;
2381                 const value = this.getRowValue(row);
2383                 if (window.qBittorrent.PropFiles.isDownloadCheckboxExists(id)) {
2384                     window.qBittorrent.PropFiles.updateDownloadCheckbox(id, value);
2385                 }
2386                 else {
2387                     const treeImg = new Element("img", {
2388                         src: "images/L.gif",
2389                         styles: {
2390                             "margin-bottom": -2
2391                         }
2392                     });
2393                     td.adopt(treeImg, window.qBittorrent.PropFiles.createDownloadCheckbox(id, row.full_data.fileId, value));
2394                 }
2395             };
2397             // name
2398             this.columns["name"].updateTd = function(td, row) {
2399                 const id = row.rowId;
2400                 const fileNameId = "filesTablefileName" + id;
2401                 const node = that.getNode(id);
2403                 if (node.isFolder) {
2404                     const value = this.getRowValue(row);
2405                     const collapseIconId = "filesTableCollapseIcon" + id;
2406                     const dirImgId = "filesTableDirImg" + id;
2407                     if ($(dirImgId)) {
2408                         // just update file name
2409                         $(fileNameId).textContent = value;
2410                     }
2411                     else {
2412                         const collapseIcon = new Element("img", {
2413                             src: "images/go-down.svg",
2414                             styles: {
2415                                 "margin-left": (node.depth * 20)
2416                             },
2417                             class: "filesTableCollapseIcon",
2418                             id: collapseIconId,
2419                             "data-id": id,
2420                             onclick: "qBittorrent.PropFiles.collapseIconClicked(this)"
2421                         });
2422                         const span = new Element("span", {
2423                             text: value,
2424                             id: fileNameId
2425                         });
2426                         const dirImg = new Element("img", {
2427                             src: "images/directory.svg",
2428                             styles: {
2429                                 "width": 15,
2430                                 "padding-right": 5,
2431                                 "margin-bottom": -3
2432                             },
2433                             id: dirImgId
2434                         });
2435                         td.replaceChildren(collapseIcon, dirImg, span);
2436                     }
2437                 }
2438                 else {
2439                     const value = this.getRowValue(row);
2440                     const span = new Element("span", {
2441                         text: value,
2442                         id: fileNameId,
2443                         styles: {
2444                             "margin-left": ((node.depth + 1) * 20)
2445                         }
2446                     });
2447                     td.replaceChildren(span);
2448                 }
2449             };
2451             // size
2452             this.columns["size"].updateTd = displaySize;
2454             // progress
2455             this.columns["progress"].updateTd = function(td, row) {
2456                 const id = row.rowId;
2457                 const value = this.getRowValue(row);
2459                 const progressBar = $("pbf_" + id);
2460                 if (progressBar === null) {
2461                     td.adopt(new window.qBittorrent.ProgressBar.ProgressBar(value.toFloat(), {
2462                         id: "pbf_" + id,
2463                         width: 80
2464                     }));
2465                 }
2466                 else {
2467                     progressBar.setValue(value.toFloat());
2468                 }
2469             };
2471             // priority
2472             this.columns["priority"].updateTd = function(td, row) {
2473                 const id = row.rowId;
2474                 const value = this.getRowValue(row);
2476                 if (window.qBittorrent.PropFiles.isPriorityComboExists(id))
2477                     window.qBittorrent.PropFiles.updatePriorityCombo(id, value);
2478                 else
2479                     td.adopt(window.qBittorrent.PropFiles.createPriorityCombo(id, row.full_data.fileId, value));
2480             };
2482             // remaining, availability
2483             this.columns["remaining"].updateTd = displaySize;
2484             this.columns["availability"].updateTd = displayPercentage;
2485         },
2487         _sortNodesByColumn: function(nodes, column) {
2488             nodes.sort((row1, row2) => {
2489                 // list folders before files when sorting by name
2490                 if (column.name === "name") {
2491                     const node1 = this.getNode(row1.data.rowId);
2492                     const node2 = this.getNode(row2.data.rowId);
2493                     if (node1.isFolder && !node2.isFolder)
2494                         return -1;
2495                     if (node2.isFolder && !node1.isFolder)
2496                         return 1;
2497                 }
2499                 const res = column.compareRows(row1, row2);
2500                 return (this.reverseSort === "0") ? res : -res;
2501             });
2503             nodes.each((node) => {
2504                 if (node.children.length > 0)
2505                     this._sortNodesByColumn(node.children, column);
2506             });
2507         },
2509         _filterNodes: function(node, filterTerms, filteredRows) {
2510             if (node.isFolder) {
2511                 const childAdded = node.children.reduce((acc, child) => {
2512                     // we must execute the function before ORing w/ acc or we'll stop checking child nodes after the first successful match
2513                     return (this._filterNodes(child, filterTerms, filteredRows) || acc);
2514                 }, false);
2516                 if (childAdded) {
2517                     const row = this.getRow(node);
2518                     filteredRows.push(row);
2519                     return true;
2520                 }
2521             }
2523             if (window.qBittorrent.Misc.containsAllTerms(node.name, filterTerms)) {
2524                 const row = this.getRow(node);
2525                 filteredRows.push(row);
2526                 return true;
2527             }
2529             return false;
2530         },
2532         setFilter: function(text) {
2533             const filterTerms = text.trim().toLowerCase().split(" ");
2534             if ((filterTerms.length === 1) && (filterTerms[0] === ""))
2535                 this.filterTerms = [];
2536             else
2537                 this.filterTerms = filterTerms;
2538         },
2540         getFilteredAndSortedRows: function() {
2541             if (this.getRoot() === null)
2542                 return [];
2544             const generateRowsSignature = function(rows) {
2545                 const rowsData = rows.map((row) => {
2546                     return row.full_data;
2547                 });
2548                 return JSON.stringify(rowsData);
2549             };
2551             const getFilteredRows = function() {
2552                 if (this.filterTerms.length === 0) {
2553                     const nodeArray = this.fileTree.toArray();
2554                     const filteredRows = nodeArray.map((node) => {
2555                         return this.getRow(node);
2556                     });
2557                     return filteredRows;
2558                 }
2560                 const filteredRows = [];
2561                 this.getRoot().children.each((child) => {
2562                     this._filterNodes(child, this.filterTerms, filteredRows);
2563                 });
2564                 filteredRows.reverse();
2565                 return filteredRows;
2566             }.bind(this);
2568             const hasRowsChanged = function(rowsString, prevRowsStringString) {
2569                 const rowsChanged = (rowsString !== prevRowsStringString);
2570                 const isFilterTermsChanged = this.filterTerms.reduce((acc, term, index) => {
2571                     return (acc || (term !== this.prevFilterTerms[index]));
2572                 }, false);
2573                 const isFilterChanged = ((this.filterTerms.length !== this.prevFilterTerms.length)
2574                     || ((this.filterTerms.length > 0) && isFilterTermsChanged));
2575                 const isSortedColumnChanged = (this.prevSortedColumn !== this.sortedColumn);
2576                 const isReverseSortChanged = (this.prevReverseSort !== this.reverseSort);
2578                 return (rowsChanged || isFilterChanged || isSortedColumnChanged || isReverseSortChanged);
2579             }.bind(this);
2581             const rowsString = generateRowsSignature(this.rows);
2582             if (!hasRowsChanged(rowsString, this.prevRowsString))
2583                 return this.prevFilteredRows;
2585             // sort, then filter
2586             const column = this.columns[this.sortedColumn];
2587             this._sortNodesByColumn(this.getRoot().children, column);
2588             const filteredRows = getFilteredRows();
2590             this.prevFilterTerms = this.filterTerms;
2591             this.prevRowsString = rowsString;
2592             this.prevFilteredRows = filteredRows;
2593             this.prevSortedColumn = this.sortedColumn;
2594             this.prevReverseSort = this.reverseSort;
2595             return filteredRows;
2596         },
2598         setIgnored: function(rowId, ignore) {
2599             const row = this.rows.get(rowId);
2600             if (ignore)
2601                 row.full_data.remaining = 0;
2602             else
2603                 row.full_data.remaining = (row.full_data.size * (1.0 - (row.full_data.progress / 100)));
2604         },
2606         setupTr: function(tr) {
2607             tr.addEventListener("keydown", function(event) {
2608                 switch (event.key) {
2609                     case "left":
2610                         qBittorrent.PropFiles.collapseFolder(this._this.getSelectedRowId());
2611                         return false;
2612                     case "right":
2613                         qBittorrent.PropFiles.expandFolder(this._this.getSelectedRowId());
2614                         return false;
2615                 }
2616             });
2617         }
2618     });
2620     const RssFeedTable = new Class({
2621         Extends: DynamicTable,
2622         initColumns: function() {
2623             this.newColumn("state_icon", "", "", 30, true);
2624             this.newColumn("name", "", "QBT_TR(RSS feeds)QBT_TR[CONTEXT=FeedListWidget]", -1, true);
2626             this.columns["state_icon"].dataProperties[0] = "";
2628             // map name row to "[name] ([unread])"
2629             this.columns["name"].dataProperties.push("unread");
2630             this.columns["name"].updateTd = function(td, row) {
2631                 const name = this.getRowValue(row, 0);
2632                 const unreadCount = this.getRowValue(row, 1);
2633                 const value = name + " (" + unreadCount + ")";
2634                 td.textContent = value;
2635                 td.title = value;
2636             };
2637         },
2638         setupHeaderMenu: function() {},
2639         setupHeaderEvents: function() {},
2640         getFilteredAndSortedRows: function() {
2641             return this.rows.getValues();
2642         },
2643         selectRow: function(rowId) {
2644             this.selectedRows.push(rowId);
2645             this.setRowClass();
2646             this.onSelectedRowChanged();
2648             const rows = this.rows.getValues();
2649             let path = "";
2650             for (let i = 0; i < rows.length; ++i) {
2651                 if (rows[i].rowId === rowId) {
2652                     path = rows[i].full_data.dataPath;
2653                     break;
2654                 }
2655             }
2656             window.qBittorrent.Rss.showRssFeed(path);
2657         },
2658         setupTr: function(tr) {
2659             tr.addEventListener("dblclick", function(e) {
2660                 if (this.rowId !== 0) {
2661                     window.qBittorrent.Rss.moveItem(this._this.rows.get(this.rowId).full_data.dataPath);
2662                     return true;
2663                 }
2664             });
2665         },
2666         updateRow: function(tr, fullUpdate) {
2667             const row = this.rows.get(tr.rowId);
2668             const data = row[fullUpdate ? "full_data" : "data"];
2670             const tds = tr.getElements("td");
2671             for (let i = 0; i < this.columns.length; ++i) {
2672                 if (Object.hasOwn(data, this.columns[i].dataProperties[0]))
2673                     this.columns[i].updateTd(tds[i], row);
2674             }
2675             row["data"] = {};
2676             tds[0].style.overflow = "visible";
2677             const indentation = row.full_data.indentation;
2678             tds[0].style.paddingLeft = (indentation * 32 + 4) + "px";
2679             tds[1].style.paddingLeft = (indentation * 32 + 4) + "px";
2680         },
2681         updateIcons: function() {
2682             // state_icon
2683             this.rows.each(row => {
2684                 let img_path;
2685                 switch (row.full_data.status) {
2686                     case "default":
2687                         img_path = "images/application-rss.svg";
2688                         break;
2689                     case "hasError":
2690                         img_path = "images/task-reject.svg";
2691                         break;
2692                     case "isLoading":
2693                         img_path = "images/spinner.gif";
2694                         break;
2695                     case "unread":
2696                         img_path = "images/mail-inbox.svg";
2697                         break;
2698                     case "isFolder":
2699                         img_path = "images/folder-documents.svg";
2700                         break;
2701                 }
2702                 let td;
2703                 for (let i = 0; i < this.tableBody.rows.length; ++i) {
2704                     if (this.tableBody.rows[i].rowId === row.rowId) {
2705                         td = this.tableBody.rows[i].children[0];
2706                         break;
2707                     }
2708                 }
2709                 if (td.getChildren("img").length > 0) {
2710                     const img = td.getChildren("img")[0];
2711                     if (!img.src.includes(img_path)) {
2712                         img.src = img_path;
2713                         img.title = status;
2714                     }
2715                 }
2716                 else {
2717                     td.adopt(new Element("img", {
2718                         "src": img_path,
2719                         "class": "stateIcon",
2720                         "height": "22px",
2721                         "width": "22px"
2722                     }));
2723                 }
2724             });
2725         },
2726         newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
2727             const column = {};
2728             column["name"] = name;
2729             column["title"] = name;
2730             column["visible"] = defaultVisible;
2731             column["force_hide"] = false;
2732             column["caption"] = caption;
2733             column["style"] = style;
2734             if (defaultWidth !== -1)
2735                 column["width"] = defaultWidth;
2737             column["dataProperties"] = [name];
2738             column["getRowValue"] = function(row, pos) {
2739                 if (pos === undefined)
2740                     pos = 0;
2741                 return row["full_data"][this.dataProperties[pos]];
2742             };
2743             column["compareRows"] = function(row1, row2) {
2744                 const value1 = this.getRowValue(row1);
2745                 const value2 = this.getRowValue(row2);
2746                 if ((typeof(value1) === "number") && (typeof(value2) === "number"))
2747                     return compareNumbers(value1, value2);
2748                 return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
2749             };
2750             column["updateTd"] = function(td, row) {
2751                 const value = this.getRowValue(row);
2752                 td.textContent = value;
2753                 td.title = value;
2754             };
2755             column["onResize"] = null;
2756             this.columns.push(column);
2757             this.columns[name] = column;
2759             this.hiddenTableHeader.appendChild(new Element("th"));
2760             this.fixedTableHeader.appendChild(new Element("th"));
2761         }
2762     });
2764     const RssArticleTable = new Class({
2765         Extends: DynamicTable,
2766         initColumns: function() {
2767             this.newColumn("name", "", "QBT_TR(Torrents: (double-click to download))QBT_TR[CONTEXT=RSSWidget]", -1, true);
2768         },
2769         setupHeaderMenu: function() {},
2770         setupHeaderEvents: function() {},
2771         getFilteredAndSortedRows: function() {
2772             return this.rows.getValues();
2773         },
2774         selectRow: function(rowId) {
2775             this.selectedRows.push(rowId);
2776             this.setRowClass();
2777             this.onSelectedRowChanged();
2779             const rows = this.rows.getValues();
2780             let articleId = "";
2781             let feedUid = "";
2782             for (let i = 0; i < rows.length; ++i) {
2783                 if (rows[i].rowId === rowId) {
2784                     articleId = rows[i].full_data.dataId;
2785                     feedUid = rows[i].full_data.feedUid;
2786                     this.tableBody.rows[rows[i].rowId].removeClass("unreadArticle");
2787                     break;
2788                 }
2789             }
2790             window.qBittorrent.Rss.showDetails(feedUid, articleId);
2791         },
2792         setupTr: function(tr) {
2793             tr.addEventListener("dblclick", function(e) {
2794                 showDownloadPage([this._this.rows.get(this.rowId).full_data.torrentURL]);
2795                 return true;
2796             });
2797             tr.addClass("torrentsTableContextMenuTarget");
2798         },
2799         updateRow: function(tr, fullUpdate) {
2800             const row = this.rows.get(tr.rowId);
2801             const data = row[fullUpdate ? "full_data" : "data"];
2802             if (!row.full_data.isRead)
2803                 tr.addClass("unreadArticle");
2804             else
2805                 tr.removeClass("unreadArticle");
2807             const tds = tr.getElements("td");
2808             for (let i = 0; i < this.columns.length; ++i) {
2809                 if (Object.hasOwn(data, this.columns[i].dataProperties[0]))
2810                     this.columns[i].updateTd(tds[i], row);
2811             }
2812             row["data"] = {};
2813         },
2814         newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
2815             const column = {};
2816             column["name"] = name;
2817             column["title"] = name;
2818             column["visible"] = defaultVisible;
2819             column["force_hide"] = false;
2820             column["caption"] = caption;
2821             column["style"] = style;
2822             if (defaultWidth !== -1)
2823                 column["width"] = defaultWidth;
2825             column["dataProperties"] = [name];
2826             column["getRowValue"] = function(row, pos) {
2827                 if (pos === undefined)
2828                     pos = 0;
2829                 return row["full_data"][this.dataProperties[pos]];
2830             };
2831             column["compareRows"] = function(row1, row2) {
2832                 const value1 = this.getRowValue(row1);
2833                 const value2 = this.getRowValue(row2);
2834                 if ((typeof(value1) === "number") && (typeof(value2) === "number"))
2835                     return compareNumbers(value1, value2);
2836                 return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
2837             };
2838             column["updateTd"] = function(td, row) {
2839                 const value = this.getRowValue(row);
2840                 td.textContent = value;
2841                 td.title = value;
2842             };
2843             column["onResize"] = null;
2844             this.columns.push(column);
2845             this.columns[name] = column;
2847             this.hiddenTableHeader.appendChild(new Element("th"));
2848             this.fixedTableHeader.appendChild(new Element("th"));
2849         }
2850     });
2852     const RssDownloaderRulesTable = new Class({
2853         Extends: DynamicTable,
2854         initColumns: function() {
2855             this.newColumn("checked", "", "", 30, true);
2856             this.newColumn("name", "", "", -1, true);
2858             this.columns["checked"].updateTd = function(td, row) {
2859                 if ($("cbRssDlRule" + row.rowId) === null) {
2860                     const checkbox = new Element("input");
2861                     checkbox.type = "checkbox";
2862                     checkbox.id = "cbRssDlRule" + row.rowId;
2863                     checkbox.checked = row.full_data.checked;
2865                     checkbox.addEventListener("click", function(e) {
2866                         window.qBittorrent.RssDownloader.rssDownloaderRulesTable.updateRowData({
2867                             rowId: row.rowId,
2868                             checked: this.checked
2869                         });
2870                         window.qBittorrent.RssDownloader.modifyRuleState(row.full_data.name, "enabled", this.checked);
2871                         e.stopPropagation();
2872                     });
2874                     td.append(checkbox);
2875                 }
2876                 else {
2877                     $("cbRssDlRule" + row.rowId).checked = row.full_data.checked;
2878                 }
2879             };
2880         },
2881         setupHeaderMenu: function() {},
2882         setupHeaderEvents: function() {},
2883         getFilteredAndSortedRows: function() {
2884             return this.rows.getValues();
2885         },
2886         setupTr: function(tr) {
2887             tr.addEventListener("dblclick", function(e) {
2888                 window.qBittorrent.RssDownloader.renameRule(this._this.rows.get(this.rowId).full_data.name);
2889                 return true;
2890             });
2891         },
2892         newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
2893             const column = {};
2894             column["name"] = name;
2895             column["title"] = name;
2896             column["visible"] = defaultVisible;
2897             column["force_hide"] = false;
2898             column["caption"] = caption;
2899             column["style"] = style;
2900             if (defaultWidth !== -1)
2901                 column["width"] = defaultWidth;
2903             column["dataProperties"] = [name];
2904             column["getRowValue"] = function(row, pos) {
2905                 if (pos === undefined)
2906                     pos = 0;
2907                 return row["full_data"][this.dataProperties[pos]];
2908             };
2909             column["compareRows"] = function(row1, row2) {
2910                 const value1 = this.getRowValue(row1);
2911                 const value2 = this.getRowValue(row2);
2912                 if ((typeof(value1) === "number") && (typeof(value2) === "number"))
2913                     return compareNumbers(value1, value2);
2914                 return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
2915             };
2916             column["updateTd"] = function(td, row) {
2917                 const value = this.getRowValue(row);
2918                 td.textContent = value;
2919                 td.title = value;
2920             };
2921             column["onResize"] = null;
2922             this.columns.push(column);
2923             this.columns[name] = column;
2925             this.hiddenTableHeader.appendChild(new Element("th"));
2926             this.fixedTableHeader.appendChild(new Element("th"));
2927         },
2928         selectRow: function(rowId) {
2929             this.selectedRows.push(rowId);
2930             this.setRowClass();
2931             this.onSelectedRowChanged();
2933             const rows = this.rows.getValues();
2934             let name = "";
2935             for (let i = 0; i < rows.length; ++i) {
2936                 if (rows[i].rowId === rowId) {
2937                     name = rows[i].full_data.name;
2938                     break;
2939                 }
2940             }
2941             window.qBittorrent.RssDownloader.showRule(name);
2942         }
2943     });
2945     const RssDownloaderFeedSelectionTable = new Class({
2946         Extends: DynamicTable,
2947         initColumns: function() {
2948             this.newColumn("checked", "", "", 30, true);
2949             this.newColumn("name", "", "", -1, true);
2951             this.columns["checked"].updateTd = function(td, row) {
2952                 if ($("cbRssDlFeed" + row.rowId) === null) {
2953                     const checkbox = new Element("input");
2954                     checkbox.type = "checkbox";
2955                     checkbox.id = "cbRssDlFeed" + row.rowId;
2956                     checkbox.checked = row.full_data.checked;
2958                     checkbox.addEventListener("click", function(e) {
2959                         window.qBittorrent.RssDownloader.rssDownloaderFeedSelectionTable.updateRowData({
2960                             rowId: row.rowId,
2961                             checked: this.checked
2962                         });
2963                         e.stopPropagation();
2964                     });
2966                     td.append(checkbox);
2967                 }
2968                 else {
2969                     $("cbRssDlFeed" + row.rowId).checked = row.full_data.checked;
2970                 }
2971             };
2972         },
2973         setupHeaderMenu: function() {},
2974         setupHeaderEvents: function() {},
2975         getFilteredAndSortedRows: function() {
2976             return this.rows.getValues();
2977         },
2978         newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
2979             const column = {};
2980             column["name"] = name;
2981             column["title"] = name;
2982             column["visible"] = defaultVisible;
2983             column["force_hide"] = false;
2984             column["caption"] = caption;
2985             column["style"] = style;
2986             if (defaultWidth !== -1)
2987                 column["width"] = defaultWidth;
2989             column["dataProperties"] = [name];
2990             column["getRowValue"] = function(row, pos) {
2991                 if (pos === undefined)
2992                     pos = 0;
2993                 return row["full_data"][this.dataProperties[pos]];
2994             };
2995             column["compareRows"] = function(row1, row2) {
2996                 const value1 = this.getRowValue(row1);
2997                 const value2 = this.getRowValue(row2);
2998                 if ((typeof(value1) === "number") && (typeof(value2) === "number"))
2999                     return compareNumbers(value1, value2);
3000                 return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
3001             };
3002             column["updateTd"] = function(td, row) {
3003                 const value = this.getRowValue(row);
3004                 td.textContent = value;
3005                 td.title = value;
3006             };
3007             column["onResize"] = null;
3008             this.columns.push(column);
3009             this.columns[name] = column;
3011             this.hiddenTableHeader.appendChild(new Element("th"));
3012             this.fixedTableHeader.appendChild(new Element("th"));
3013         },
3014         selectRow: function() {}
3015     });
3017     const RssDownloaderArticlesTable = new Class({
3018         Extends: DynamicTable,
3019         initColumns: function() {
3020             this.newColumn("name", "", "", -1, true);
3021         },
3022         setupHeaderMenu: function() {},
3023         setupHeaderEvents: function() {},
3024         getFilteredAndSortedRows: function() {
3025             return this.rows.getValues();
3026         },
3027         newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
3028             const column = {};
3029             column["name"] = name;
3030             column["title"] = name;
3031             column["visible"] = defaultVisible;
3032             column["force_hide"] = false;
3033             column["caption"] = caption;
3034             column["style"] = style;
3035             if (defaultWidth !== -1)
3036                 column["width"] = defaultWidth;
3038             column["dataProperties"] = [name];
3039             column["getRowValue"] = function(row, pos) {
3040                 if (pos === undefined)
3041                     pos = 0;
3042                 return row["full_data"][this.dataProperties[pos]];
3043             };
3044             column["compareRows"] = function(row1, row2) {
3045                 const value1 = this.getRowValue(row1);
3046                 const value2 = this.getRowValue(row2);
3047                 if ((typeof(value1) === "number") && (typeof(value2) === "number"))
3048                     return compareNumbers(value1, value2);
3049                 return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
3050             };
3051             column["updateTd"] = function(td, row) {
3052                 const value = this.getRowValue(row);
3053                 td.textContent = value;
3054                 td.title = value;
3055             };
3056             column["onResize"] = null;
3057             this.columns.push(column);
3058             this.columns[name] = column;
3060             this.hiddenTableHeader.appendChild(new Element("th"));
3061             this.fixedTableHeader.appendChild(new Element("th"));
3062         },
3063         selectRow: function() {},
3064         updateRow: function(tr, fullUpdate) {
3065             const row = this.rows.get(tr.rowId);
3066             const data = row[fullUpdate ? "full_data" : "data"];
3068             if (row.full_data.isFeed) {
3069                 tr.addClass("articleTableFeed");
3070                 tr.removeClass("articleTableArticle");
3071             }
3072             else {
3073                 tr.removeClass("articleTableFeed");
3074                 tr.addClass("articleTableArticle");
3075             }
3077             const tds = tr.getElements("td");
3078             for (let i = 0; i < this.columns.length; ++i) {
3079                 if (Object.hasOwn(data, this.columns[i].dataProperties[0]))
3080                     this.columns[i].updateTd(tds[i], row);
3081             }
3082             row["data"] = {};
3083         }
3084     });
3086     const LogMessageTable = new Class({
3087         Extends: DynamicTable,
3089         filterText: "",
3091         filteredLength: function() {
3092             return this.tableBody.getElements("tr").length;
3093         },
3095         initColumns: function() {
3096             this.newColumn("rowId", "", "QBT_TR(ID)QBT_TR[CONTEXT=ExecutionLogWidget]", 50, true);
3097             this.newColumn("message", "", "QBT_TR(Message)QBT_TR[CONTEXT=ExecutionLogWidget]", 350, true);
3098             this.newColumn("timestamp", "", "QBT_TR(Timestamp)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);
3099             this.newColumn("type", "", "QBT_TR(Log Type)QBT_TR[CONTEXT=ExecutionLogWidget]", 100, true);
3100             this.initColumnsFunctions();
3101         },
3103         initColumnsFunctions: function() {
3104             this.columns["timestamp"].updateTd = function(td, row) {
3105                 const date = new Date(this.getRowValue(row) * 1000).toLocaleString();
3106                 td.set({ "text": date, "title": date });
3107             };
3109             this.columns["type"].updateTd = function(td, row) {
3110                 // Type of the message: Log::NORMAL: 1, Log::INFO: 2, Log::WARNING: 4, Log::CRITICAL: 8
3111                 let logLevel, addClass;
3112                 switch (this.getRowValue(row).toInt()) {
3113                     case 1:
3114                         logLevel = "QBT_TR(Normal)QBT_TR[CONTEXT=ExecutionLogWidget]";
3115                         addClass = "logNormal";
3116                         break;
3117                     case 2:
3118                         logLevel = "QBT_TR(Info)QBT_TR[CONTEXT=ExecutionLogWidget]";
3119                         addClass = "logInfo";
3120                         break;
3121                     case 4:
3122                         logLevel = "QBT_TR(Warning)QBT_TR[CONTEXT=ExecutionLogWidget]";
3123                         addClass = "logWarning";
3124                         break;
3125                     case 8:
3126                         logLevel = "QBT_TR(Critical)QBT_TR[CONTEXT=ExecutionLogWidget]";
3127                         addClass = "logCritical";
3128                         break;
3129                     default:
3130                         logLevel = "QBT_TR(Unknown)QBT_TR[CONTEXT=ExecutionLogWidget]";
3131                         addClass = "logUnknown";
3132                         break;
3133                 }
3134                 td.set({ "text": logLevel, "title": logLevel });
3135                 td.getParent("tr").className = `logTableRow${addClass}`;
3136             };
3137         },
3139         getFilteredAndSortedRows: function() {
3140             let filteredRows = [];
3141             const rows = this.rows.getValues();
3142             this.filterText = window.qBittorrent.Log.getFilterText();
3143             const filterTerms = (this.filterText.length > 0) ? this.filterText.toLowerCase().split(" ") : [];
3144             const logLevels = window.qBittorrent.Log.getSelectedLevels();
3145             if ((filterTerms.length > 0) || (logLevels.length < 4)) {
3146                 for (let i = 0; i < rows.length; ++i) {
3147                     if (!logLevels.includes(rows[i].full_data.type.toString()))
3148                         continue;
3150                     if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(rows[i].full_data.message, filterTerms))
3151                         continue;
3153                     filteredRows.push(rows[i]);
3154                 }
3155             }
3156             else {
3157                 filteredRows = rows;
3158             }
3160             filteredRows.sort((row1, row2) => {
3161                 const column = this.columns[this.sortedColumn];
3162                 const res = column.compareRows(row1, row2);
3163                 return (this.reverseSort === "0") ? res : -res;
3164             });
3166             return filteredRows;
3167         },
3169         setupCommonEvents: function() {},
3171         setupTr: function(tr) {
3172             tr.addClass("logTableRow");
3173         }
3174     });
3176     const LogPeerTable = new Class({
3177         Extends: LogMessageTable,
3179         initColumns: function() {
3180             this.newColumn("rowId", "", "QBT_TR(ID)QBT_TR[CONTEXT=ExecutionLogWidget]", 50, true);
3181             this.newColumn("ip", "", "QBT_TR(IP)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);
3182             this.newColumn("timestamp", "", "QBT_TR(Timestamp)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);
3183             this.newColumn("blocked", "", "QBT_TR(Status)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);
3184             this.newColumn("reason", "", "QBT_TR(Reason)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);
3186             this.columns["timestamp"].updateTd = function(td, row) {
3187                 const date = new Date(this.getRowValue(row) * 1000).toLocaleString();
3188                 td.set({ "text": date, "title": date });
3189             };
3191             this.columns["blocked"].updateTd = function(td, row) {
3192                 let status, addClass;
3193                 if (this.getRowValue(row)) {
3194                     status = "QBT_TR(Blocked)QBT_TR[CONTEXT=ExecutionLogWidget]";
3195                     addClass = "peerBlocked";
3196                 }
3197                 else {
3198                     status = "QBT_TR(Banned)QBT_TR[CONTEXT=ExecutionLogWidget]";
3199                     addClass = "peerBanned";
3200                 }
3201                 td.set({ "text": status, "title": status });
3202                 td.getParent("tr").className = `logTableRow${addClass}`;
3203             };
3204         },
3206         getFilteredAndSortedRows: function() {
3207             let filteredRows = [];
3208             const rows = this.rows.getValues();
3209             this.filterText = window.qBittorrent.Log.getFilterText();
3210             const filterTerms = (this.filterText.length > 0) ? this.filterText.toLowerCase().split(" ") : [];
3211             if (filterTerms.length > 0) {
3212                 for (let i = 0; i < rows.length; ++i) {
3213                     if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(rows[i].full_data.ip, filterTerms))
3214                         continue;
3216                     filteredRows.push(rows[i]);
3217                 }
3218             }
3219             else {
3220                 filteredRows = rows;
3221             }
3223             filteredRows.sort((row1, row2) => {
3224                 const column = this.columns[this.sortedColumn];
3225                 const res = column.compareRows(row1, row2);
3226                 return (this.reverseSort === "0") ? res : -res;
3227             });
3229             return filteredRows;
3230         }
3231     });
3233     return exports();
3234 })();
3235 Object.freeze(window.qBittorrent.DynamicTable);
3237 /*************************************************************/