WebUI: Use Map instead of Mootools Hash in Torrents table
[qBittorrent.git] / src / webui / www / private / scripts / dynamicTable.js
blobe039dfbd189a7342134a7b127701e32f14166604
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         setup: function(dynamicTableDivId, dynamicTableFixedHeaderDivId, contextMenu) {
902             this.parent(dynamicTableDivId, dynamicTableFixedHeaderDivId, contextMenu);
903             this.rows = new Map();
904         },
906         initColumns: function() {
907             this.newColumn("priority", "", "#", 30, true);
908             this.newColumn("state_icon", "cursor: default", "", 22, true);
909             this.newColumn("name", "", "QBT_TR(Name)QBT_TR[CONTEXT=TransferListModel]", 200, true);
910             this.newColumn("size", "", "QBT_TR(Size)QBT_TR[CONTEXT=TransferListModel]", 100, true);
911             this.newColumn("total_size", "", "QBT_TR(Total Size)QBT_TR[CONTEXT=TransferListModel]", 100, false);
912             this.newColumn("progress", "", "QBT_TR(Progress)QBT_TR[CONTEXT=TransferListModel]", 85, true);
913             this.newColumn("status", "", "QBT_TR(Status)QBT_TR[CONTEXT=TransferListModel]", 100, true);
914             this.newColumn("num_seeds", "", "QBT_TR(Seeds)QBT_TR[CONTEXT=TransferListModel]", 100, true);
915             this.newColumn("num_leechs", "", "QBT_TR(Peers)QBT_TR[CONTEXT=TransferListModel]", 100, true);
916             this.newColumn("dlspeed", "", "QBT_TR(Down Speed)QBT_TR[CONTEXT=TransferListModel]", 100, true);
917             this.newColumn("upspeed", "", "QBT_TR(Up Speed)QBT_TR[CONTEXT=TransferListModel]", 100, true);
918             this.newColumn("eta", "", "QBT_TR(ETA)QBT_TR[CONTEXT=TransferListModel]", 100, true);
919             this.newColumn("ratio", "", "QBT_TR(Ratio)QBT_TR[CONTEXT=TransferListModel]", 100, true);
920             this.newColumn("popularity", "", "QBT_TR(Popularity)QBT_TR[CONTEXT=TransferListModel]", 100, true);
921             this.newColumn("category", "", "QBT_TR(Category)QBT_TR[CONTEXT=TransferListModel]", 100, true);
922             this.newColumn("tags", "", "QBT_TR(Tags)QBT_TR[CONTEXT=TransferListModel]", 100, true);
923             this.newColumn("added_on", "", "QBT_TR(Added On)QBT_TR[CONTEXT=TransferListModel]", 100, true);
924             this.newColumn("completion_on", "", "QBT_TR(Completed On)QBT_TR[CONTEXT=TransferListModel]", 100, false);
925             this.newColumn("tracker", "", "QBT_TR(Tracker)QBT_TR[CONTEXT=TransferListModel]", 100, false);
926             this.newColumn("dl_limit", "", "QBT_TR(Down Limit)QBT_TR[CONTEXT=TransferListModel]", 100, false);
927             this.newColumn("up_limit", "", "QBT_TR(Up Limit)QBT_TR[CONTEXT=TransferListModel]", 100, false);
928             this.newColumn("downloaded", "", "QBT_TR(Downloaded)QBT_TR[CONTEXT=TransferListModel]", 100, false);
929             this.newColumn("uploaded", "", "QBT_TR(Uploaded)QBT_TR[CONTEXT=TransferListModel]", 100, false);
930             this.newColumn("downloaded_session", "", "QBT_TR(Session Download)QBT_TR[CONTEXT=TransferListModel]", 100, false);
931             this.newColumn("uploaded_session", "", "QBT_TR(Session Upload)QBT_TR[CONTEXT=TransferListModel]", 100, false);
932             this.newColumn("amount_left", "", "QBT_TR(Remaining)QBT_TR[CONTEXT=TransferListModel]", 100, false);
933             this.newColumn("time_active", "", "QBT_TR(Time Active)QBT_TR[CONTEXT=TransferListModel]", 100, false);
934             this.newColumn("save_path", "", "QBT_TR(Save path)QBT_TR[CONTEXT=TransferListModel]", 100, false);
935             this.newColumn("completed", "", "QBT_TR(Completed)QBT_TR[CONTEXT=TransferListModel]", 100, false);
936             this.newColumn("max_ratio", "", "QBT_TR(Ratio Limit)QBT_TR[CONTEXT=TransferListModel]", 100, false);
937             this.newColumn("seen_complete", "", "QBT_TR(Last Seen Complete)QBT_TR[CONTEXT=TransferListModel]", 100, false);
938             this.newColumn("last_activity", "", "QBT_TR(Last Activity)QBT_TR[CONTEXT=TransferListModel]", 100, false);
939             this.newColumn("availability", "", "QBT_TR(Availability)QBT_TR[CONTEXT=TransferListModel]", 100, false);
940             this.newColumn("download_path", "", "QBT_TR(Incomplete Save Path)QBT_TR[CONTEXT=TransferListModel]", 100, false);
941             this.newColumn("infohash_v1", "", "QBT_TR(Info Hash v1)QBT_TR[CONTEXT=TransferListModel]", 100, false);
942             this.newColumn("infohash_v2", "", "QBT_TR(Info Hash v2)QBT_TR[CONTEXT=TransferListModel]", 100, false);
943             this.newColumn("reannounce", "", "QBT_TR(Reannounce In)QBT_TR[CONTEXT=TransferListModel]", 100, false);
944             this.newColumn("private", "", "QBT_TR(Private)QBT_TR[CONTEXT=TransferListModel]", 100, false);
946             this.columns["state_icon"].onclick = "";
947             this.columns["state_icon"].dataProperties[0] = "state";
949             this.columns["num_seeds"].dataProperties.push("num_complete");
950             this.columns["num_leechs"].dataProperties.push("num_incomplete");
951             this.columns["time_active"].dataProperties.push("seeding_time");
953             this.initColumnsFunctions();
954         },
956         initColumnsFunctions: function() {
958             // state_icon
959             this.columns["state_icon"].updateTd = function(td, row) {
960                 let state = this.getRowValue(row);
961                 let img_path;
962                 // normalize states
963                 switch (state) {
964                     case "forcedDL":
965                     case "metaDL":
966                     case "forcedMetaDL":
967                     case "downloading":
968                         state = "downloading";
969                         img_path = "images/downloading.svg";
970                         break;
971                     case "forcedUP":
972                     case "uploading":
973                         state = "uploading";
974                         img_path = "images/upload.svg";
975                         break;
976                     case "stalledUP":
977                         state = "stalledUP";
978                         img_path = "images/stalledUP.svg";
979                         break;
980                     case "stalledDL":
981                         state = "stalledDL";
982                         img_path = "images/stalledDL.svg";
983                         break;
984                     case "stoppedDL":
985                         state = "torrent-stop";
986                         img_path = "images/stopped.svg";
987                         break;
988                     case "stoppedUP":
989                         state = "checked-completed";
990                         img_path = "images/checked-completed.svg";
991                         break;
992                     case "queuedDL":
993                     case "queuedUP":
994                         state = "queued";
995                         img_path = "images/queued.svg";
996                         break;
997                     case "checkingDL":
998                     case "checkingUP":
999                     case "queuedForChecking":
1000                     case "checkingResumeData":
1001                         state = "force-recheck";
1002                         img_path = "images/force-recheck.svg";
1003                         break;
1004                     case "moving":
1005                         state = "moving";
1006                         img_path = "images/set-location.svg";
1007                         break;
1008                     case "error":
1009                     case "unknown":
1010                     case "missingFiles":
1011                         state = "error";
1012                         img_path = "images/error.svg";
1013                         break;
1014                     default:
1015                         break; // do nothing
1016                 }
1018                 if (td.getChildren("img").length > 0) {
1019                     const img = td.getChildren("img")[0];
1020                     if (!img.src.includes(img_path)) {
1021                         img.src = img_path;
1022                         img.title = state;
1023                     }
1024                 }
1025                 else {
1026                     td.adopt(new Element("img", {
1027                         "src": img_path,
1028                         "class": "stateIcon",
1029                         "title": state
1030                     }));
1031                 }
1032             };
1034             // status
1035             this.columns["status"].updateTd = function(td, row) {
1036                 const state = this.getRowValue(row);
1037                 if (!state)
1038                     return;
1040                 let status;
1041                 switch (state) {
1042                     case "downloading":
1043                         status = "QBT_TR(Downloading)QBT_TR[CONTEXT=TransferListDelegate]";
1044                         break;
1045                     case "stalledDL":
1046                         status = "QBT_TR(Stalled)QBT_TR[CONTEXT=TransferListDelegate]";
1047                         break;
1048                     case "metaDL":
1049                         status = "QBT_TR(Downloading metadata)QBT_TR[CONTEXT=TransferListDelegate]";
1050                         break;
1051                     case "forcedMetaDL":
1052                         status = "QBT_TR([F] Downloading metadata)QBT_TR[CONTEXT=TransferListDelegate]";
1053                         break;
1054                     case "forcedDL":
1055                         status = "QBT_TR([F] Downloading)QBT_TR[CONTEXT=TransferListDelegate]";
1056                         break;
1057                     case "uploading":
1058                     case "stalledUP":
1059                         status = "QBT_TR(Seeding)QBT_TR[CONTEXT=TransferListDelegate]";
1060                         break;
1061                     case "forcedUP":
1062                         status = "QBT_TR([F] Seeding)QBT_TR[CONTEXT=TransferListDelegate]";
1063                         break;
1064                     case "queuedDL":
1065                     case "queuedUP":
1066                         status = "QBT_TR(Queued)QBT_TR[CONTEXT=TransferListDelegate]";
1067                         break;
1068                     case "checkingDL":
1069                     case "checkingUP":
1070                         status = "QBT_TR(Checking)QBT_TR[CONTEXT=TransferListDelegate]";
1071                         break;
1072                     case "queuedForChecking":
1073                         status = "QBT_TR(Queued for checking)QBT_TR[CONTEXT=TransferListDelegate]";
1074                         break;
1075                     case "checkingResumeData":
1076                         status = "QBT_TR(Checking resume data)QBT_TR[CONTEXT=TransferListDelegate]";
1077                         break;
1078                     case "stoppedDL":
1079                         status = "QBT_TR(Stopped)QBT_TR[CONTEXT=TransferListDelegate]";
1080                         break;
1081                     case "stoppedUP":
1082                         status = "QBT_TR(Completed)QBT_TR[CONTEXT=TransferListDelegate]";
1083                         break;
1084                     case "moving":
1085                         status = "QBT_TR(Moving)QBT_TR[CONTEXT=TransferListDelegate]";
1086                         break;
1087                     case "missingFiles":
1088                         status = "QBT_TR(Missing Files)QBT_TR[CONTEXT=TransferListDelegate]";
1089                         break;
1090                     case "error":
1091                         status = "QBT_TR(Errored)QBT_TR[CONTEXT=TransferListDelegate]";
1092                         break;
1093                     default:
1094                         status = "QBT_TR(Unknown)QBT_TR[CONTEXT=HttpServer]";
1095                 }
1097                 td.textContent = status;
1098                 td.title = status;
1099             };
1101             // priority
1102             this.columns["priority"].updateTd = function(td, row) {
1103                 const queuePos = this.getRowValue(row);
1104                 const formattedQueuePos = (queuePos < 1) ? "*" : queuePos;
1105                 td.textContent = formattedQueuePos;
1106                 td.title = formattedQueuePos;
1107             };
1109             this.columns["priority"].compareRows = function(row1, row2) {
1110                 let row1_val = this.getRowValue(row1);
1111                 let row2_val = this.getRowValue(row2);
1112                 if (row1_val < 1)
1113                     row1_val = 1000000;
1114                 if (row2_val < 1)
1115                     row2_val = 1000000;
1116                 return compareNumbers(row1_val, row2_val);
1117             };
1119             // name, category, tags
1120             this.columns["name"].compareRows = function(row1, row2) {
1121                 const row1Val = this.getRowValue(row1);
1122                 const row2Val = this.getRowValue(row2);
1123                 return row1Val.localeCompare(row2Val, undefined, { numeric: true, sensitivity: "base" });
1124             };
1125             this.columns["category"].compareRows = this.columns["name"].compareRows;
1126             this.columns["tags"].compareRows = this.columns["name"].compareRows;
1128             // size, total_size
1129             this.columns["size"].updateTd = function(td, row) {
1130                 const size = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false);
1131                 td.textContent = size;
1132                 td.title = size;
1133             };
1134             this.columns["total_size"].updateTd = this.columns["size"].updateTd;
1136             // progress
1137             this.columns["progress"].updateTd = function(td, row) {
1138                 const progress = this.getRowValue(row);
1139                 let progressFormatted = (progress * 100).round(1);
1140                 if ((progressFormatted === 100.0) && (progress !== 1.0))
1141                     progressFormatted = 99.9;
1143                 if (td.getChildren("div").length > 0) {
1144                     const div = td.getChildren("div")[0];
1145                     if (td.resized) {
1146                         td.resized = false;
1147                         div.setWidth(ProgressColumnWidth - 5);
1148                     }
1149                     if (div.getValue() !== progressFormatted)
1150                         div.setValue(progressFormatted);
1151                 }
1152                 else {
1153                     if (ProgressColumnWidth < 0)
1154                         ProgressColumnWidth = td.offsetWidth;
1155                     td.adopt(new window.qBittorrent.ProgressBar.ProgressBar(progressFormatted.toFloat(), {
1156                         "width": ProgressColumnWidth - 5
1157                     }));
1158                     td.resized = false;
1159                 }
1160             };
1162             this.columns["progress"].onResize = function(columnName) {
1163                 const pos = this.getColumnPos(columnName);
1164                 const trs = this.tableBody.getElements("tr");
1165                 ProgressColumnWidth = -1;
1166                 for (let i = 0; i < trs.length; ++i) {
1167                     const td = trs[i].getElements("td")[pos];
1168                     if (ProgressColumnWidth < 0)
1169                         ProgressColumnWidth = td.offsetWidth;
1170                     td.resized = true;
1171                     this.columns[columnName].updateTd(td, this.rows.get(trs[i].rowId));
1172                 }
1173             }.bind(this);
1175             // num_seeds
1176             this.columns["num_seeds"].updateTd = function(td, row) {
1177                 const num_seeds = this.getRowValue(row, 0);
1178                 const num_complete = this.getRowValue(row, 1);
1179                 let value = num_seeds;
1180                 if (num_complete !== -1)
1181                     value += " (" + num_complete + ")";
1182                 td.textContent = value;
1183                 td.title = value;
1184             };
1185             this.columns["num_seeds"].compareRows = function(row1, row2) {
1186                 const num_seeds1 = this.getRowValue(row1, 0);
1187                 const num_complete1 = this.getRowValue(row1, 1);
1189                 const num_seeds2 = this.getRowValue(row2, 0);
1190                 const num_complete2 = this.getRowValue(row2, 1);
1192                 const result = compareNumbers(num_complete1, num_complete2);
1193                 if (result !== 0)
1194                     return result;
1195                 return compareNumbers(num_seeds1, num_seeds2);
1196             };
1198             // num_leechs
1199             this.columns["num_leechs"].updateTd = this.columns["num_seeds"].updateTd;
1200             this.columns["num_leechs"].compareRows = this.columns["num_seeds"].compareRows;
1202             // dlspeed
1203             this.columns["dlspeed"].updateTd = function(td, row) {
1204                 const speed = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), true);
1205                 td.textContent = speed;
1206                 td.title = speed;
1207             };
1209             // upspeed
1210             this.columns["upspeed"].updateTd = this.columns["dlspeed"].updateTd;
1212             // eta
1213             this.columns["eta"].updateTd = function(td, row) {
1214                 const eta = window.qBittorrent.Misc.friendlyDuration(this.getRowValue(row), window.qBittorrent.Misc.MAX_ETA);
1215                 td.textContent = eta;
1216                 td.title = eta;
1217             };
1219             // ratio
1220             this.columns["ratio"].updateTd = function(td, row) {
1221                 const ratio = this.getRowValue(row);
1222                 const string = (ratio === -1) ? "∞" : window.qBittorrent.Misc.toFixedPointString(ratio, 2);
1223                 td.textContent = string;
1224                 td.title = string;
1225             };
1227             // popularity
1228             this.columns["popularity"].updateTd = function(td, row) {
1229                 const value = this.getRowValue(row);
1230                 const popularity = (value === -1) ? "∞" : window.qBittorrent.Misc.toFixedPointString(value, 2);
1231                 td.textContent = popularity;
1232                 td.title = popularity;
1233             };
1235             // added on
1236             this.columns["added_on"].updateTd = function(td, row) {
1237                 const date = new Date(this.getRowValue(row) * 1000).toLocaleString();
1238                 td.textContent = date;
1239                 td.title = date;
1240             };
1242             // completion_on
1243             this.columns["completion_on"].updateTd = function(td, row) {
1244                 const val = this.getRowValue(row);
1245                 if ((val === 0xffffffff) || (val < 0)) {
1246                     td.textContent = "";
1247                     td.title = "";
1248                 }
1249                 else {
1250                     const date = new Date(this.getRowValue(row) * 1000).toLocaleString();
1251                     td.textContent = date;
1252                     td.title = date;
1253                 }
1254             };
1256             // tracker
1257             this.columns["tracker"].updateTd = function(td, row) {
1258                 const value = this.getRowValue(row);
1259                 const tracker = displayFullURLTrackerColumn ? value : window.qBittorrent.Misc.getHost(value);
1260                 td.textContent = tracker;
1261                 td.title = value;
1262             };
1264             //  dl_limit, up_limit
1265             this.columns["dl_limit"].updateTd = function(td, row) {
1266                 const speed = this.getRowValue(row);
1267                 if (speed === 0) {
1268                     td.textContent = "∞";
1269                     td.title = "∞";
1270                 }
1271                 else {
1272                     const formattedSpeed = window.qBittorrent.Misc.friendlyUnit(speed, true);
1273                     td.textContent = formattedSpeed;
1274                     td.title = formattedSpeed;
1275                 }
1276             };
1278             this.columns["up_limit"].updateTd = this.columns["dl_limit"].updateTd;
1280             // downloaded, uploaded, downloaded_session, uploaded_session, amount_left
1281             this.columns["downloaded"].updateTd = this.columns["size"].updateTd;
1282             this.columns["uploaded"].updateTd = this.columns["size"].updateTd;
1283             this.columns["downloaded_session"].updateTd = this.columns["size"].updateTd;
1284             this.columns["uploaded_session"].updateTd = this.columns["size"].updateTd;
1285             this.columns["amount_left"].updateTd = this.columns["size"].updateTd;
1287             // time active
1288             this.columns["time_active"].updateTd = function(td, row) {
1289                 const activeTime = this.getRowValue(row, 0);
1290                 const seedingTime = this.getRowValue(row, 1);
1291                 const time = (seedingTime > 0)
1292                     ? ("QBT_TR(%1 (seeded for %2))QBT_TR[CONTEXT=TransferListDelegate]"
1293                         .replace("%1", window.qBittorrent.Misc.friendlyDuration(activeTime))
1294                         .replace("%2", window.qBittorrent.Misc.friendlyDuration(seedingTime)))
1295                     : window.qBittorrent.Misc.friendlyDuration(activeTime);
1296                 td.textContent = time;
1297                 td.title = time;
1298             };
1300             // completed
1301             this.columns["completed"].updateTd = this.columns["size"].updateTd;
1303             // max_ratio
1304             this.columns["max_ratio"].updateTd = this.columns["ratio"].updateTd;
1306             // seen_complete
1307             this.columns["seen_complete"].updateTd = this.columns["completion_on"].updateTd;
1309             // last_activity
1310             this.columns["last_activity"].updateTd = function(td, row) {
1311                 const val = this.getRowValue(row);
1312                 if (val < 1) {
1313                     td.textContent = "∞";
1314                     td.title = "∞";
1315                 }
1316                 else {
1317                     const formattedVal = "QBT_TR(%1 ago)QBT_TR[CONTEXT=TransferListDelegate]".replace("%1", window.qBittorrent.Misc.friendlyDuration((new Date() / 1000) - val));
1318                     td.textContent = formattedVal;
1319                     td.title = formattedVal;
1320                 }
1321             };
1323             // availability
1324             this.columns["availability"].updateTd = function(td, row) {
1325                 const value = window.qBittorrent.Misc.toFixedPointString(this.getRowValue(row), 3);
1326                 td.textContent = value;
1327                 td.title = value;
1328             };
1330             // infohash_v1
1331             this.columns["infohash_v1"].updateTd = function(td, row) {
1332                 const sourceInfohashV1 = this.getRowValue(row);
1333                 const infohashV1 = (sourceInfohashV1 !== "") ? sourceInfohashV1 : "QBT_TR(N/A)QBT_TR[CONTEXT=TransferListDelegate]";
1334                 td.textContent = infohashV1;
1335                 td.title = infohashV1;
1336             };
1338             // infohash_v2
1339             this.columns["infohash_v2"].updateTd = function(td, row) {
1340                 const sourceInfohashV2 = this.getRowValue(row);
1341                 const infohashV2 = (sourceInfohashV2 !== "") ? sourceInfohashV2 : "QBT_TR(N/A)QBT_TR[CONTEXT=TransferListDelegate]";
1342                 td.textContent = infohashV2;
1343                 td.title = infohashV2;
1344             };
1346             // reannounce
1347             this.columns["reannounce"].updateTd = function(td, row) {
1348                 const time = window.qBittorrent.Misc.friendlyDuration(this.getRowValue(row));
1349                 td.textContent = time;
1350                 td.title = time;
1351             };
1353             // private
1354             this.columns["private"].updateTd = function(td, row) {
1355                 const hasMetadata = row["full_data"].has_metadata;
1356                 const isPrivate = this.getRowValue(row);
1357                 const string = hasMetadata
1358                     ? (isPrivate
1359                         ? "QBT_TR(Yes)QBT_TR[CONTEXT=PropertiesWidget]"
1360                         : "QBT_TR(No)QBT_TR[CONTEXT=PropertiesWidget]")
1361                     : "QBT_TR(N/A)QBT_TR[CONTEXT=PropertiesWidget]";
1362                 td.textContent = string;
1363                 td.title = string;
1364             };
1365         },
1367         applyFilter: function(row, filterName, categoryHash, tagHash, trackerHash, filterTerms) {
1368             const state = row["full_data"].state;
1369             let inactive = false;
1371             switch (filterName) {
1372                 case "downloading":
1373                     if ((state !== "downloading") && !state.includes("DL"))
1374                         return false;
1375                     break;
1376                 case "seeding":
1377                     if ((state !== "uploading") && (state !== "forcedUP") && (state !== "stalledUP") && (state !== "queuedUP") && (state !== "checkingUP"))
1378                         return false;
1379                     break;
1380                 case "completed":
1381                     if ((state !== "uploading") && !state.includes("UP"))
1382                         return false;
1383                     break;
1384                 case "stopped":
1385                     if (!state.includes("stopped"))
1386                         return false;
1387                     break;
1388                 case "running":
1389                     if (state.includes("stopped"))
1390                         return false;
1391                     break;
1392                 case "stalled":
1393                     if ((state !== "stalledUP") && (state !== "stalledDL"))
1394                         return false;
1395                     break;
1396                 case "stalled_uploading":
1397                     if (state !== "stalledUP")
1398                         return false;
1399                     break;
1400                 case "stalled_downloading":
1401                     if (state !== "stalledDL")
1402                         return false;
1403                     break;
1404                 case "inactive":
1405                     inactive = true;
1406                     // fallthrough
1407                 case "active": {
1408                     let r;
1409                     if (state === "stalledDL")
1410                         r = (row["full_data"].upspeed > 0);
1411                     else
1412                         r = (state === "metaDL") || (state === "forcedMetaDL") || (state === "downloading") || (state === "forcedDL") || (state === "uploading") || (state === "forcedUP");
1413                     if (r === inactive)
1414                         return false;
1415                     break;
1416                 }
1417                 case "checking":
1418                     if ((state !== "checkingUP") && (state !== "checkingDL") && (state !== "checkingResumeData"))
1419                         return false;
1420                     break;
1421                 case "moving":
1422                     if (state !== "moving")
1423                         return false;
1424                     break;
1425                 case "errored":
1426                     if ((state !== "error") && (state !== "unknown") && (state !== "missingFiles"))
1427                         return false;
1428                     break;
1429             }
1431             switch (categoryHash) {
1432                 case CATEGORIES_ALL:
1433                     break; // do nothing
1434                 case CATEGORIES_UNCATEGORIZED:
1435                     if (row["full_data"].category.length !== 0)
1436                         return false;
1437                     break; // do nothing
1438                 default:
1439                     if (!useSubcategories) {
1440                         if (categoryHash !== window.qBittorrent.Misc.genHash(row["full_data"].category))
1441                             return false;
1442                     }
1443                     else {
1444                         const selectedCategory = category_list.get(categoryHash);
1445                         if (selectedCategory !== undefined) {
1446                             const selectedCategoryName = selectedCategory.name + "/";
1447                             const torrentCategoryName = row["full_data"].category + "/";
1448                             if (!torrentCategoryName.startsWith(selectedCategoryName))
1449                                 return false;
1450                         }
1451                     }
1452                     break;
1453             }
1455             switch (tagHash) {
1456                 case TAGS_ALL:
1457                     break; // do nothing
1459                 case TAGS_UNTAGGED:
1460                     if (row["full_data"].tags.length !== 0)
1461                         return false;
1462                     break; // do nothing
1464                 default: {
1465                     const tagHashes = row["full_data"].tags.split(", ").map(tag => window.qBittorrent.Misc.genHash(tag));
1466                     if (!tagHashes.contains(tagHash))
1467                         return false;
1468                     break;
1469                 }
1470             }
1472             switch (trackerHash) {
1473                 case TRACKERS_ALL:
1474                     break; // do nothing
1475                 case TRACKERS_TRACKERLESS:
1476                     if (row["full_data"].trackers_count !== 0)
1477                         return false;
1478                     break;
1479                 default: {
1480                     const tracker = trackerList.get(trackerHash);
1481                     if (tracker) {
1482                         let found = false;
1483                         for (const torrents of tracker.trackerTorrentMap.values()) {
1484                             if (torrents.includes(row["full_data"].rowId)) {
1485                                 found = true;
1486                                 break;
1487                             }
1488                         }
1489                         if (!found)
1490                             return false;
1491                     }
1492                     break;
1493                 }
1494             }
1496             if ((filterTerms !== undefined) && (filterTerms !== null)) {
1497                 const filterBy = document.getElementById("torrentsFilterSelect").value;
1498                 const textToSearch = row["full_data"][filterBy].toLowerCase();
1499                 if (filterTerms instanceof RegExp) {
1500                     if (!filterTerms.test(textToSearch))
1501                         return false;
1502                 }
1503                 else {
1504                     if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(textToSearch, filterTerms))
1505                         return false;
1506                 }
1507             }
1509             return true;
1510         },
1512         removeRow: function(rowId) {
1513             this.selectedRows.erase(rowId);
1514             this.rows.delete(rowId);
1515             const tr = this.getTrByRowId(rowId);
1516             tr?.destroy();
1517         },
1519         clear: function() {
1520             this.deselectAll();
1521             this.rows.clear();
1522             const trs = this.tableBody.getElements("tr");
1523             while (trs.length > 0)
1524                 trs.pop().destroy();
1525         },
1527         getRowIds: function() {
1528             return this.rows.keys();
1529         },
1531         getFilteredTorrentsNumber: function(filterName, categoryHash, tagHash, trackerHash) {
1532             let cnt = 0;
1534             for (const row of this.rows.values()) {
1535                 if (this.applyFilter(row, filterName, categoryHash, tagHash, trackerHash, null))
1536                     ++cnt;
1537             }
1538             return cnt;
1539         },
1541         getFilteredTorrentsHashes: function(filterName, categoryHash, tagHash, trackerHash) {
1542             const rowsHashes = [];
1544             for (const row of this.rows.values()) {
1545                 if (this.applyFilter(row, filterName, categoryHash, tagHash, trackerHash, null))
1546                     rowsHashes.push(row["rowId"]);
1547             }
1549             return rowsHashes;
1550         },
1552         getFilteredAndSortedRows: function() {
1553             const filteredRows = [];
1555             const useRegex = $("torrentsFilterRegexBox").checked;
1556             const filterText = $("torrentsFilterInput").value.trim().toLowerCase();
1557             let filterTerms;
1558             try {
1559                 filterTerms = (filterText.length > 0)
1560                     ? (useRegex ? new RegExp(filterText) : filterText.split(" "))
1561                     : null;
1562             }
1563             catch (e) { // SyntaxError: Invalid regex pattern
1564                 return filteredRows;
1565             }
1567             for (const row of this.rows.values()) {
1568                 if (this.applyFilter(row, selectedStatus, selectedCategory, selectedTag, selectedTracker, filterTerms)) {
1569                     filteredRows.push(row);
1570                     filteredRows[row.rowId] = row;
1571                 }
1572             }
1574             filteredRows.sort((row1, row2) => {
1575                 const column = this.columns[this.sortedColumn];
1576                 const res = column.compareRows(row1, row2);
1577                 if (this.reverseSort === "0")
1578                     return res;
1579                 else
1580                     return -res;
1581             });
1582             return filteredRows;
1583         },
1585         setupTr: function(tr) {
1586             tr.addEventListener("dblclick", function(e) {
1587                 e.preventDefault();
1588                 e.stopPropagation();
1590                 this._this.deselectAll();
1591                 this._this.selectRow(this.rowId);
1592                 const row = this._this.rows.get(this.rowId);
1593                 const state = row["full_data"].state;
1595                 const prefKey =
1596                     (state !== "uploading")
1597                     && (state !== "stoppedUP")
1598                     && (state !== "forcedUP")
1599                     && (state !== "stalledUP")
1600                     && (state !== "queuedUP")
1601                     && (state !== "checkingUP")
1602                     ? "dblclick_download"
1603                     : "dblclick_complete";
1605                 if (LocalPreferences.get(prefKey, "1") !== "1")
1606                     return true;
1608                 if (state.includes("stopped"))
1609                     startFN();
1610                 else
1611                     stopFN();
1612                 return true;
1613             });
1614             tr.addClass("torrentsTableContextMenuTarget");
1615         },
1617         getCurrentTorrentID: function() {
1618             return this.getSelectedRowId();
1619         },
1621         onSelectedRowChanged: function() {
1622             updatePropertiesPanel();
1623         }
1624     });
1626     const TorrentPeersTable = new Class({
1627         Extends: DynamicTable,
1629         initColumns: function() {
1630             this.newColumn("country", "", "QBT_TR(Country/Region)QBT_TR[CONTEXT=PeerListWidget]", 22, true);
1631             this.newColumn("ip", "", "QBT_TR(IP)QBT_TR[CONTEXT=PeerListWidget]", 80, true);
1632             this.newColumn("port", "", "QBT_TR(Port)QBT_TR[CONTEXT=PeerListWidget]", 35, true);
1633             this.newColumn("connection", "", "QBT_TR(Connection)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1634             this.newColumn("flags", "", "QBT_TR(Flags)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1635             this.newColumn("client", "", "QBT_TR(Client)QBT_TR[CONTEXT=PeerListWidget]", 140, true);
1636             this.newColumn("peer_id_client", "", "QBT_TR(Peer ID Client)QBT_TR[CONTEXT=PeerListWidget]", 60, false);
1637             this.newColumn("progress", "", "QBT_TR(Progress)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1638             this.newColumn("dl_speed", "", "QBT_TR(Down Speed)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1639             this.newColumn("up_speed", "", "QBT_TR(Up Speed)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1640             this.newColumn("downloaded", "", "QBT_TR(Downloaded)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1641             this.newColumn("uploaded", "", "QBT_TR(Uploaded)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1642             this.newColumn("relevance", "", "QBT_TR(Relevance)QBT_TR[CONTEXT=PeerListWidget]", 30, true);
1643             this.newColumn("files", "", "QBT_TR(Files)QBT_TR[CONTEXT=PeerListWidget]", 100, true);
1645             this.columns["country"].dataProperties.push("country_code");
1646             this.columns["flags"].dataProperties.push("flags_desc");
1647             this.initColumnsFunctions();
1648         },
1650         initColumnsFunctions: function() {
1652             // country
1653             this.columns["country"].updateTd = function(td, row) {
1654                 const country = this.getRowValue(row, 0);
1655                 const country_code = this.getRowValue(row, 1);
1657                 let span = td.firstElementChild;
1658                 if (span === null) {
1659                     span = document.createElement("span");
1660                     span.classList.add("flags");
1661                     td.append(span);
1662                 }
1664                 span.style.backgroundImage = `url('images/flags/${country_code ?? "xx"}.svg')`;
1665                 span.textContent = country;
1666                 td.title = country;
1667             };
1669             // ip
1670             this.columns["ip"].compareRows = function(row1, row2) {
1671                 const ip1 = this.getRowValue(row1);
1672                 const ip2 = this.getRowValue(row2);
1674                 const a = ip1.split(".");
1675                 const b = ip2.split(".");
1677                 for (let i = 0; i < 4; ++i) {
1678                     if (a[i] !== b[i])
1679                         return a[i] - b[i];
1680                 }
1682                 return 0;
1683             };
1685             // flags
1686             this.columns["flags"].updateTd = function(td, row) {
1687                 td.textContent = this.getRowValue(row, 0);
1688                 td.title = this.getRowValue(row, 1);
1689             };
1691             // progress
1692             this.columns["progress"].updateTd = function(td, row) {
1693                 const progress = this.getRowValue(row);
1694                 let progressFormatted = (progress * 100).round(1);
1695                 if ((progressFormatted === 100.0) && (progress !== 1.0))
1696                     progressFormatted = 99.9;
1697                 progressFormatted += "%";
1698                 td.textContent = progressFormatted;
1699                 td.title = progressFormatted;
1700             };
1702             // dl_speed, up_speed
1703             this.columns["dl_speed"].updateTd = function(td, row) {
1704                 const speed = this.getRowValue(row);
1705                 if (speed === 0) {
1706                     td.textContent = "";
1707                     td.title = "";
1708                 }
1709                 else {
1710                     const formattedSpeed = window.qBittorrent.Misc.friendlyUnit(speed, true);
1711                     td.textContent = formattedSpeed;
1712                     td.title = formattedSpeed;
1713                 }
1714             };
1715             this.columns["up_speed"].updateTd = this.columns["dl_speed"].updateTd;
1717             // downloaded, uploaded
1718             this.columns["downloaded"].updateTd = function(td, row) {
1719                 const downloaded = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false);
1720                 td.textContent = downloaded;
1721                 td.title = downloaded;
1722             };
1723             this.columns["uploaded"].updateTd = this.columns["downloaded"].updateTd;
1725             // relevance
1726             this.columns["relevance"].updateTd = this.columns["progress"].updateTd;
1728             // files
1729             this.columns["files"].updateTd = function(td, row) {
1730                 const value = this.getRowValue(row, 0);
1731                 td.textContent = value.replace(/\n/g, ";");
1732                 td.title = value;
1733             };
1735         }
1736     });
1738     const SearchResultsTable = new Class({
1739         Extends: DynamicTable,
1741         initColumns: function() {
1742             this.newColumn("fileName", "", "QBT_TR(Name)QBT_TR[CONTEXT=SearchResultsTable]", 500, true);
1743             this.newColumn("fileSize", "", "QBT_TR(Size)QBT_TR[CONTEXT=SearchResultsTable]", 100, true);
1744             this.newColumn("nbSeeders", "", "QBT_TR(Seeders)QBT_TR[CONTEXT=SearchResultsTable]", 100, true);
1745             this.newColumn("nbLeechers", "", "QBT_TR(Leechers)QBT_TR[CONTEXT=SearchResultsTable]", 100, true);
1746             this.newColumn("siteUrl", "", "QBT_TR(Search engine)QBT_TR[CONTEXT=SearchResultsTable]", 250, true);
1747             this.newColumn("pubDate", "", "QBT_TR(Published On)QBT_TR[CONTEXT=SearchResultsTable]", 200, true);
1749             this.initColumnsFunctions();
1750         },
1752         initColumnsFunctions: function() {
1753             const displaySize = function(td, row) {
1754                 const size = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false);
1755                 td.textContent = size;
1756                 td.title = size;
1757             };
1758             const displayNum = function(td, row) {
1759                 const value = this.getRowValue(row);
1760                 const formattedValue = (value === "-1") ? "Unknown" : value;
1761                 td.textContent = formattedValue;
1762                 td.title = formattedValue;
1763             };
1764             const displayDate = function(td, row) {
1765                 const value = this.getRowValue(row) * 1000;
1766                 const formattedValue = (isNaN(value) || (value <= 0)) ? "" : (new Date(value).toLocaleString());
1767                 td.textContent = formattedValue;
1768                 td.title = formattedValue;
1769             };
1771             this.columns["fileSize"].updateTd = displaySize;
1772             this.columns["nbSeeders"].updateTd = displayNum;
1773             this.columns["nbLeechers"].updateTd = displayNum;
1774             this.columns["pubDate"].updateTd = displayDate;
1775         },
1777         getFilteredAndSortedRows: function() {
1778             const getSizeFilters = function() {
1779                 let minSize = (window.qBittorrent.Search.searchSizeFilter.min > 0.00) ? (window.qBittorrent.Search.searchSizeFilter.min * Math.pow(1024, window.qBittorrent.Search.searchSizeFilter.minUnit)) : 0.00;
1780                 let maxSize = (window.qBittorrent.Search.searchSizeFilter.max > 0.00) ? (window.qBittorrent.Search.searchSizeFilter.max * Math.pow(1024, window.qBittorrent.Search.searchSizeFilter.maxUnit)) : 0.00;
1782                 if ((minSize > maxSize) && (maxSize > 0.00)) {
1783                     const tmp = minSize;
1784                     minSize = maxSize;
1785                     maxSize = tmp;
1786                 }
1788                 return {
1789                     min: minSize,
1790                     max: maxSize
1791                 };
1792             };
1794             const getSeedsFilters = function() {
1795                 let minSeeds = (window.qBittorrent.Search.searchSeedsFilter.min > 0) ? window.qBittorrent.Search.searchSeedsFilter.min : 0;
1796                 let maxSeeds = (window.qBittorrent.Search.searchSeedsFilter.max > 0) ? window.qBittorrent.Search.searchSeedsFilter.max : 0;
1798                 if ((minSeeds > maxSeeds) && (maxSeeds > 0)) {
1799                     const tmp = minSeeds;
1800                     minSeeds = maxSeeds;
1801                     maxSeeds = tmp;
1802                 }
1804                 return {
1805                     min: minSeeds,
1806                     max: maxSeeds
1807                 };
1808             };
1810             let filteredRows = [];
1811             const rows = this.rows.getValues();
1812             const searchTerms = window.qBittorrent.Search.searchText.pattern.toLowerCase().split(" ");
1813             const filterTerms = window.qBittorrent.Search.searchText.filterPattern.toLowerCase().split(" ");
1814             const sizeFilters = getSizeFilters();
1815             const seedsFilters = getSeedsFilters();
1816             const searchInTorrentName = $("searchInTorrentName").value === "names";
1818             if (searchInTorrentName || (filterTerms.length > 0) || (window.qBittorrent.Search.searchSizeFilter.min > 0.00) || (window.qBittorrent.Search.searchSizeFilter.max > 0.00)) {
1819                 for (let i = 0; i < rows.length; ++i) {
1820                     const row = rows[i];
1822                     if (searchInTorrentName && !window.qBittorrent.Misc.containsAllTerms(row.full_data.fileName, searchTerms))
1823                         continue;
1824                     if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(row.full_data.fileName, filterTerms))
1825                         continue;
1826                     if ((sizeFilters.min > 0.00) && (row.full_data.fileSize < sizeFilters.min))
1827                         continue;
1828                     if ((sizeFilters.max > 0.00) && (row.full_data.fileSize > sizeFilters.max))
1829                         continue;
1830                     if ((seedsFilters.min > 0) && (row.full_data.nbSeeders < seedsFilters.min))
1831                         continue;
1832                     if ((seedsFilters.max > 0) && (row.full_data.nbSeeders > seedsFilters.max))
1833                         continue;
1835                     filteredRows.push(row);
1836                 }
1837             }
1838             else {
1839                 filteredRows = rows;
1840             }
1842             filteredRows.sort((row1, row2) => {
1843                 const column = this.columns[this.sortedColumn];
1844                 const res = column.compareRows(row1, row2);
1845                 if (this.reverseSort === "0")
1846                     return res;
1847                 else
1848                     return -res;
1849             });
1851             return filteredRows;
1852         },
1854         setupTr: function(tr) {
1855             tr.addClass("searchTableRow");
1856         }
1857     });
1859     const SearchPluginsTable = new Class({
1860         Extends: DynamicTable,
1862         initColumns: function() {
1863             this.newColumn("fullName", "", "QBT_TR(Name)QBT_TR[CONTEXT=SearchPluginsTable]", 175, true);
1864             this.newColumn("version", "", "QBT_TR(Version)QBT_TR[CONTEXT=SearchPluginsTable]", 100, true);
1865             this.newColumn("url", "", "QBT_TR(Url)QBT_TR[CONTEXT=SearchPluginsTable]", 175, true);
1866             this.newColumn("enabled", "", "QBT_TR(Enabled)QBT_TR[CONTEXT=SearchPluginsTable]", 100, true);
1868             this.initColumnsFunctions();
1869         },
1871         initColumnsFunctions: function() {
1872             this.columns["enabled"].updateTd = function(td, row) {
1873                 const value = this.getRowValue(row);
1874                 if (value) {
1875                     td.textContent = "QBT_TR(Yes)QBT_TR[CONTEXT=SearchPluginsTable]";
1876                     td.title = "QBT_TR(Yes)QBT_TR[CONTEXT=SearchPluginsTable]";
1877                     td.getParent("tr").addClass("green");
1878                     td.getParent("tr").removeClass("red");
1879                 }
1880                 else {
1881                     td.textContent = "QBT_TR(No)QBT_TR[CONTEXT=SearchPluginsTable]";
1882                     td.title = "QBT_TR(No)QBT_TR[CONTEXT=SearchPluginsTable]";
1883                     td.getParent("tr").addClass("red");
1884                     td.getParent("tr").removeClass("green");
1885                 }
1886             };
1887         },
1889         setupTr: function(tr) {
1890             tr.addClass("searchPluginsTableRow");
1891         }
1892     });
1894     const TorrentTrackersTable = new Class({
1895         Extends: DynamicTable,
1897         initColumns: function() {
1898             this.newColumn("tier", "", "QBT_TR(Tier)QBT_TR[CONTEXT=TrackerListWidget]", 35, true);
1899             this.newColumn("url", "", "QBT_TR(URL)QBT_TR[CONTEXT=TrackerListWidget]", 250, true);
1900             this.newColumn("status", "", "QBT_TR(Status)QBT_TR[CONTEXT=TrackerListWidget]", 125, true);
1901             this.newColumn("peers", "", "QBT_TR(Peers)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
1902             this.newColumn("seeds", "", "QBT_TR(Seeds)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
1903             this.newColumn("leeches", "", "QBT_TR(Leeches)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
1904             this.newColumn("downloaded", "", "QBT_TR(Times Downloaded)QBT_TR[CONTEXT=TrackerListWidget]", 100, true);
1905             this.newColumn("message", "", "QBT_TR(Message)QBT_TR[CONTEXT=TrackerListWidget]", 250, true);
1906         },
1907     });
1909     const BulkRenameTorrentFilesTable = new Class({
1910         Extends: DynamicTable,
1912         filterTerms: [],
1913         prevFilterTerms: [],
1914         prevRowsString: null,
1915         prevFilteredRows: [],
1916         prevSortedColumn: null,
1917         prevReverseSort: null,
1918         fileTree: new window.qBittorrent.FileTree.FileTree(),
1920         populateTable: function(root) {
1921             this.fileTree.setRoot(root);
1922             root.children.each((node) => {
1923                 this._addNodeToTable(node, 0);
1924             });
1925         },
1927         _addNodeToTable: function(node, depth) {
1928             node.depth = depth;
1930             if (node.isFolder) {
1931                 const data = {
1932                     rowId: node.rowId,
1933                     fileId: -1,
1934                     checked: node.checked,
1935                     path: node.path,
1936                     original: node.original,
1937                     renamed: node.renamed
1938                 };
1940                 node.data = data;
1941                 node.full_data = data;
1942                 this.updateRowData(data);
1943             }
1944             else {
1945                 node.data.rowId = node.rowId;
1946                 node.full_data = node.data;
1947                 this.updateRowData(node.data);
1948             }
1950             node.children.each((child) => {
1951                 this._addNodeToTable(child, depth + 1);
1952             });
1953         },
1955         getRoot: function() {
1956             return this.fileTree.getRoot();
1957         },
1959         getNode: function(rowId) {
1960             return this.fileTree.getNode(rowId);
1961         },
1963         getRow: function(node) {
1964             const rowId = this.fileTree.getRowId(node);
1965             return this.rows.get(rowId);
1966         },
1968         getSelectedRows: function() {
1969             const nodes = this.fileTree.toArray();
1971             return nodes.filter(x => x.checked === 0);
1972         },
1974         initColumns: function() {
1975             // Blocks saving header width (because window width isn't saved)
1976             LocalPreferences.remove("column_" + "checked" + "_width_" + this.dynamicTableDivId);
1977             LocalPreferences.remove("column_" + "original" + "_width_" + this.dynamicTableDivId);
1978             LocalPreferences.remove("column_" + "renamed" + "_width_" + this.dynamicTableDivId);
1979             this.newColumn("checked", "", "", 50, true);
1980             this.newColumn("original", "", "QBT_TR(Original)QBT_TR[CONTEXT=TrackerListWidget]", 270, true);
1981             this.newColumn("renamed", "", "QBT_TR(Renamed)QBT_TR[CONTEXT=TrackerListWidget]", 220, true);
1983             this.initColumnsFunctions();
1984         },
1986         /**
1987          * Toggles the global checkbox and all checkboxes underneath
1988          */
1989         toggleGlobalCheckbox: function() {
1990             const checkbox = $("rootMultiRename_cb");
1991             const checkboxes = $$("input.RenamingCB");
1993             for (let i = 0; i < checkboxes.length; ++i) {
1994                 const node = this.getNode(i);
1996                 if (checkbox.checked || checkbox.indeterminate) {
1997                     const cb = checkboxes[i];
1998                     cb.checked = true;
1999                     cb.indeterminate = false;
2000                     cb.state = "checked";
2001                     node.checked = 0;
2002                     node.full_data.checked = node.checked;
2003                 }
2004                 else {
2005                     const cb = checkboxes[i];
2006                     cb.checked = false;
2007                     cb.indeterminate = false;
2008                     cb.state = "unchecked";
2009                     node.checked = 1;
2010                     node.full_data.checked = node.checked;
2011                 }
2012             }
2014             this.updateGlobalCheckbox();
2015         },
2017         toggleNodeTreeCheckbox: function(rowId, checkState) {
2018             const node = this.getNode(rowId);
2019             node.checked = checkState;
2020             node.full_data.checked = checkState;
2021             const checkbox = $(`cbRename${rowId}`);
2022             checkbox.checked = node.checked === 0;
2023             checkbox.state = checkbox.checked ? "checked" : "unchecked";
2025             for (let i = 0; i < node.children.length; ++i)
2026                 this.toggleNodeTreeCheckbox(node.children[i].rowId, checkState);
2027         },
2029         updateGlobalCheckbox: function() {
2030             const checkbox = $("rootMultiRename_cb");
2031             const checkboxes = $$("input.RenamingCB");
2032             const isAllChecked = function() {
2033                 for (let i = 0; i < checkboxes.length; ++i) {
2034                     if (!checkboxes[i].checked)
2035                         return false;
2036                 }
2037                 return true;
2038             };
2039             const isAllUnchecked = function() {
2040                 for (let i = 0; i < checkboxes.length; ++i) {
2041                     if (checkboxes[i].checked)
2042                         return false;
2043                 }
2044                 return true;
2045             };
2046             if (isAllChecked()) {
2047                 checkbox.state = "checked";
2048                 checkbox.indeterminate = false;
2049                 checkbox.checked = true;
2050             }
2051             else if (isAllUnchecked()) {
2052                 checkbox.state = "unchecked";
2053                 checkbox.indeterminate = false;
2054                 checkbox.checked = false;
2055             }
2056             else {
2057                 checkbox.state = "partial";
2058                 checkbox.indeterminate = true;
2059                 checkbox.checked = false;
2060             }
2061         },
2063         initColumnsFunctions: function() {
2064             const that = this;
2066             // checked
2067             this.columns["checked"].updateTd = function(td, row) {
2068                 const id = row.rowId;
2069                 const value = this.getRowValue(row);
2071                 const treeImg = new Element("img", {
2072                     src: "images/L.gif",
2073                     styles: {
2074                         "margin-bottom": -2
2075                     }
2076                 });
2077                 const checkbox = new Element("input");
2078                 checkbox.type = "checkbox";
2079                 checkbox.id = "cbRename" + id;
2080                 checkbox.setAttribute("data-id", id);
2081                 checkbox.className = "RenamingCB";
2082                 checkbox.addEventListener("click", (e) => {
2083                     const node = that.getNode(id);
2084                     node.checked = e.target.checked ? 0 : 1;
2085                     node.full_data.checked = node.checked;
2086                     that.updateGlobalCheckbox();
2087                     that.onRowSelectionChange(node);
2088                     e.stopPropagation();
2089                 });
2090                 checkbox.checked = (value === 0);
2091                 checkbox.state = checkbox.checked ? "checked" : "unchecked";
2092                 checkbox.indeterminate = false;
2093                 td.adopt(treeImg, checkbox);
2094             };
2096             // original
2097             this.columns["original"].updateTd = function(td, row) {
2098                 const id = row.rowId;
2099                 const fileNameId = "filesTablefileName" + id;
2100                 const node = that.getNode(id);
2102                 if (node.isFolder) {
2103                     const value = this.getRowValue(row);
2104                     const dirImgId = "renameTableDirImg" + id;
2105                     if ($(dirImgId)) {
2106                         // just update file name
2107                         $(fileNameId).textContent = value;
2108                     }
2109                     else {
2110                         const span = new Element("span", {
2111                             text: value,
2112                             id: fileNameId
2113                         });
2114                         const dirImg = new Element("img", {
2115                             src: "images/directory.svg",
2116                             styles: {
2117                                 "width": 15,
2118                                 "padding-right": 5,
2119                                 "margin-bottom": -3,
2120                                 "margin-left": (node.depth * 20)
2121                             },
2122                             id: dirImgId
2123                         });
2124                         td.replaceChildren(dirImg, span);
2125                     }
2126                 }
2127                 else { // is file
2128                     const value = this.getRowValue(row);
2129                     const span = new Element("span", {
2130                         text: value,
2131                         id: fileNameId,
2132                         styles: {
2133                             "margin-left": ((node.depth + 1) * 20)
2134                         }
2135                     });
2136                     td.replaceChildren(span);
2137                 }
2138             };
2140             // renamed
2141             this.columns["renamed"].updateTd = function(td, row) {
2142                 const id = row.rowId;
2143                 const fileNameRenamedId = "filesTablefileRenamed" + id;
2144                 const value = this.getRowValue(row);
2146                 const span = new Element("span", {
2147                     text: value,
2148                     id: fileNameRenamedId,
2149                 });
2150                 td.replaceChildren(span);
2151             };
2152         },
2154         onRowSelectionChange: function(row) {},
2156         selectRow: function() {
2157             return;
2158         },
2160         reselectRows: function(rowIds) {
2161             const that = this;
2162             this.deselectAll();
2163             this.tableBody.getElements("tr").each((tr) => {
2164                 if (rowIds.includes(tr.rowId)) {
2165                     const node = that.getNode(tr.rowId);
2166                     node.checked = 0;
2167                     node.full_data.checked = 0;
2169                     const checkbox = tr.children[0].getElement("input");
2170                     checkbox.state = "checked";
2171                     checkbox.indeterminate = false;
2172                     checkbox.checked = true;
2173                 }
2174             });
2176             this.updateGlobalCheckbox();
2177         },
2179         _sortNodesByColumn: function(nodes, column) {
2180             nodes.sort((row1, row2) => {
2181                 // list folders before files when sorting by name
2182                 if (column.name === "original") {
2183                     const node1 = this.getNode(row1.data.rowId);
2184                     const node2 = this.getNode(row2.data.rowId);
2185                     if (node1.isFolder && !node2.isFolder)
2186                         return -1;
2187                     if (node2.isFolder && !node1.isFolder)
2188                         return 1;
2189                 }
2191                 const res = column.compareRows(row1, row2);
2192                 return (this.reverseSort === "0") ? res : -res;
2193             });
2195             nodes.each((node) => {
2196                 if (node.children.length > 0)
2197                     this._sortNodesByColumn(node.children, column);
2198             });
2199         },
2201         _filterNodes: function(node, filterTerms, filteredRows) {
2202             if (node.isFolder) {
2203                 const childAdded = node.children.reduce((acc, child) => {
2204                     // we must execute the function before ORing w/ acc or we'll stop checking child nodes after the first successful match
2205                     return (this._filterNodes(child, filterTerms, filteredRows) || acc);
2206                 }, false);
2208                 if (childAdded) {
2209                     const row = this.getRow(node);
2210                     filteredRows.push(row);
2211                     return true;
2212                 }
2213             }
2215             if (window.qBittorrent.Misc.containsAllTerms(node.original, filterTerms)) {
2216                 const row = this.getRow(node);
2217                 filteredRows.push(row);
2218                 return true;
2219             }
2221             return false;
2222         },
2224         setFilter: function(text) {
2225             const filterTerms = text.trim().toLowerCase().split(" ");
2226             if ((filterTerms.length === 1) && (filterTerms[0] === ""))
2227                 this.filterTerms = [];
2228             else
2229                 this.filterTerms = filterTerms;
2230         },
2232         getFilteredAndSortedRows: function() {
2233             if (this.getRoot() === null)
2234                 return [];
2236             const generateRowsSignature = function(rows) {
2237                 const rowsData = rows.map((row) => {
2238                     return row.full_data;
2239                 });
2240                 return JSON.stringify(rowsData);
2241             };
2243             const getFilteredRows = function() {
2244                 if (this.filterTerms.length === 0) {
2245                     const nodeArray = this.fileTree.toArray();
2246                     const filteredRows = nodeArray.map((node) => {
2247                         return this.getRow(node);
2248                     });
2249                     return filteredRows;
2250                 }
2252                 const filteredRows = [];
2253                 this.getRoot().children.each((child) => {
2254                     this._filterNodes(child, this.filterTerms, filteredRows);
2255                 });
2256                 filteredRows.reverse();
2257                 return filteredRows;
2258             }.bind(this);
2260             const hasRowsChanged = function(rowsString, prevRowsStringString) {
2261                 const rowsChanged = (rowsString !== prevRowsStringString);
2262                 const isFilterTermsChanged = this.filterTerms.reduce((acc, term, index) => {
2263                     return (acc || (term !== this.prevFilterTerms[index]));
2264                 }, false);
2265                 const isFilterChanged = ((this.filterTerms.length !== this.prevFilterTerms.length)
2266                     || ((this.filterTerms.length > 0) && isFilterTermsChanged));
2267                 const isSortedColumnChanged = (this.prevSortedColumn !== this.sortedColumn);
2268                 const isReverseSortChanged = (this.prevReverseSort !== this.reverseSort);
2270                 return (rowsChanged || isFilterChanged || isSortedColumnChanged || isReverseSortChanged);
2271             }.bind(this);
2273             const rowsString = generateRowsSignature(this.rows);
2274             if (!hasRowsChanged(rowsString, this.prevRowsString))
2275                 return this.prevFilteredRows;
2277             // sort, then filter
2278             const column = this.columns[this.sortedColumn];
2279             this._sortNodesByColumn(this.getRoot().children, column);
2280             const filteredRows = getFilteredRows();
2282             this.prevFilterTerms = this.filterTerms;
2283             this.prevRowsString = rowsString;
2284             this.prevFilteredRows = filteredRows;
2285             this.prevSortedColumn = this.sortedColumn;
2286             this.prevReverseSort = this.reverseSort;
2287             return filteredRows;
2288         },
2290         setIgnored: function(rowId, ignore) {
2291             const row = this.rows.get(rowId);
2292             if (ignore)
2293                 row.full_data.remaining = 0;
2294             else
2295                 row.full_data.remaining = (row.full_data.size * (1.0 - (row.full_data.progress / 100)));
2296         },
2298         setupTr: function(tr) {
2299             tr.addEventListener("keydown", function(event) {
2300                 switch (event.key) {
2301                     case "left":
2302                         qBittorrent.PropFiles.collapseFolder(this._this.getSelectedRowId());
2303                         return false;
2304                     case "right":
2305                         qBittorrent.PropFiles.expandFolder(this._this.getSelectedRowId());
2306                         return false;
2307                 }
2308             });
2309         }
2310     });
2312     const TorrentFilesTable = new Class({
2313         Extends: DynamicTable,
2315         filterTerms: [],
2316         prevFilterTerms: [],
2317         prevRowsString: null,
2318         prevFilteredRows: [],
2319         prevSortedColumn: null,
2320         prevReverseSort: null,
2321         fileTree: new window.qBittorrent.FileTree.FileTree(),
2323         populateTable: function(root) {
2324             this.fileTree.setRoot(root);
2325             root.children.each((node) => {
2326                 this._addNodeToTable(node, 0);
2327             });
2328         },
2330         _addNodeToTable: function(node, depth) {
2331             node.depth = depth;
2333             if (node.isFolder) {
2334                 const data = {
2335                     rowId: node.rowId,
2336                     size: node.size,
2337                     checked: node.checked,
2338                     remaining: node.remaining,
2339                     progress: node.progress,
2340                     priority: window.qBittorrent.PropFiles.normalizePriority(node.priority),
2341                     availability: node.availability,
2342                     fileId: -1,
2343                     name: node.name
2344                 };
2346                 node.data = data;
2347                 node.full_data = data;
2348                 this.updateRowData(data);
2349             }
2350             else {
2351                 node.data.rowId = node.rowId;
2352                 node.full_data = node.data;
2353                 this.updateRowData(node.data);
2354             }
2356             node.children.each((child) => {
2357                 this._addNodeToTable(child, depth + 1);
2358             });
2359         },
2361         getRoot: function() {
2362             return this.fileTree.getRoot();
2363         },
2365         getNode: function(rowId) {
2366             return this.fileTree.getNode(rowId);
2367         },
2369         getRow: function(node) {
2370             const rowId = this.fileTree.getRowId(node);
2371             return this.rows.get(rowId);
2372         },
2374         initColumns: function() {
2375             this.newColumn("checked", "", "", 50, true);
2376             this.newColumn("name", "", "QBT_TR(Name)QBT_TR[CONTEXT=TrackerListWidget]", 300, true);
2377             this.newColumn("size", "", "QBT_TR(Total Size)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
2378             this.newColumn("progress", "", "QBT_TR(Progress)QBT_TR[CONTEXT=TrackerListWidget]", 100, true);
2379             this.newColumn("priority", "", "QBT_TR(Download Priority)QBT_TR[CONTEXT=TrackerListWidget]", 150, true);
2380             this.newColumn("remaining", "", "QBT_TR(Remaining)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
2381             this.newColumn("availability", "", "QBT_TR(Availability)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
2383             this.initColumnsFunctions();
2384         },
2386         initColumnsFunctions: function() {
2387             const that = this;
2388             const displaySize = function(td, row) {
2389                 const size = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false);
2390                 td.textContent = size;
2391                 td.title = size;
2392             };
2393             const displayPercentage = function(td, row) {
2394                 const value = window.qBittorrent.Misc.friendlyPercentage(this.getRowValue(row));
2395                 td.textContent = value;
2396                 td.title = value;
2397             };
2399             // checked
2400             this.columns["checked"].updateTd = function(td, row) {
2401                 const id = row.rowId;
2402                 const value = this.getRowValue(row);
2404                 if (window.qBittorrent.PropFiles.isDownloadCheckboxExists(id)) {
2405                     window.qBittorrent.PropFiles.updateDownloadCheckbox(id, value);
2406                 }
2407                 else {
2408                     const treeImg = new Element("img", {
2409                         src: "images/L.gif",
2410                         styles: {
2411                             "margin-bottom": -2
2412                         }
2413                     });
2414                     td.adopt(treeImg, window.qBittorrent.PropFiles.createDownloadCheckbox(id, row.full_data.fileId, value));
2415                 }
2416             };
2418             // name
2419             this.columns["name"].updateTd = function(td, row) {
2420                 const id = row.rowId;
2421                 const fileNameId = "filesTablefileName" + id;
2422                 const node = that.getNode(id);
2424                 if (node.isFolder) {
2425                     const value = this.getRowValue(row);
2426                     const collapseIconId = "filesTableCollapseIcon" + id;
2427                     const dirImgId = "filesTableDirImg" + id;
2428                     if ($(dirImgId)) {
2429                         // just update file name
2430                         $(fileNameId).textContent = value;
2431                     }
2432                     else {
2433                         const collapseIcon = new Element("img", {
2434                             src: "images/go-down.svg",
2435                             styles: {
2436                                 "margin-left": (node.depth * 20)
2437                             },
2438                             class: "filesTableCollapseIcon",
2439                             id: collapseIconId,
2440                             "data-id": id,
2441                             onclick: "qBittorrent.PropFiles.collapseIconClicked(this)"
2442                         });
2443                         const span = new Element("span", {
2444                             text: value,
2445                             id: fileNameId
2446                         });
2447                         const dirImg = new Element("img", {
2448                             src: "images/directory.svg",
2449                             styles: {
2450                                 "width": 15,
2451                                 "padding-right": 5,
2452                                 "margin-bottom": -3
2453                             },
2454                             id: dirImgId
2455                         });
2456                         td.replaceChildren(collapseIcon, dirImg, span);
2457                     }
2458                 }
2459                 else {
2460                     const value = this.getRowValue(row);
2461                     const span = new Element("span", {
2462                         text: value,
2463                         id: fileNameId,
2464                         styles: {
2465                             "margin-left": ((node.depth + 1) * 20)
2466                         }
2467                     });
2468                     td.replaceChildren(span);
2469                 }
2470             };
2472             // size
2473             this.columns["size"].updateTd = displaySize;
2475             // progress
2476             this.columns["progress"].updateTd = function(td, row) {
2477                 const id = row.rowId;
2478                 const value = this.getRowValue(row);
2480                 const progressBar = $("pbf_" + id);
2481                 if (progressBar === null) {
2482                     td.adopt(new window.qBittorrent.ProgressBar.ProgressBar(value.toFloat(), {
2483                         id: "pbf_" + id,
2484                         width: 80
2485                     }));
2486                 }
2487                 else {
2488                     progressBar.setValue(value.toFloat());
2489                 }
2490             };
2492             // priority
2493             this.columns["priority"].updateTd = function(td, row) {
2494                 const id = row.rowId;
2495                 const value = this.getRowValue(row);
2497                 if (window.qBittorrent.PropFiles.isPriorityComboExists(id))
2498                     window.qBittorrent.PropFiles.updatePriorityCombo(id, value);
2499                 else
2500                     td.adopt(window.qBittorrent.PropFiles.createPriorityCombo(id, row.full_data.fileId, value));
2501             };
2503             // remaining, availability
2504             this.columns["remaining"].updateTd = displaySize;
2505             this.columns["availability"].updateTd = displayPercentage;
2506         },
2508         _sortNodesByColumn: function(nodes, column) {
2509             nodes.sort((row1, row2) => {
2510                 // list folders before files when sorting by name
2511                 if (column.name === "name") {
2512                     const node1 = this.getNode(row1.data.rowId);
2513                     const node2 = this.getNode(row2.data.rowId);
2514                     if (node1.isFolder && !node2.isFolder)
2515                         return -1;
2516                     if (node2.isFolder && !node1.isFolder)
2517                         return 1;
2518                 }
2520                 const res = column.compareRows(row1, row2);
2521                 return (this.reverseSort === "0") ? res : -res;
2522             });
2524             nodes.each((node) => {
2525                 if (node.children.length > 0)
2526                     this._sortNodesByColumn(node.children, column);
2527             });
2528         },
2530         _filterNodes: function(node, filterTerms, filteredRows) {
2531             if (node.isFolder) {
2532                 const childAdded = node.children.reduce((acc, child) => {
2533                     // we must execute the function before ORing w/ acc or we'll stop checking child nodes after the first successful match
2534                     return (this._filterNodes(child, filterTerms, filteredRows) || acc);
2535                 }, false);
2537                 if (childAdded) {
2538                     const row = this.getRow(node);
2539                     filteredRows.push(row);
2540                     return true;
2541                 }
2542             }
2544             if (window.qBittorrent.Misc.containsAllTerms(node.name, filterTerms)) {
2545                 const row = this.getRow(node);
2546                 filteredRows.push(row);
2547                 return true;
2548             }
2550             return false;
2551         },
2553         setFilter: function(text) {
2554             const filterTerms = text.trim().toLowerCase().split(" ");
2555             if ((filterTerms.length === 1) && (filterTerms[0] === ""))
2556                 this.filterTerms = [];
2557             else
2558                 this.filterTerms = filterTerms;
2559         },
2561         getFilteredAndSortedRows: function() {
2562             if (this.getRoot() === null)
2563                 return [];
2565             const generateRowsSignature = function(rows) {
2566                 const rowsData = rows.map((row) => {
2567                     return row.full_data;
2568                 });
2569                 return JSON.stringify(rowsData);
2570             };
2572             const getFilteredRows = function() {
2573                 if (this.filterTerms.length === 0) {
2574                     const nodeArray = this.fileTree.toArray();
2575                     const filteredRows = nodeArray.map((node) => {
2576                         return this.getRow(node);
2577                     });
2578                     return filteredRows;
2579                 }
2581                 const filteredRows = [];
2582                 this.getRoot().children.each((child) => {
2583                     this._filterNodes(child, this.filterTerms, filteredRows);
2584                 });
2585                 filteredRows.reverse();
2586                 return filteredRows;
2587             }.bind(this);
2589             const hasRowsChanged = function(rowsString, prevRowsStringString) {
2590                 const rowsChanged = (rowsString !== prevRowsStringString);
2591                 const isFilterTermsChanged = this.filterTerms.reduce((acc, term, index) => {
2592                     return (acc || (term !== this.prevFilterTerms[index]));
2593                 }, false);
2594                 const isFilterChanged = ((this.filterTerms.length !== this.prevFilterTerms.length)
2595                     || ((this.filterTerms.length > 0) && isFilterTermsChanged));
2596                 const isSortedColumnChanged = (this.prevSortedColumn !== this.sortedColumn);
2597                 const isReverseSortChanged = (this.prevReverseSort !== this.reverseSort);
2599                 return (rowsChanged || isFilterChanged || isSortedColumnChanged || isReverseSortChanged);
2600             }.bind(this);
2602             const rowsString = generateRowsSignature(this.rows);
2603             if (!hasRowsChanged(rowsString, this.prevRowsString))
2604                 return this.prevFilteredRows;
2606             // sort, then filter
2607             const column = this.columns[this.sortedColumn];
2608             this._sortNodesByColumn(this.getRoot().children, column);
2609             const filteredRows = getFilteredRows();
2611             this.prevFilterTerms = this.filterTerms;
2612             this.prevRowsString = rowsString;
2613             this.prevFilteredRows = filteredRows;
2614             this.prevSortedColumn = this.sortedColumn;
2615             this.prevReverseSort = this.reverseSort;
2616             return filteredRows;
2617         },
2619         setIgnored: function(rowId, ignore) {
2620             const row = this.rows.get(rowId);
2621             if (ignore)
2622                 row.full_data.remaining = 0;
2623             else
2624                 row.full_data.remaining = (row.full_data.size * (1.0 - (row.full_data.progress / 100)));
2625         },
2627         setupTr: function(tr) {
2628             tr.addEventListener("keydown", function(event) {
2629                 switch (event.key) {
2630                     case "left":
2631                         qBittorrent.PropFiles.collapseFolder(this._this.getSelectedRowId());
2632                         return false;
2633                     case "right":
2634                         qBittorrent.PropFiles.expandFolder(this._this.getSelectedRowId());
2635                         return false;
2636                 }
2637             });
2638         }
2639     });
2641     const RssFeedTable = new Class({
2642         Extends: DynamicTable,
2643         initColumns: function() {
2644             this.newColumn("state_icon", "", "", 30, true);
2645             this.newColumn("name", "", "QBT_TR(RSS feeds)QBT_TR[CONTEXT=FeedListWidget]", -1, true);
2647             this.columns["state_icon"].dataProperties[0] = "";
2649             // map name row to "[name] ([unread])"
2650             this.columns["name"].dataProperties.push("unread");
2651             this.columns["name"].updateTd = function(td, row) {
2652                 const name = this.getRowValue(row, 0);
2653                 const unreadCount = this.getRowValue(row, 1);
2654                 const value = name + " (" + unreadCount + ")";
2655                 td.textContent = value;
2656                 td.title = value;
2657             };
2658         },
2659         setupHeaderMenu: function() {},
2660         setupHeaderEvents: function() {},
2661         getFilteredAndSortedRows: function() {
2662             return this.rows.getValues();
2663         },
2664         selectRow: function(rowId) {
2665             this.selectedRows.push(rowId);
2666             this.setRowClass();
2667             this.onSelectedRowChanged();
2669             const rows = this.rows.getValues();
2670             let path = "";
2671             for (let i = 0; i < rows.length; ++i) {
2672                 if (rows[i].rowId === rowId) {
2673                     path = rows[i].full_data.dataPath;
2674                     break;
2675                 }
2676             }
2677             window.qBittorrent.Rss.showRssFeed(path);
2678         },
2679         setupTr: function(tr) {
2680             tr.addEventListener("dblclick", function(e) {
2681                 if (this.rowId !== 0) {
2682                     window.qBittorrent.Rss.moveItem(this._this.rows.get(this.rowId).full_data.dataPath);
2683                     return true;
2684                 }
2685             });
2686         },
2687         updateRow: function(tr, fullUpdate) {
2688             const row = this.rows.get(tr.rowId);
2689             const data = row[fullUpdate ? "full_data" : "data"];
2691             const tds = tr.getElements("td");
2692             for (let i = 0; i < this.columns.length; ++i) {
2693                 if (Object.hasOwn(data, this.columns[i].dataProperties[0]))
2694                     this.columns[i].updateTd(tds[i], row);
2695             }
2696             row["data"] = {};
2697             tds[0].style.overflow = "visible";
2698             const indentation = row.full_data.indentation;
2699             tds[0].style.paddingLeft = (indentation * 32 + 4) + "px";
2700             tds[1].style.paddingLeft = (indentation * 32 + 4) + "px";
2701         },
2702         updateIcons: function() {
2703             // state_icon
2704             this.rows.each(row => {
2705                 let img_path;
2706                 switch (row.full_data.status) {
2707                     case "default":
2708                         img_path = "images/application-rss.svg";
2709                         break;
2710                     case "hasError":
2711                         img_path = "images/task-reject.svg";
2712                         break;
2713                     case "isLoading":
2714                         img_path = "images/spinner.gif";
2715                         break;
2716                     case "unread":
2717                         img_path = "images/mail-inbox.svg";
2718                         break;
2719                     case "isFolder":
2720                         img_path = "images/folder-documents.svg";
2721                         break;
2722                 }
2723                 let td;
2724                 for (let i = 0; i < this.tableBody.rows.length; ++i) {
2725                     if (this.tableBody.rows[i].rowId === row.rowId) {
2726                         td = this.tableBody.rows[i].children[0];
2727                         break;
2728                     }
2729                 }
2730                 if (td.getChildren("img").length > 0) {
2731                     const img = td.getChildren("img")[0];
2732                     if (!img.src.includes(img_path)) {
2733                         img.src = img_path;
2734                         img.title = status;
2735                     }
2736                 }
2737                 else {
2738                     td.adopt(new Element("img", {
2739                         "src": img_path,
2740                         "class": "stateIcon",
2741                         "height": "22px",
2742                         "width": "22px"
2743                     }));
2744                 }
2745             });
2746         },
2747         newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
2748             const column = {};
2749             column["name"] = name;
2750             column["title"] = name;
2751             column["visible"] = defaultVisible;
2752             column["force_hide"] = false;
2753             column["caption"] = caption;
2754             column["style"] = style;
2755             if (defaultWidth !== -1)
2756                 column["width"] = defaultWidth;
2758             column["dataProperties"] = [name];
2759             column["getRowValue"] = function(row, pos) {
2760                 if (pos === undefined)
2761                     pos = 0;
2762                 return row["full_data"][this.dataProperties[pos]];
2763             };
2764             column["compareRows"] = function(row1, row2) {
2765                 const value1 = this.getRowValue(row1);
2766                 const value2 = this.getRowValue(row2);
2767                 if ((typeof(value1) === "number") && (typeof(value2) === "number"))
2768                     return compareNumbers(value1, value2);
2769                 return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
2770             };
2771             column["updateTd"] = function(td, row) {
2772                 const value = this.getRowValue(row);
2773                 td.textContent = value;
2774                 td.title = value;
2775             };
2776             column["onResize"] = null;
2777             this.columns.push(column);
2778             this.columns[name] = column;
2780             this.hiddenTableHeader.appendChild(new Element("th"));
2781             this.fixedTableHeader.appendChild(new Element("th"));
2782         }
2783     });
2785     const RssArticleTable = new Class({
2786         Extends: DynamicTable,
2787         initColumns: function() {
2788             this.newColumn("name", "", "QBT_TR(Torrents: (double-click to download))QBT_TR[CONTEXT=RSSWidget]", -1, true);
2789         },
2790         setupHeaderMenu: function() {},
2791         setupHeaderEvents: function() {},
2792         getFilteredAndSortedRows: function() {
2793             return this.rows.getValues();
2794         },
2795         selectRow: function(rowId) {
2796             this.selectedRows.push(rowId);
2797             this.setRowClass();
2798             this.onSelectedRowChanged();
2800             const rows = this.rows.getValues();
2801             let articleId = "";
2802             let feedUid = "";
2803             for (let i = 0; i < rows.length; ++i) {
2804                 if (rows[i].rowId === rowId) {
2805                     articleId = rows[i].full_data.dataId;
2806                     feedUid = rows[i].full_data.feedUid;
2807                     this.tableBody.rows[rows[i].rowId].removeClass("unreadArticle");
2808                     break;
2809                 }
2810             }
2811             window.qBittorrent.Rss.showDetails(feedUid, articleId);
2812         },
2813         setupTr: function(tr) {
2814             tr.addEventListener("dblclick", function(e) {
2815                 showDownloadPage([this._this.rows.get(this.rowId).full_data.torrentURL]);
2816                 return true;
2817             });
2818             tr.addClass("torrentsTableContextMenuTarget");
2819         },
2820         updateRow: function(tr, fullUpdate) {
2821             const row = this.rows.get(tr.rowId);
2822             const data = row[fullUpdate ? "full_data" : "data"];
2823             if (!row.full_data.isRead)
2824                 tr.addClass("unreadArticle");
2825             else
2826                 tr.removeClass("unreadArticle");
2828             const tds = tr.getElements("td");
2829             for (let i = 0; i < this.columns.length; ++i) {
2830                 if (Object.hasOwn(data, this.columns[i].dataProperties[0]))
2831                     this.columns[i].updateTd(tds[i], row);
2832             }
2833             row["data"] = {};
2834         },
2835         newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
2836             const column = {};
2837             column["name"] = name;
2838             column["title"] = name;
2839             column["visible"] = defaultVisible;
2840             column["force_hide"] = false;
2841             column["caption"] = caption;
2842             column["style"] = style;
2843             if (defaultWidth !== -1)
2844                 column["width"] = defaultWidth;
2846             column["dataProperties"] = [name];
2847             column["getRowValue"] = function(row, pos) {
2848                 if (pos === undefined)
2849                     pos = 0;
2850                 return row["full_data"][this.dataProperties[pos]];
2851             };
2852             column["compareRows"] = function(row1, row2) {
2853                 const value1 = this.getRowValue(row1);
2854                 const value2 = this.getRowValue(row2);
2855                 if ((typeof(value1) === "number") && (typeof(value2) === "number"))
2856                     return compareNumbers(value1, value2);
2857                 return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
2858             };
2859             column["updateTd"] = function(td, row) {
2860                 const value = this.getRowValue(row);
2861                 td.textContent = value;
2862                 td.title = value;
2863             };
2864             column["onResize"] = null;
2865             this.columns.push(column);
2866             this.columns[name] = column;
2868             this.hiddenTableHeader.appendChild(new Element("th"));
2869             this.fixedTableHeader.appendChild(new Element("th"));
2870         }
2871     });
2873     const RssDownloaderRulesTable = new Class({
2874         Extends: DynamicTable,
2875         initColumns: function() {
2876             this.newColumn("checked", "", "", 30, true);
2877             this.newColumn("name", "", "", -1, true);
2879             this.columns["checked"].updateTd = function(td, row) {
2880                 if ($("cbRssDlRule" + row.rowId) === null) {
2881                     const checkbox = new Element("input");
2882                     checkbox.type = "checkbox";
2883                     checkbox.id = "cbRssDlRule" + row.rowId;
2884                     checkbox.checked = row.full_data.checked;
2886                     checkbox.addEventListener("click", function(e) {
2887                         window.qBittorrent.RssDownloader.rssDownloaderRulesTable.updateRowData({
2888                             rowId: row.rowId,
2889                             checked: this.checked
2890                         });
2891                         window.qBittorrent.RssDownloader.modifyRuleState(row.full_data.name, "enabled", this.checked);
2892                         e.stopPropagation();
2893                     });
2895                     td.append(checkbox);
2896                 }
2897                 else {
2898                     $("cbRssDlRule" + row.rowId).checked = row.full_data.checked;
2899                 }
2900             };
2901         },
2902         setupHeaderMenu: function() {},
2903         setupHeaderEvents: function() {},
2904         getFilteredAndSortedRows: function() {
2905             return this.rows.getValues();
2906         },
2907         setupTr: function(tr) {
2908             tr.addEventListener("dblclick", function(e) {
2909                 window.qBittorrent.RssDownloader.renameRule(this._this.rows.get(this.rowId).full_data.name);
2910                 return true;
2911             });
2912         },
2913         newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
2914             const column = {};
2915             column["name"] = name;
2916             column["title"] = name;
2917             column["visible"] = defaultVisible;
2918             column["force_hide"] = false;
2919             column["caption"] = caption;
2920             column["style"] = style;
2921             if (defaultWidth !== -1)
2922                 column["width"] = defaultWidth;
2924             column["dataProperties"] = [name];
2925             column["getRowValue"] = function(row, pos) {
2926                 if (pos === undefined)
2927                     pos = 0;
2928                 return row["full_data"][this.dataProperties[pos]];
2929             };
2930             column["compareRows"] = function(row1, row2) {
2931                 const value1 = this.getRowValue(row1);
2932                 const value2 = this.getRowValue(row2);
2933                 if ((typeof(value1) === "number") && (typeof(value2) === "number"))
2934                     return compareNumbers(value1, value2);
2935                 return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
2936             };
2937             column["updateTd"] = function(td, row) {
2938                 const value = this.getRowValue(row);
2939                 td.textContent = value;
2940                 td.title = value;
2941             };
2942             column["onResize"] = null;
2943             this.columns.push(column);
2944             this.columns[name] = column;
2946             this.hiddenTableHeader.appendChild(new Element("th"));
2947             this.fixedTableHeader.appendChild(new Element("th"));
2948         },
2949         selectRow: function(rowId) {
2950             this.selectedRows.push(rowId);
2951             this.setRowClass();
2952             this.onSelectedRowChanged();
2954             const rows = this.rows.getValues();
2955             let name = "";
2956             for (let i = 0; i < rows.length; ++i) {
2957                 if (rows[i].rowId === rowId) {
2958                     name = rows[i].full_data.name;
2959                     break;
2960                 }
2961             }
2962             window.qBittorrent.RssDownloader.showRule(name);
2963         }
2964     });
2966     const RssDownloaderFeedSelectionTable = new Class({
2967         Extends: DynamicTable,
2968         initColumns: function() {
2969             this.newColumn("checked", "", "", 30, true);
2970             this.newColumn("name", "", "", -1, true);
2972             this.columns["checked"].updateTd = function(td, row) {
2973                 if ($("cbRssDlFeed" + row.rowId) === null) {
2974                     const checkbox = new Element("input");
2975                     checkbox.type = "checkbox";
2976                     checkbox.id = "cbRssDlFeed" + row.rowId;
2977                     checkbox.checked = row.full_data.checked;
2979                     checkbox.addEventListener("click", function(e) {
2980                         window.qBittorrent.RssDownloader.rssDownloaderFeedSelectionTable.updateRowData({
2981                             rowId: row.rowId,
2982                             checked: this.checked
2983                         });
2984                         e.stopPropagation();
2985                     });
2987                     td.append(checkbox);
2988                 }
2989                 else {
2990                     $("cbRssDlFeed" + row.rowId).checked = row.full_data.checked;
2991                 }
2992             };
2993         },
2994         setupHeaderMenu: function() {},
2995         setupHeaderEvents: function() {},
2996         getFilteredAndSortedRows: function() {
2997             return this.rows.getValues();
2998         },
2999         newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
3000             const column = {};
3001             column["name"] = name;
3002             column["title"] = name;
3003             column["visible"] = defaultVisible;
3004             column["force_hide"] = false;
3005             column["caption"] = caption;
3006             column["style"] = style;
3007             if (defaultWidth !== -1)
3008                 column["width"] = defaultWidth;
3010             column["dataProperties"] = [name];
3011             column["getRowValue"] = function(row, pos) {
3012                 if (pos === undefined)
3013                     pos = 0;
3014                 return row["full_data"][this.dataProperties[pos]];
3015             };
3016             column["compareRows"] = function(row1, row2) {
3017                 const value1 = this.getRowValue(row1);
3018                 const value2 = this.getRowValue(row2);
3019                 if ((typeof(value1) === "number") && (typeof(value2) === "number"))
3020                     return compareNumbers(value1, value2);
3021                 return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
3022             };
3023             column["updateTd"] = function(td, row) {
3024                 const value = this.getRowValue(row);
3025                 td.textContent = value;
3026                 td.title = value;
3027             };
3028             column["onResize"] = null;
3029             this.columns.push(column);
3030             this.columns[name] = column;
3032             this.hiddenTableHeader.appendChild(new Element("th"));
3033             this.fixedTableHeader.appendChild(new Element("th"));
3034         },
3035         selectRow: function() {}
3036     });
3038     const RssDownloaderArticlesTable = new Class({
3039         Extends: DynamicTable,
3040         initColumns: function() {
3041             this.newColumn("name", "", "", -1, true);
3042         },
3043         setupHeaderMenu: function() {},
3044         setupHeaderEvents: function() {},
3045         getFilteredAndSortedRows: function() {
3046             return this.rows.getValues();
3047         },
3048         newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
3049             const column = {};
3050             column["name"] = name;
3051             column["title"] = name;
3052             column["visible"] = defaultVisible;
3053             column["force_hide"] = false;
3054             column["caption"] = caption;
3055             column["style"] = style;
3056             if (defaultWidth !== -1)
3057                 column["width"] = defaultWidth;
3059             column["dataProperties"] = [name];
3060             column["getRowValue"] = function(row, pos) {
3061                 if (pos === undefined)
3062                     pos = 0;
3063                 return row["full_data"][this.dataProperties[pos]];
3064             };
3065             column["compareRows"] = function(row1, row2) {
3066                 const value1 = this.getRowValue(row1);
3067                 const value2 = this.getRowValue(row2);
3068                 if ((typeof(value1) === "number") && (typeof(value2) === "number"))
3069                     return compareNumbers(value1, value2);
3070                 return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
3071             };
3072             column["updateTd"] = function(td, row) {
3073                 const value = this.getRowValue(row);
3074                 td.textContent = value;
3075                 td.title = value;
3076             };
3077             column["onResize"] = null;
3078             this.columns.push(column);
3079             this.columns[name] = column;
3081             this.hiddenTableHeader.appendChild(new Element("th"));
3082             this.fixedTableHeader.appendChild(new Element("th"));
3083         },
3084         selectRow: function() {},
3085         updateRow: function(tr, fullUpdate) {
3086             const row = this.rows.get(tr.rowId);
3087             const data = row[fullUpdate ? "full_data" : "data"];
3089             if (row.full_data.isFeed) {
3090                 tr.addClass("articleTableFeed");
3091                 tr.removeClass("articleTableArticle");
3092             }
3093             else {
3094                 tr.removeClass("articleTableFeed");
3095                 tr.addClass("articleTableArticle");
3096             }
3098             const tds = tr.getElements("td");
3099             for (let i = 0; i < this.columns.length; ++i) {
3100                 if (Object.hasOwn(data, this.columns[i].dataProperties[0]))
3101                     this.columns[i].updateTd(tds[i], row);
3102             }
3103             row["data"] = {};
3104         }
3105     });
3107     const LogMessageTable = new Class({
3108         Extends: DynamicTable,
3110         filterText: "",
3112         filteredLength: function() {
3113             return this.tableBody.getElements("tr").length;
3114         },
3116         initColumns: function() {
3117             this.newColumn("rowId", "", "QBT_TR(ID)QBT_TR[CONTEXT=ExecutionLogWidget]", 50, true);
3118             this.newColumn("message", "", "QBT_TR(Message)QBT_TR[CONTEXT=ExecutionLogWidget]", 350, true);
3119             this.newColumn("timestamp", "", "QBT_TR(Timestamp)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);
3120             this.newColumn("type", "", "QBT_TR(Log Type)QBT_TR[CONTEXT=ExecutionLogWidget]", 100, true);
3121             this.initColumnsFunctions();
3122         },
3124         initColumnsFunctions: function() {
3125             this.columns["timestamp"].updateTd = function(td, row) {
3126                 const date = new Date(this.getRowValue(row) * 1000).toLocaleString();
3127                 td.set({ "text": date, "title": date });
3128             };
3130             this.columns["type"].updateTd = function(td, row) {
3131                 // Type of the message: Log::NORMAL: 1, Log::INFO: 2, Log::WARNING: 4, Log::CRITICAL: 8
3132                 let logLevel, addClass;
3133                 switch (this.getRowValue(row).toInt()) {
3134                     case 1:
3135                         logLevel = "QBT_TR(Normal)QBT_TR[CONTEXT=ExecutionLogWidget]";
3136                         addClass = "logNormal";
3137                         break;
3138                     case 2:
3139                         logLevel = "QBT_TR(Info)QBT_TR[CONTEXT=ExecutionLogWidget]";
3140                         addClass = "logInfo";
3141                         break;
3142                     case 4:
3143                         logLevel = "QBT_TR(Warning)QBT_TR[CONTEXT=ExecutionLogWidget]";
3144                         addClass = "logWarning";
3145                         break;
3146                     case 8:
3147                         logLevel = "QBT_TR(Critical)QBT_TR[CONTEXT=ExecutionLogWidget]";
3148                         addClass = "logCritical";
3149                         break;
3150                     default:
3151                         logLevel = "QBT_TR(Unknown)QBT_TR[CONTEXT=ExecutionLogWidget]";
3152                         addClass = "logUnknown";
3153                         break;
3154                 }
3155                 td.set({ "text": logLevel, "title": logLevel });
3156                 td.getParent("tr").className = `logTableRow${addClass}`;
3157             };
3158         },
3160         getFilteredAndSortedRows: function() {
3161             let filteredRows = [];
3162             const rows = this.rows.getValues();
3163             this.filterText = window.qBittorrent.Log.getFilterText();
3164             const filterTerms = (this.filterText.length > 0) ? this.filterText.toLowerCase().split(" ") : [];
3165             const logLevels = window.qBittorrent.Log.getSelectedLevels();
3166             if ((filterTerms.length > 0) || (logLevels.length < 4)) {
3167                 for (let i = 0; i < rows.length; ++i) {
3168                     if (!logLevels.includes(rows[i].full_data.type.toString()))
3169                         continue;
3171                     if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(rows[i].full_data.message, filterTerms))
3172                         continue;
3174                     filteredRows.push(rows[i]);
3175                 }
3176             }
3177             else {
3178                 filteredRows = rows;
3179             }
3181             filteredRows.sort((row1, row2) => {
3182                 const column = this.columns[this.sortedColumn];
3183                 const res = column.compareRows(row1, row2);
3184                 return (this.reverseSort === "0") ? res : -res;
3185             });
3187             return filteredRows;
3188         },
3190         setupCommonEvents: function() {},
3192         setupTr: function(tr) {
3193             tr.addClass("logTableRow");
3194         }
3195     });
3197     const LogPeerTable = new Class({
3198         Extends: LogMessageTable,
3200         initColumns: function() {
3201             this.newColumn("rowId", "", "QBT_TR(ID)QBT_TR[CONTEXT=ExecutionLogWidget]", 50, true);
3202             this.newColumn("ip", "", "QBT_TR(IP)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);
3203             this.newColumn("timestamp", "", "QBT_TR(Timestamp)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);
3204             this.newColumn("blocked", "", "QBT_TR(Status)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);
3205             this.newColumn("reason", "", "QBT_TR(Reason)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);
3207             this.columns["timestamp"].updateTd = function(td, row) {
3208                 const date = new Date(this.getRowValue(row) * 1000).toLocaleString();
3209                 td.set({ "text": date, "title": date });
3210             };
3212             this.columns["blocked"].updateTd = function(td, row) {
3213                 let status, addClass;
3214                 if (this.getRowValue(row)) {
3215                     status = "QBT_TR(Blocked)QBT_TR[CONTEXT=ExecutionLogWidget]";
3216                     addClass = "peerBlocked";
3217                 }
3218                 else {
3219                     status = "QBT_TR(Banned)QBT_TR[CONTEXT=ExecutionLogWidget]";
3220                     addClass = "peerBanned";
3221                 }
3222                 td.set({ "text": status, "title": status });
3223                 td.getParent("tr").className = `logTableRow${addClass}`;
3224             };
3225         },
3227         getFilteredAndSortedRows: function() {
3228             let filteredRows = [];
3229             const rows = this.rows.getValues();
3230             this.filterText = window.qBittorrent.Log.getFilterText();
3231             const filterTerms = (this.filterText.length > 0) ? this.filterText.toLowerCase().split(" ") : [];
3232             if (filterTerms.length > 0) {
3233                 for (let i = 0; i < rows.length; ++i) {
3234                     if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(rows[i].full_data.ip, filterTerms))
3235                         continue;
3237                     filteredRows.push(rows[i]);
3238                 }
3239             }
3240             else {
3241                 filteredRows = rows;
3242             }
3244             filteredRows.sort((row1, row2) => {
3245                 const column = this.columns[this.sortedColumn];
3246                 const res = column.compareRows(row1, row2);
3247                 return (this.reverseSort === "0") ? res : -res;
3248             });
3250             return filteredRows;
3251         }
3252     });
3254     return exports();
3255 })();
3256 Object.freeze(window.qBittorrent.DynamicTable);
3258 /*************************************************************/