WebUI: Hide context menu when clicking on a table row
[qBittorrent.git] / src / webui / www / private / scripts / dynamicTable.js
blob4a3b2c7e63a4bde1bc62b67e2739555f1825fc4e
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             TorrentWebseedsTable: TorrentWebseedsTable
55         };
56     };
58     const compareNumbers = (val1, val2) => {
59         if (val1 < val2)
60             return -1;
61         if (val1 > val2)
62             return 1;
63         return 0;
64     };
66     let DynamicTableHeaderContextMenuClass = null;
67     let ProgressColumnWidth = -1;
69     const DynamicTable = new Class({
71         initialize: () => {},
73         setup: function(dynamicTableDivId, dynamicTableFixedHeaderDivId, contextMenu) {
74             this.dynamicTableDivId = dynamicTableDivId;
75             this.dynamicTableFixedHeaderDivId = dynamicTableFixedHeaderDivId;
76             this.fixedTableHeader = $(dynamicTableFixedHeaderDivId).getElements("tr")[0];
77             this.hiddenTableHeader = $(dynamicTableDivId).getElements("tr")[0];
78             this.tableBody = $(dynamicTableDivId).getElements("tbody")[0];
79             this.rows = new Map();
80             this.selectedRows = [];
81             this.columns = [];
82             this.contextMenu = contextMenu;
83             this.sortedColumn = LocalPreferences.get("sorted_column_" + this.dynamicTableDivId, 0);
84             this.reverseSort = LocalPreferences.get("reverse_sort_" + this.dynamicTableDivId, "0");
85             this.initColumns();
86             this.loadColumnsOrder();
87             this.updateTableHeaders();
88             this.setupCommonEvents();
89             this.setupHeaderEvents();
90             this.setupHeaderMenu();
91             this.setSortedColumnIcon(this.sortedColumn, null, (this.reverseSort === "1"));
92             this.setupAltRow();
93         },
95         setupCommonEvents: function() {
96             const tableDiv = $(this.dynamicTableDivId);
97             const tableFixedHeaderDiv = $(this.dynamicTableFixedHeaderDivId);
99             const tableElement = tableFixedHeaderDiv.querySelector("table");
100             tableDiv.addEventListener("scroll", () => {
101                 tableElement.style.left = `${-tableDiv.scrollLeft}px`;
102             });
103         },
105         setupHeaderEvents: function() {
106             this.currentHeaderAction = "";
107             this.canResize = false;
109             const resetElementBorderStyle = (el, side) => {
110                 if ((side === "left") || (side !== "right"))
111                     el.style.borderLeft = "";
112                 if ((side === "right") || (side !== "left"))
113                     el.style.borderRight = "";
114             };
116             const mouseMoveFn = function(e) {
117                 const brect = e.target.getBoundingClientRect();
118                 const mouseXRelative = e.clientX - brect.left;
119                 if (this.currentHeaderAction === "") {
120                     if ((brect.width - mouseXRelative) < 5) {
121                         this.resizeTh = e.target;
122                         this.canResize = true;
123                         e.target.getParent("tr").style.cursor = "col-resize";
124                     }
125                     else if ((mouseXRelative < 5) && e.target.getPrevious('[class=""]')) {
126                         this.resizeTh = e.target.getPrevious('[class=""]');
127                         this.canResize = true;
128                         e.target.getParent("tr").style.cursor = "col-resize";
129                     }
130                     else {
131                         this.canResize = false;
132                         e.target.getParent("tr").style.cursor = "";
133                     }
134                 }
135                 if (this.currentHeaderAction === "drag") {
136                     const previousVisibleSibling = e.target.getPrevious('[class=""]');
137                     let borderChangeElement = previousVisibleSibling;
138                     let changeBorderSide = "right";
140                     if (mouseXRelative > (brect.width / 2)) {
141                         borderChangeElement = e.target;
142                         this.dropSide = "right";
143                     }
144                     else {
145                         this.dropSide = "left";
146                     }
148                     e.target.getParent("tr").style.cursor = "move";
150                     if (!previousVisibleSibling) { // right most column
151                         borderChangeElement = e.target;
153                         if (mouseXRelative <= (brect.width / 2))
154                             changeBorderSide = "left";
155                     }
157                     const borderStyle = "initial solid #e60";
158                     if (changeBorderSide === "left")
159                         borderChangeElement.style.borderLeft = borderStyle;
160                     else
161                         borderChangeElement.style.borderRight = borderStyle;
163                     resetElementBorderStyle(borderChangeElement, ((changeBorderSide === "right") ? "left" : "right"));
165                     borderChangeElement.getSiblings('[class=""]').each((el) => {
166                         resetElementBorderStyle(el);
167                     });
168                 }
169                 this.lastHoverTh = e.target;
170                 this.lastClientX = e.clientX;
171             }.bind(this);
173             const mouseOutFn = (e) => {
174                 resetElementBorderStyle(e.target);
175             };
177             const onBeforeStart = function(el) {
178                 this.clickedTh = el;
179                 this.currentHeaderAction = "start";
180                 this.dragMovement = false;
181                 this.dragStartX = this.lastClientX;
182             }.bind(this);
184             const onStart = function(el, event) {
185                 if (this.canResize) {
186                     this.currentHeaderAction = "resize";
187                     this.startWidth = parseInt(this.resizeTh.style.width, 10);
188                 }
189                 else {
190                     this.currentHeaderAction = "drag";
191                     el.style.backgroundColor = "#C1D5E7";
192                 }
193             }.bind(this);
195             const onDrag = function(el, event) {
196                 if (this.currentHeaderAction === "resize") {
197                     let width = this.startWidth + (event.event.pageX - this.dragStartX);
198                     if (width < 16)
199                         width = 16;
200                     this.columns[this.resizeTh.columnName].width = width;
201                     this.updateColumn(this.resizeTh.columnName);
202                 }
203             }.bind(this);
205             const onComplete = function(el, event) {
206                 resetElementBorderStyle(this.lastHoverTh);
207                 el.style.backgroundColor = "";
208                 if (this.currentHeaderAction === "resize")
209                     this.saveColumnWidth(this.resizeTh.columnName);
210                 if ((this.currentHeaderAction === "drag") && (el !== this.lastHoverTh)) {
211                     this.saveColumnsOrder();
212                     const val = LocalPreferences.get("columns_order_" + this.dynamicTableDivId).split(",");
213                     val.erase(el.columnName);
214                     let pos = val.indexOf(this.lastHoverTh.columnName);
215                     if (this.dropSide === "right")
216                         ++pos;
217                     val.splice(pos, 0, el.columnName);
218                     LocalPreferences.set("columns_order_" + this.dynamicTableDivId, val.join(","));
219                     this.loadColumnsOrder();
220                     this.updateTableHeaders();
221                     while (this.tableBody.firstChild)
222                         this.tableBody.removeChild(this.tableBody.firstChild);
223                     this.updateTable(true);
224                 }
225                 if (this.currentHeaderAction === "drag") {
226                     resetElementBorderStyle(el);
227                     el.getSiblings('[class=""]').each((el) => {
228                         resetElementBorderStyle(el);
229                     });
230                 }
231                 this.currentHeaderAction = "";
232             }.bind(this);
234             const onCancel = function(el) {
235                 this.currentHeaderAction = "";
237                 // ignore click/touch events performed when on the column's resize area
238                 if (!this.canResize)
239                     this.setSortedColumn(el.columnName);
240             }.bind(this);
242             const onTouch = function(e) {
243                 const column = e.target.columnName;
244                 this.currentHeaderAction = "";
245                 this.setSortedColumn(column);
246             }.bind(this);
248             const onDoubleClick = function(e) {
249                 e.preventDefault();
250                 this.currentHeaderAction = "";
252                 // only resize when hovering on the column's resize area
253                 if (this.canResize) {
254                     this.currentHeaderAction = "resize";
255                     this.autoResizeColumn(e.target.columnName);
256                     onComplete(e.target);
257                 }
258             }.bind(this);
260             const ths = this.fixedTableHeader.getElements("th");
262             for (let i = 0; i < ths.length; ++i) {
263                 const th = ths[i];
264                 th.addEventListener("mousemove", mouseMoveFn);
265                 th.addEventListener("mouseout", mouseOutFn);
266                 th.addEventListener("touchend", onTouch, { passive: true });
267                 th.addEventListener("dblclick", onDoubleClick);
268                 th.makeResizable({
269                     modifiers: {
270                         x: "",
271                         y: ""
272                     },
273                     onBeforeStart: onBeforeStart,
274                     onStart: onStart,
275                     onDrag: onDrag,
276                     onComplete: onComplete,
277                     onCancel: onCancel
278                 });
279             }
280         },
282         setupDynamicTableHeaderContextMenuClass: function() {
283             DynamicTableHeaderContextMenuClass ??= class extends window.qBittorrent.ContextMenu.ContextMenu {
284                 updateMenuItems() {
285                     for (let i = 0; i < this.dynamicTable.columns.length; ++i) {
286                         if (this.dynamicTable.columns[i].caption === "")
287                             continue;
288                         if (this.dynamicTable.columns[i].visible !== "0")
289                             this.setItemChecked(this.dynamicTable.columns[i].name, true);
290                         else
291                             this.setItemChecked(this.dynamicTable.columns[i].name, false);
292                     }
293                 }
294             };
295         },
297         showColumn: function(columnName, show) {
298             this.columns[columnName].visible = show ? "1" : "0";
299             LocalPreferences.set("column_" + columnName + "_visible_" + this.dynamicTableDivId, show ? "1" : "0");
300             this.updateColumn(columnName);
301         },
303         _calculateColumnBodyWidth: function(column) {
304             const columnIndex = this.getColumnPos(column.name);
305             const bodyColumn = document.getElementById(this.dynamicTableDivId).querySelectorAll("tr>th")[columnIndex];
306             const canvas = document.createElement("canvas");
307             const context = canvas.getContext("2d");
308             context.font = window.getComputedStyle(bodyColumn, null).getPropertyValue("font");
310             const longestTd = { value: "", width: 0 };
311             for (const tr of this.tableBody.querySelectorAll("tr")) {
312                 const tds = tr.querySelectorAll("td");
313                 const td = tds[columnIndex];
315                 const buffer = column.calculateBuffer(tr.rowId);
316                 const valueWidth = context.measureText(td.textContent).width;
317                 if ((valueWidth + buffer) > (longestTd.width)) {
318                     longestTd.value = td.textContent;
319                     longestTd.width = valueWidth + buffer;
320                 }
321             }
323             // slight buffer to prevent clipping
324             return longestTd.width + 10;
325         },
327         autoResizeColumn: function(columnName) {
328             const column = this.columns[columnName];
330             let width = column.staticWidth ?? 0;
331             if (column.staticWidth === null) {
332                 // check required min body width
333                 const bodyTextWidth = this._calculateColumnBodyWidth(column);
335                 // check required min header width
336                 const columnIndex = this.getColumnPos(column.name);
337                 const headColumn = document.getElementById(this.dynamicTableFixedHeaderDivId).querySelectorAll("tr>th")[columnIndex];
338                 const canvas = document.createElement("canvas");
339                 const context = canvas.getContext("2d");
340                 context.font = window.getComputedStyle(headColumn, null).getPropertyValue("font");
341                 const columnTitle = column.caption;
342                 const sortedIconWidth = 20;
343                 const headTextWidth = context.measureText(columnTitle).width + sortedIconWidth;
345                 width = Math.max(headTextWidth, bodyTextWidth);
346             }
348             column.width = width;
349             this.updateColumn(column.name);
350             this.saveColumnWidth(column.name);
351         },
353         saveColumnWidth: function(columnName) {
354             LocalPreferences.set(`column_${columnName}_width_${this.dynamicTableDivId}`, this.columns[columnName].width);
355         },
357         setupHeaderMenu: function() {
358             this.setupDynamicTableHeaderContextMenuClass();
360             const menuId = this.dynamicTableDivId + "_headerMenu";
362             // reuse menu if already exists
363             const ul = $(menuId) ?? new Element("ul", {
364                 id: menuId,
365                 class: "contextMenu scrollableMenu"
366             });
368             const createLi = (columnName, text) => {
369                 const anchor = document.createElement("a");
370                 anchor.href = `#${columnName}`;
371                 anchor.textContent = text;
373                 const img = document.createElement("img");
374                 img.src = "images/checked-completed.svg";
375                 anchor.prepend(img);
377                 const listItem = document.createElement("li");
378                 listItem.appendChild(anchor);
380                 return listItem;
381             };
383             const actions = {
384                 autoResizeAction: function(element, ref, action) {
385                     this.autoResizeColumn(element.columnName);
386                 }.bind(this),
388                 autoResizeAllAction: function(element, ref, action) {
389                     for (const { name } of this.columns)
390                         this.autoResizeColumn(name);
391                 }.bind(this),
392             };
394             const onMenuItemClicked = function(element, ref, action) {
395                 this.showColumn(action, this.columns[action].visible === "0");
396             }.bind(this);
398             // recreate child nodes when reusing (enables the context menu to work correctly)
399             if (ul.hasChildNodes()) {
400                 while (ul.firstChild)
401                     ul.removeChild(ul.lastChild);
402             }
404             for (let i = 0; i < this.columns.length; ++i) {
405                 const text = this.columns[i].caption;
406                 if (text === "")
407                     continue;
408                 ul.appendChild(createLi(this.columns[i].name, text));
409                 actions[this.columns[i].name] = onMenuItemClicked;
410             }
412             const createResizeElement = (text, href) => {
413                 const anchor = document.createElement("a");
414                 anchor.href = href;
415                 anchor.textContent = text;
417                 const spacer = document.createElement("span");
418                 spacer.style = "display: inline-block; width: calc(.5em + 16px);";
419                 anchor.prepend(spacer);
421                 const li = document.createElement("li");
422                 li.appendChild(anchor);
423                 return li;
424             };
426             const autoResizeAllElement = createResizeElement("Resize All", "#autoResizeAllAction");
427             const autoResizeElement = createResizeElement("Resize", "#autoResizeAction");
429             ul.firstChild.classList.add("separator");
430             ul.insertBefore(autoResizeAllElement, ul.firstChild);
431             ul.insertBefore(autoResizeElement, ul.firstChild);
432             ul.inject(document.body);
434             this.headerContextMenu = new DynamicTableHeaderContextMenuClass({
435                 targets: "#" + this.dynamicTableFixedHeaderDivId + " tr th",
436                 actions: actions,
437                 menu: menuId,
438                 offsets: {
439                     x: 0,
440                     y: 2
441                 }
442             });
444             this.headerContextMenu.dynamicTable = this;
445         },
447         initColumns: () => {},
449         newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
450             const column = {};
451             column["name"] = name;
452             column["title"] = name;
453             column["visible"] = LocalPreferences.get("column_" + name + "_visible_" + this.dynamicTableDivId, defaultVisible ? "1" : "0");
454             column["force_hide"] = false;
455             column["caption"] = caption;
456             column["style"] = style;
457             column["width"] = LocalPreferences.get("column_" + name + "_width_" + this.dynamicTableDivId, defaultWidth);
458             column["dataProperties"] = [name];
459             column["getRowValue"] = function(row, pos) {
460                 if (pos === undefined)
461                     pos = 0;
462                 return row["full_data"][this.dataProperties[pos]];
463             };
464             column["compareRows"] = function(row1, row2) {
465                 const value1 = this.getRowValue(row1);
466                 const value2 = this.getRowValue(row2);
467                 if ((typeof(value1) === "number") && (typeof(value2) === "number"))
468                     return compareNumbers(value1, value2);
469                 return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
470             };
471             column["updateTd"] = function(td, row) {
472                 const value = this.getRowValue(row);
473                 td.textContent = value;
474                 td.title = value;
475             };
476             column["onResize"] = null;
477             column["staticWidth"] = null;
478             column["calculateBuffer"] = () => 0;
479             this.columns.push(column);
480             this.columns[name] = column;
482             this.hiddenTableHeader.appendChild(new Element("th"));
483             this.fixedTableHeader.appendChild(new Element("th"));
484         },
486         loadColumnsOrder: function() {
487             const columnsOrder = [];
488             const val = LocalPreferences.get("columns_order_" + this.dynamicTableDivId);
489             if ((val === null) || (val === undefined))
490                 return;
491             val.split(",").forEach((v) => {
492                 if ((v in this.columns) && (!columnsOrder.contains(v)))
493                     columnsOrder.push(v);
494             });
496             for (let i = 0; i < this.columns.length; ++i) {
497                 if (!columnsOrder.contains(this.columns[i].name))
498                     columnsOrder.push(this.columns[i].name);
499             }
501             for (let i = 0; i < this.columns.length; ++i)
502                 this.columns[i] = this.columns[columnsOrder[i]];
503         },
505         saveColumnsOrder: function() {
506             let val = "";
507             for (let i = 0; i < this.columns.length; ++i) {
508                 if (i > 0)
509                     val += ",";
510                 val += this.columns[i].name;
511             }
512             LocalPreferences.set("columns_order_" + this.dynamicTableDivId, val);
513         },
515         updateTableHeaders: function() {
516             this.updateHeader(this.hiddenTableHeader);
517             this.updateHeader(this.fixedTableHeader);
518         },
520         updateHeader: function(header) {
521             const ths = header.getElements("th");
523             for (let i = 0; i < ths.length; ++i) {
524                 const th = ths[i];
525                 th._this = this;
526                 th.title = this.columns[i].caption;
527                 th.textContent = this.columns[i].caption;
528                 th.setAttribute("style", "width: " + this.columns[i].width + "px;" + this.columns[i].style);
529                 th.columnName = this.columns[i].name;
530                 th.addClass("column_" + th.columnName);
531                 if ((this.columns[i].visible === "0") || this.columns[i].force_hide)
532                     th.addClass("invisible");
533                 else
534                     th.removeClass("invisible");
535             }
536         },
538         getColumnPos: function(columnName) {
539             for (let i = 0; i < this.columns.length; ++i) {
540                 if (this.columns[i].name === columnName)
541                     return i;
542             }
543             return -1;
544         },
546         updateColumn: function(columnName) {
547             const pos = this.getColumnPos(columnName);
548             const visible = ((this.columns[pos].visible !== "0") && !this.columns[pos].force_hide);
549             const ths = this.hiddenTableHeader.getElements("th");
550             const fths = this.fixedTableHeader.getElements("th");
551             const trs = this.tableBody.getElements("tr");
552             const style = "width: " + this.columns[pos].width + "px;" + this.columns[pos].style;
554             ths[pos].setAttribute("style", style);
555             fths[pos].setAttribute("style", style);
557             if (visible) {
558                 ths[pos].removeClass("invisible");
559                 fths[pos].removeClass("invisible");
560                 for (let i = 0; i < trs.length; ++i)
561                     trs[i].getElements("td")[pos].removeClass("invisible");
562             }
563             else {
564                 ths[pos].addClass("invisible");
565                 fths[pos].addClass("invisible");
566                 for (let j = 0; j < trs.length; ++j)
567                     trs[j].getElements("td")[pos].addClass("invisible");
568             }
569             if (this.columns[pos].onResize !== null)
570                 this.columns[pos].onResize(columnName);
571         },
573         getSortedColumn: function() {
574             return LocalPreferences.get("sorted_column_" + this.dynamicTableDivId);
575         },
577         /**
578          * @param {string} column name to sort by
579          * @param {string|null} reverse defaults to implementation-specific behavior when not specified. Should only be passed when restoring previous state.
580          */
581         setSortedColumn: function(column, reverse = null) {
582             if (column !== this.sortedColumn) {
583                 const oldColumn = this.sortedColumn;
584                 this.sortedColumn = column;
585                 this.reverseSort = reverse ?? "0";
586                 this.setSortedColumnIcon(column, oldColumn, false);
587             }
588             else {
589                 // Toggle sort order
590                 this.reverseSort = reverse ?? (this.reverseSort === "0" ? "1" : "0");
591                 this.setSortedColumnIcon(column, null, (this.reverseSort === "1"));
592             }
593             LocalPreferences.set("sorted_column_" + this.dynamicTableDivId, column);
594             LocalPreferences.set("reverse_sort_" + this.dynamicTableDivId, this.reverseSort);
595             this.updateTable(false);
596         },
598         setSortedColumnIcon: function(newColumn, oldColumn, isReverse) {
599             const getCol = (headerDivId, colName) => {
600                 const colElem = $$("#" + headerDivId + " .column_" + colName);
601                 if (colElem.length === 1)
602                     return colElem[0];
603                 return null;
604             };
606             const colElem = getCol(this.dynamicTableFixedHeaderDivId, newColumn);
607             if (colElem !== null) {
608                 colElem.addClass("sorted");
609                 if (isReverse)
610                     colElem.addClass("reverse");
611                 else
612                     colElem.removeClass("reverse");
613             }
614             const oldColElem = getCol(this.dynamicTableFixedHeaderDivId, oldColumn);
615             if (oldColElem !== null) {
616                 oldColElem.removeClass("sorted");
617                 oldColElem.removeClass("reverse");
618             }
619         },
621         getSelectedRowId: function() {
622             if (this.selectedRows.length > 0)
623                 return this.selectedRows[0];
624             return "";
625         },
627         isRowSelected: function(rowId) {
628             return this.selectedRows.contains(rowId);
629         },
631         setupAltRow: function() {
632             const useAltRowColors = (LocalPreferences.get("use_alt_row_colors", "true") === "true");
633             if (useAltRowColors)
634                 document.getElementById(this.dynamicTableDivId).classList.add("altRowColors");
635         },
637         selectAll: function() {
638             this.deselectAll();
640             const trs = this.tableBody.getElements("tr");
641             for (let i = 0; i < trs.length; ++i) {
642                 const tr = trs[i];
643                 this.selectedRows.push(tr.rowId);
644                 if (!tr.hasClass("selected"))
645                     tr.addClass("selected");
646             }
647         },
649         deselectAll: function() {
650             this.selectedRows.empty();
651         },
653         selectRow: function(rowId) {
654             this.selectedRows.push(rowId);
655             this.setRowClass();
656             this.onSelectedRowChanged();
657         },
659         deselectRow: function(rowId) {
660             this.selectedRows.erase(rowId);
661             this.setRowClass();
662             this.onSelectedRowChanged();
663         },
665         selectRows: function(rowId1, rowId2) {
666             this.deselectAll();
667             if (rowId1 === rowId2) {
668                 this.selectRow(rowId1);
669                 return;
670             }
672             let select = false;
673             const that = this;
674             this.tableBody.getElements("tr").each((tr) => {
675                 if ((tr.rowId === rowId1) || (tr.rowId === rowId2)) {
676                     select = !select;
677                     that.selectedRows.push(tr.rowId);
678                 }
679                 else if (select) {
680                     that.selectedRows.push(tr.rowId);
681                 }
682             });
683             this.setRowClass();
684             this.onSelectedRowChanged();
685         },
687         reselectRows: function(rowIds) {
688             this.deselectAll();
689             this.selectedRows = rowIds.slice();
690             this.tableBody.getElements("tr").each((tr) => {
691                 if (rowIds.includes(tr.rowId))
692                     tr.addClass("selected");
693             });
694         },
696         setRowClass: function() {
697             const that = this;
698             this.tableBody.getElements("tr").each((tr) => {
699                 if (that.isRowSelected(tr.rowId))
700                     tr.addClass("selected");
701                 else
702                     tr.removeClass("selected");
703             });
704         },
706         onSelectedRowChanged: () => {},
708         updateRowData: function(data) {
709             // ensure rowId is a string
710             const rowId = `${data["rowId"]}`;
711             let row;
713             if (!this.rows.has(rowId)) {
714                 row = {
715                     "full_data": {},
716                     "rowId": rowId
717                 };
718                 this.rows.set(rowId, row);
719             }
720             else {
721                 row = this.rows.get(rowId);
722             }
724             row["data"] = data;
725             for (const x in data) {
726                 if (!Object.hasOwn(data, x))
727                     continue;
728                 row["full_data"][x] = data[x];
729             }
730         },
732         getRow: function(rowId) {
733             return this.rows.get(rowId);
734         },
736         getFilteredAndSortedRows: function() {
737             const filteredRows = [];
739             for (const row of this.getRowValues()) {
740                 filteredRows.push(row);
741                 filteredRows[row.rowId] = row;
742             }
744             filteredRows.sort((row1, row2) => {
745                 const column = this.columns[this.sortedColumn];
746                 const res = column.compareRows(row1, row2);
747                 if (this.reverseSort === "0")
748                     return res;
749                 else
750                     return -res;
751             });
752             return filteredRows;
753         },
755         getTrByRowId: function(rowId) {
756             const trs = this.tableBody.getElements("tr");
757             for (let i = 0; i < trs.length; ++i) {
758                 if (trs[i].rowId === rowId)
759                     return trs[i];
760             }
761             return null;
762         },
764         updateTable: function(fullUpdate = false) {
765             const rows = this.getFilteredAndSortedRows();
767             for (let i = 0; i < this.selectedRows.length; ++i) {
768                 if (!(this.selectedRows[i] in rows)) {
769                     this.selectedRows.splice(i, 1);
770                     --i;
771                 }
772             }
774             const trs = this.tableBody.getElements("tr");
776             for (let rowPos = 0; rowPos < rows.length; ++rowPos) {
777                 const rowId = rows[rowPos]["rowId"];
778                 let tr_found = false;
779                 for (let j = rowPos; j < trs.length; ++j) {
780                     if (trs[j]["rowId"] === rowId) {
781                         tr_found = true;
782                         if (rowPos === j)
783                             break;
784                         trs[j].inject(trs[rowPos], "before");
785                         const tmpTr = trs[j];
786                         trs.splice(j, 1);
787                         trs.splice(rowPos, 0, tmpTr);
788                         break;
789                     }
790                 }
791                 if (tr_found) { // row already exists in the table
792                     this.updateRow(trs[rowPos], fullUpdate);
793                 }
794                 else { // else create a new row in the table
795                     const tr = new Element("tr");
796                     // set tabindex so element receives keydown events
797                     // more info: https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event
798                     tr.tabIndex = -1;
800                     const rowId = rows[rowPos]["rowId"];
801                     tr.setAttribute("data-row-id", rowId);
802                     tr["rowId"] = rowId;
804                     tr._this = this;
805                     tr.addEventListener("contextmenu", function(e) {
806                         if (!this._this.isRowSelected(this.rowId)) {
807                             this._this.deselectAll();
808                             this._this.selectRow(this.rowId);
809                         }
810                         return true;
811                     });
812                     tr.addEventListener("click", function(e) {
813                         e.preventDefault();
815                         if (e.ctrlKey || e.metaKey) {
816                             // CTRL/CMD âŒ˜ key was pressed
817                             if (this._this.isRowSelected(this.rowId))
818                                 this._this.deselectRow(this.rowId);
819                             else
820                                 this._this.selectRow(this.rowId);
821                         }
822                         else if (e.shiftKey && (this._this.selectedRows.length === 1)) {
823                             // Shift key was pressed
824                             this._this.selectRows(this._this.getSelectedRowId(), this.rowId);
825                         }
826                         else {
827                             // Simple selection
828                             this._this.deselectAll();
829                             this._this.selectRow(this.rowId);
830                         }
831                         return false;
832                     });
833                     tr.addEventListener("touchstart", function(e) {
834                         if (!this._this.isRowSelected(this.rowId)) {
835                             this._this.deselectAll();
836                             this._this.selectRow(this.rowId);
837                         }
838                     }, { passive: true });
839                     tr.addEventListener("keydown", function(event) {
840                         switch (event.key) {
841                             case "ArrowUp":
842                                 this._this.selectPreviousRow();
843                                 return false;
844                             case "ArrowDown":
845                                 this._this.selectNextRow();
846                                 return false;
847                         }
848                     });
850                     this.setupTr(tr);
852                     for (let k = 0; k < this.columns.length; ++k) {
853                         const td = new Element("td");
854                         if ((this.columns[k].visible === "0") || this.columns[k].force_hide)
855                             td.addClass("invisible");
856                         td.injectInside(tr);
857                     }
859                     // Insert
860                     if (rowPos >= trs.length) {
861                         tr.inject(this.tableBody);
862                         trs.push(tr);
863                     }
864                     else {
865                         tr.inject(trs[rowPos], "before");
866                         trs.splice(rowPos, 0, tr);
867                     }
869                     // Update context menu
870                     if (this.contextMenu)
871                         this.contextMenu.addTarget(tr);
873                     this.updateRow(tr, true);
874                 }
875             }
877             const rowPos = rows.length;
879             while ((rowPos < trs.length) && (trs.length > 0))
880                 trs.pop().destroy();
881         },
883         setupTr: (tr) => {},
885         updateRow: function(tr, fullUpdate) {
886             const row = this.rows.get(tr.rowId);
887             const data = row[fullUpdate ? "full_data" : "data"];
889             const tds = tr.getElements("td");
890             for (let i = 0; i < this.columns.length; ++i) {
891                 if (Object.hasOwn(data, this.columns[i].dataProperties[0]))
892                     this.columns[i].updateTd(tds[i], row);
893             }
894             row["data"] = {};
895         },
897         removeRow: function(rowId) {
898             this.selectedRows.erase(rowId);
899             this.rows.delete(rowId);
900             const tr = this.getTrByRowId(rowId);
901             tr?.destroy();
902         },
904         clear: function() {
905             this.deselectAll();
906             this.rows.clear();
907             const trs = this.tableBody.getElements("tr");
908             while (trs.length > 0)
909                 trs.pop().destroy();
910         },
912         selectedRowsIds: function() {
913             return this.selectedRows.slice();
914         },
916         getRowIds: function() {
917             return this.rows.keys();
918         },
920         getRowValues: function() {
921             return this.rows.values();
922         },
924         getRowItems: function() {
925             return this.rows.entries();
926         },
928         getRowSize: function() {
929             return this.rows.size;
930         },
932         selectNextRow: function() {
933             const visibleRows = $(this.dynamicTableDivId).getElements("tbody tr").filter(e => e.style.display !== "none");
934             const selectedRowId = this.getSelectedRowId();
936             let selectedIndex = -1;
937             for (let i = 0; i < visibleRows.length; ++i) {
938                 const row = visibleRows[i];
939                 if (row.getAttribute("data-row-id") === selectedRowId) {
940                     selectedIndex = i;
941                     break;
942                 }
943             }
945             const isLastRowSelected = (selectedIndex >= (visibleRows.length - 1));
946             if (!isLastRowSelected) {
947                 this.deselectAll();
949                 const newRow = visibleRows[selectedIndex + 1];
950                 this.selectRow(newRow.getAttribute("data-row-id"));
951             }
952         },
954         selectPreviousRow: function() {
955             const visibleRows = $(this.dynamicTableDivId).getElements("tbody tr").filter(e => e.style.display !== "none");
956             const selectedRowId = this.getSelectedRowId();
958             let selectedIndex = -1;
959             for (let i = 0; i < visibleRows.length; ++i) {
960                 const row = visibleRows[i];
961                 if (row.getAttribute("data-row-id") === selectedRowId) {
962                     selectedIndex = i;
963                     break;
964                 }
965             }
967             const isFirstRowSelected = selectedIndex <= 0;
968             if (!isFirstRowSelected) {
969                 this.deselectAll();
971                 const newRow = visibleRows[selectedIndex - 1];
972                 this.selectRow(newRow.getAttribute("data-row-id"));
973             }
974         },
975     });
977     const TorrentsTable = new Class({
978         Extends: DynamicTable,
980         initColumns: function() {
981             this.newColumn("priority", "", "#", 30, true);
982             this.newColumn("state_icon", "cursor: default", "", 22, true);
983             this.newColumn("name", "", "QBT_TR(Name)QBT_TR[CONTEXT=TransferListModel]", 200, true);
984             this.newColumn("size", "", "QBT_TR(Size)QBT_TR[CONTEXT=TransferListModel]", 100, true);
985             this.newColumn("total_size", "", "QBT_TR(Total Size)QBT_TR[CONTEXT=TransferListModel]", 100, false);
986             this.newColumn("progress", "", "QBT_TR(Progress)QBT_TR[CONTEXT=TransferListModel]", 85, true);
987             this.newColumn("status", "", "QBT_TR(Status)QBT_TR[CONTEXT=TransferListModel]", 100, true);
988             this.newColumn("num_seeds", "", "QBT_TR(Seeds)QBT_TR[CONTEXT=TransferListModel]", 100, true);
989             this.newColumn("num_leechs", "", "QBT_TR(Peers)QBT_TR[CONTEXT=TransferListModel]", 100, true);
990             this.newColumn("dlspeed", "", "QBT_TR(Down Speed)QBT_TR[CONTEXT=TransferListModel]", 100, true);
991             this.newColumn("upspeed", "", "QBT_TR(Up Speed)QBT_TR[CONTEXT=TransferListModel]", 100, true);
992             this.newColumn("eta", "", "QBT_TR(ETA)QBT_TR[CONTEXT=TransferListModel]", 100, true);
993             this.newColumn("ratio", "", "QBT_TR(Ratio)QBT_TR[CONTEXT=TransferListModel]", 100, true);
994             this.newColumn("popularity", "", "QBT_TR(Popularity)QBT_TR[CONTEXT=TransferListModel]", 100, true);
995             this.newColumn("category", "", "QBT_TR(Category)QBT_TR[CONTEXT=TransferListModel]", 100, true);
996             this.newColumn("tags", "", "QBT_TR(Tags)QBT_TR[CONTEXT=TransferListModel]", 100, true);
997             this.newColumn("added_on", "", "QBT_TR(Added On)QBT_TR[CONTEXT=TransferListModel]", 100, true);
998             this.newColumn("completion_on", "", "QBT_TR(Completed On)QBT_TR[CONTEXT=TransferListModel]", 100, false);
999             this.newColumn("tracker", "", "QBT_TR(Tracker)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1000             this.newColumn("dl_limit", "", "QBT_TR(Down Limit)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1001             this.newColumn("up_limit", "", "QBT_TR(Up Limit)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1002             this.newColumn("downloaded", "", "QBT_TR(Downloaded)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1003             this.newColumn("uploaded", "", "QBT_TR(Uploaded)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1004             this.newColumn("downloaded_session", "", "QBT_TR(Session Download)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1005             this.newColumn("uploaded_session", "", "QBT_TR(Session Upload)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1006             this.newColumn("amount_left", "", "QBT_TR(Remaining)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1007             this.newColumn("time_active", "", "QBT_TR(Time Active)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1008             this.newColumn("save_path", "", "QBT_TR(Save path)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1009             this.newColumn("completed", "", "QBT_TR(Completed)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1010             this.newColumn("max_ratio", "", "QBT_TR(Ratio Limit)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1011             this.newColumn("seen_complete", "", "QBT_TR(Last Seen Complete)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1012             this.newColumn("last_activity", "", "QBT_TR(Last Activity)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1013             this.newColumn("availability", "", "QBT_TR(Availability)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1014             this.newColumn("download_path", "", "QBT_TR(Incomplete Save Path)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1015             this.newColumn("infohash_v1", "", "QBT_TR(Info Hash v1)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1016             this.newColumn("infohash_v2", "", "QBT_TR(Info Hash v2)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1017             this.newColumn("reannounce", "", "QBT_TR(Reannounce In)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1018             this.newColumn("private", "", "QBT_TR(Private)QBT_TR[CONTEXT=TransferListModel]", 100, false);
1020             this.columns["state_icon"].onclick = "";
1021             this.columns["state_icon"].dataProperties[0] = "state";
1023             this.columns["num_seeds"].dataProperties.push("num_complete");
1024             this.columns["num_leechs"].dataProperties.push("num_incomplete");
1025             this.columns["time_active"].dataProperties.push("seeding_time");
1027             this.initColumnsFunctions();
1028         },
1030         initColumnsFunctions: function() {
1032             // state_icon
1033             this.columns["state_icon"].updateTd = function(td, row) {
1034                 let state = this.getRowValue(row);
1035                 let img_path;
1036                 // normalize states
1037                 switch (state) {
1038                     case "forcedDL":
1039                     case "metaDL":
1040                     case "forcedMetaDL":
1041                     case "downloading":
1042                         state = "downloading";
1043                         img_path = "images/downloading.svg";
1044                         break;
1045                     case "forcedUP":
1046                     case "uploading":
1047                         state = "uploading";
1048                         img_path = "images/upload.svg";
1049                         break;
1050                     case "stalledUP":
1051                         state = "stalledUP";
1052                         img_path = "images/stalledUP.svg";
1053                         break;
1054                     case "stalledDL":
1055                         state = "stalledDL";
1056                         img_path = "images/stalledDL.svg";
1057                         break;
1058                     case "stoppedDL":
1059                         state = "torrent-stop";
1060                         img_path = "images/stopped.svg";
1061                         break;
1062                     case "stoppedUP":
1063                         state = "checked-completed";
1064                         img_path = "images/checked-completed.svg";
1065                         break;
1066                     case "queuedDL":
1067                     case "queuedUP":
1068                         state = "queued";
1069                         img_path = "images/queued.svg";
1070                         break;
1071                     case "checkingDL":
1072                     case "checkingUP":
1073                     case "queuedForChecking":
1074                     case "checkingResumeData":
1075                         state = "force-recheck";
1076                         img_path = "images/force-recheck.svg";
1077                         break;
1078                     case "moving":
1079                         state = "moving";
1080                         img_path = "images/set-location.svg";
1081                         break;
1082                     case "error":
1083                     case "unknown":
1084                     case "missingFiles":
1085                         state = "error";
1086                         img_path = "images/error.svg";
1087                         break;
1088                     default:
1089                         break; // do nothing
1090                 }
1092                 if (td.getChildren("img").length > 0) {
1093                     const img = td.getChildren("img")[0];
1094                     if (!img.src.includes(img_path)) {
1095                         img.src = img_path;
1096                         img.title = state;
1097                     }
1098                 }
1099                 else {
1100                     td.adopt(new Element("img", {
1101                         "src": img_path,
1102                         "class": "stateIcon",
1103                         "title": state
1104                     }));
1105                 }
1106             };
1108             // status
1109             this.columns["status"].updateTd = function(td, row) {
1110                 const state = this.getRowValue(row);
1111                 if (!state)
1112                     return;
1114                 let status;
1115                 switch (state) {
1116                     case "downloading":
1117                         status = "QBT_TR(Downloading)QBT_TR[CONTEXT=TransferListDelegate]";
1118                         break;
1119                     case "stalledDL":
1120                         status = "QBT_TR(Stalled)QBT_TR[CONTEXT=TransferListDelegate]";
1121                         break;
1122                     case "metaDL":
1123                         status = "QBT_TR(Downloading metadata)QBT_TR[CONTEXT=TransferListDelegate]";
1124                         break;
1125                     case "forcedMetaDL":
1126                         status = "QBT_TR([F] Downloading metadata)QBT_TR[CONTEXT=TransferListDelegate]";
1127                         break;
1128                     case "forcedDL":
1129                         status = "QBT_TR([F] Downloading)QBT_TR[CONTEXT=TransferListDelegate]";
1130                         break;
1131                     case "uploading":
1132                     case "stalledUP":
1133                         status = "QBT_TR(Seeding)QBT_TR[CONTEXT=TransferListDelegate]";
1134                         break;
1135                     case "forcedUP":
1136                         status = "QBT_TR([F] Seeding)QBT_TR[CONTEXT=TransferListDelegate]";
1137                         break;
1138                     case "queuedDL":
1139                     case "queuedUP":
1140                         status = "QBT_TR(Queued)QBT_TR[CONTEXT=TransferListDelegate]";
1141                         break;
1142                     case "checkingDL":
1143                     case "checkingUP":
1144                         status = "QBT_TR(Checking)QBT_TR[CONTEXT=TransferListDelegate]";
1145                         break;
1146                     case "queuedForChecking":
1147                         status = "QBT_TR(Queued for checking)QBT_TR[CONTEXT=TransferListDelegate]";
1148                         break;
1149                     case "checkingResumeData":
1150                         status = "QBT_TR(Checking resume data)QBT_TR[CONTEXT=TransferListDelegate]";
1151                         break;
1152                     case "stoppedDL":
1153                         status = "QBT_TR(Stopped)QBT_TR[CONTEXT=TransferListDelegate]";
1154                         break;
1155                     case "stoppedUP":
1156                         status = "QBT_TR(Completed)QBT_TR[CONTEXT=TransferListDelegate]";
1157                         break;
1158                     case "moving":
1159                         status = "QBT_TR(Moving)QBT_TR[CONTEXT=TransferListDelegate]";
1160                         break;
1161                     case "missingFiles":
1162                         status = "QBT_TR(Missing Files)QBT_TR[CONTEXT=TransferListDelegate]";
1163                         break;
1164                     case "error":
1165                         status = "QBT_TR(Errored)QBT_TR[CONTEXT=TransferListDelegate]";
1166                         break;
1167                     default:
1168                         status = "QBT_TR(Unknown)QBT_TR[CONTEXT=HttpServer]";
1169                 }
1171                 td.textContent = status;
1172                 td.title = status;
1173             };
1175             this.columns["status"].compareRows = (row1, row2) => {
1176                 return compareNumbers(row1.full_data._statusOrder, row2.full_data._statusOrder);
1177             };
1179             // priority
1180             this.columns["priority"].updateTd = function(td, row) {
1181                 const queuePos = this.getRowValue(row);
1182                 const formattedQueuePos = (queuePos < 1) ? "*" : queuePos;
1183                 td.textContent = formattedQueuePos;
1184                 td.title = formattedQueuePos;
1185             };
1187             this.columns["priority"].compareRows = function(row1, row2) {
1188                 let row1_val = this.getRowValue(row1);
1189                 let row2_val = this.getRowValue(row2);
1190                 if (row1_val < 1)
1191                     row1_val = 1000000;
1192                 if (row2_val < 1)
1193                     row2_val = 1000000;
1194                 return compareNumbers(row1_val, row2_val);
1195             };
1197             // name, category, tags
1198             this.columns["name"].compareRows = function(row1, row2) {
1199                 const row1Val = this.getRowValue(row1);
1200                 const row2Val = this.getRowValue(row2);
1201                 return row1Val.localeCompare(row2Val, undefined, { numeric: true, sensitivity: "base" });
1202             };
1203             this.columns["category"].compareRows = this.columns["name"].compareRows;
1204             this.columns["tags"].compareRows = this.columns["name"].compareRows;
1206             // size, total_size
1207             this.columns["size"].updateTd = function(td, row) {
1208                 const size = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false);
1209                 td.textContent = size;
1210                 td.title = size;
1211             };
1212             this.columns["total_size"].updateTd = this.columns["size"].updateTd;
1214             // progress
1215             this.columns["progress"].updateTd = function(td, row) {
1216                 const progress = this.getRowValue(row);
1217                 let progressFormatted = (progress * 100).round(1);
1218                 if ((progressFormatted === 100.0) && (progress !== 1.0))
1219                     progressFormatted = 99.9;
1221                 if (td.getChildren("div").length > 0) {
1222                     const div = td.getChildren("div")[0];
1223                     if (td.resized) {
1224                         td.resized = false;
1225                         div.setWidth(ProgressColumnWidth - 5);
1226                     }
1227                     if (div.getValue() !== progressFormatted)
1228                         div.setValue(progressFormatted);
1229                 }
1230                 else {
1231                     if (ProgressColumnWidth < 0)
1232                         ProgressColumnWidth = td.offsetWidth;
1233                     td.adopt(new window.qBittorrent.ProgressBar.ProgressBar(progressFormatted.toFloat(), {
1234                         "width": ProgressColumnWidth - 5
1235                     }));
1236                     td.resized = false;
1237                 }
1238             };
1239             this.columns["progress"].staticWidth = 100;
1240             this.columns["progress"].onResize = function(columnName) {
1241                 const pos = this.getColumnPos(columnName);
1242                 const trs = this.tableBody.getElements("tr");
1243                 ProgressColumnWidth = -1;
1244                 for (let i = 0; i < trs.length; ++i) {
1245                     const td = trs[i].getElements("td")[pos];
1246                     if (ProgressColumnWidth < 0)
1247                         ProgressColumnWidth = td.offsetWidth;
1248                     td.resized = true;
1249                     this.columns[columnName].updateTd(td, this.rows.get(trs[i].rowId));
1250                 }
1251             }.bind(this);
1253             // num_seeds
1254             this.columns["num_seeds"].updateTd = function(td, row) {
1255                 const num_seeds = this.getRowValue(row, 0);
1256                 const num_complete = this.getRowValue(row, 1);
1257                 let value = num_seeds;
1258                 if (num_complete !== -1)
1259                     value += " (" + num_complete + ")";
1260                 td.textContent = value;
1261                 td.title = value;
1262             };
1263             this.columns["num_seeds"].compareRows = function(row1, row2) {
1264                 const num_seeds1 = this.getRowValue(row1, 0);
1265                 const num_complete1 = this.getRowValue(row1, 1);
1267                 const num_seeds2 = this.getRowValue(row2, 0);
1268                 const num_complete2 = this.getRowValue(row2, 1);
1270                 const result = compareNumbers(num_complete1, num_complete2);
1271                 if (result !== 0)
1272                     return result;
1273                 return compareNumbers(num_seeds1, num_seeds2);
1274             };
1276             // num_leechs
1277             this.columns["num_leechs"].updateTd = this.columns["num_seeds"].updateTd;
1278             this.columns["num_leechs"].compareRows = this.columns["num_seeds"].compareRows;
1280             // dlspeed
1281             this.columns["dlspeed"].updateTd = function(td, row) {
1282                 const speed = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), true);
1283                 td.textContent = speed;
1284                 td.title = speed;
1285             };
1287             // upspeed
1288             this.columns["upspeed"].updateTd = this.columns["dlspeed"].updateTd;
1290             // eta
1291             this.columns["eta"].updateTd = function(td, row) {
1292                 const eta = window.qBittorrent.Misc.friendlyDuration(this.getRowValue(row), window.qBittorrent.Misc.MAX_ETA);
1293                 td.textContent = eta;
1294                 td.title = eta;
1295             };
1297             // ratio
1298             this.columns["ratio"].updateTd = function(td, row) {
1299                 const ratio = this.getRowValue(row);
1300                 const string = (ratio === -1) ? "∞" : window.qBittorrent.Misc.toFixedPointString(ratio, 2);
1301                 td.textContent = string;
1302                 td.title = string;
1303             };
1305             // popularity
1306             this.columns["popularity"].updateTd = function(td, row) {
1307                 const value = this.getRowValue(row);
1308                 const popularity = (value === -1) ? "∞" : window.qBittorrent.Misc.toFixedPointString(value, 2);
1309                 td.textContent = popularity;
1310                 td.title = popularity;
1311             };
1313             // added on
1314             this.columns["added_on"].updateTd = function(td, row) {
1315                 const date = new Date(this.getRowValue(row) * 1000).toLocaleString();
1316                 td.textContent = date;
1317                 td.title = date;
1318             };
1320             // completion_on
1321             this.columns["completion_on"].updateTd = function(td, row) {
1322                 const val = this.getRowValue(row);
1323                 if ((val === 0xffffffff) || (val < 0)) {
1324                     td.textContent = "";
1325                     td.title = "";
1326                 }
1327                 else {
1328                     const date = new Date(this.getRowValue(row) * 1000).toLocaleString();
1329                     td.textContent = date;
1330                     td.title = date;
1331                 }
1332             };
1334             // tracker
1335             this.columns["tracker"].updateTd = function(td, row) {
1336                 const value = this.getRowValue(row);
1337                 const tracker = displayFullURLTrackerColumn ? value : window.qBittorrent.Misc.getHost(value);
1338                 td.textContent = tracker;
1339                 td.title = value;
1340             };
1342             //  dl_limit, up_limit
1343             this.columns["dl_limit"].updateTd = function(td, row) {
1344                 const speed = this.getRowValue(row);
1345                 if (speed === 0) {
1346                     td.textContent = "∞";
1347                     td.title = "∞";
1348                 }
1349                 else {
1350                     const formattedSpeed = window.qBittorrent.Misc.friendlyUnit(speed, true);
1351                     td.textContent = formattedSpeed;
1352                     td.title = formattedSpeed;
1353                 }
1354             };
1356             this.columns["up_limit"].updateTd = this.columns["dl_limit"].updateTd;
1358             // downloaded, uploaded, downloaded_session, uploaded_session, amount_left
1359             this.columns["downloaded"].updateTd = this.columns["size"].updateTd;
1360             this.columns["uploaded"].updateTd = this.columns["size"].updateTd;
1361             this.columns["downloaded_session"].updateTd = this.columns["size"].updateTd;
1362             this.columns["uploaded_session"].updateTd = this.columns["size"].updateTd;
1363             this.columns["amount_left"].updateTd = this.columns["size"].updateTd;
1365             // time active
1366             this.columns["time_active"].updateTd = function(td, row) {
1367                 const activeTime = this.getRowValue(row, 0);
1368                 const seedingTime = this.getRowValue(row, 1);
1369                 const time = (seedingTime > 0)
1370                     ? ("QBT_TR(%1 (seeded for %2))QBT_TR[CONTEXT=TransferListDelegate]"
1371                         .replace("%1", window.qBittorrent.Misc.friendlyDuration(activeTime))
1372                         .replace("%2", window.qBittorrent.Misc.friendlyDuration(seedingTime)))
1373                     : window.qBittorrent.Misc.friendlyDuration(activeTime);
1374                 td.textContent = time;
1375                 td.title = time;
1376             };
1378             // completed
1379             this.columns["completed"].updateTd = this.columns["size"].updateTd;
1381             // max_ratio
1382             this.columns["max_ratio"].updateTd = this.columns["ratio"].updateTd;
1384             // seen_complete
1385             this.columns["seen_complete"].updateTd = this.columns["completion_on"].updateTd;
1387             // last_activity
1388             this.columns["last_activity"].updateTd = function(td, row) {
1389                 const val = this.getRowValue(row);
1390                 if (val < 1) {
1391                     td.textContent = "∞";
1392                     td.title = "∞";
1393                 }
1394                 else {
1395                     const formattedVal = "QBT_TR(%1 ago)QBT_TR[CONTEXT=TransferListDelegate]".replace("%1", window.qBittorrent.Misc.friendlyDuration((new Date() / 1000) - val));
1396                     td.textContent = formattedVal;
1397                     td.title = formattedVal;
1398                 }
1399             };
1401             // availability
1402             this.columns["availability"].updateTd = function(td, row) {
1403                 const value = window.qBittorrent.Misc.toFixedPointString(this.getRowValue(row), 3);
1404                 td.textContent = value;
1405                 td.title = value;
1406             };
1408             // infohash_v1
1409             this.columns["infohash_v1"].updateTd = function(td, row) {
1410                 const sourceInfohashV1 = this.getRowValue(row);
1411                 const infohashV1 = (sourceInfohashV1 !== "") ? sourceInfohashV1 : "QBT_TR(N/A)QBT_TR[CONTEXT=TransferListDelegate]";
1412                 td.textContent = infohashV1;
1413                 td.title = infohashV1;
1414             };
1416             // infohash_v2
1417             this.columns["infohash_v2"].updateTd = function(td, row) {
1418                 const sourceInfohashV2 = this.getRowValue(row);
1419                 const infohashV2 = (sourceInfohashV2 !== "") ? sourceInfohashV2 : "QBT_TR(N/A)QBT_TR[CONTEXT=TransferListDelegate]";
1420                 td.textContent = infohashV2;
1421                 td.title = infohashV2;
1422             };
1424             // reannounce
1425             this.columns["reannounce"].updateTd = function(td, row) {
1426                 const time = window.qBittorrent.Misc.friendlyDuration(this.getRowValue(row));
1427                 td.textContent = time;
1428                 td.title = time;
1429             };
1431             // private
1432             this.columns["private"].updateTd = function(td, row) {
1433                 const hasMetadata = row["full_data"].has_metadata;
1434                 const isPrivate = this.getRowValue(row);
1435                 const string = hasMetadata
1436                     ? (isPrivate
1437                         ? "QBT_TR(Yes)QBT_TR[CONTEXT=PropertiesWidget]"
1438                         : "QBT_TR(No)QBT_TR[CONTEXT=PropertiesWidget]")
1439                     : "QBT_TR(N/A)QBT_TR[CONTEXT=PropertiesWidget]";
1440                 td.textContent = string;
1441                 td.title = string;
1442             };
1443         },
1445         applyFilter: (row, filterName, categoryHash, tagHash, trackerHash, filterTerms) => {
1446             const state = row["full_data"].state;
1447             let inactive = false;
1449             switch (filterName) {
1450                 case "downloading":
1451                     if ((state !== "downloading") && !state.includes("DL"))
1452                         return false;
1453                     break;
1454                 case "seeding":
1455                     if ((state !== "uploading") && (state !== "forcedUP") && (state !== "stalledUP") && (state !== "queuedUP") && (state !== "checkingUP"))
1456                         return false;
1457                     break;
1458                 case "completed":
1459                     if ((state !== "uploading") && !state.includes("UP"))
1460                         return false;
1461                     break;
1462                 case "stopped":
1463                     if (!state.includes("stopped"))
1464                         return false;
1465                     break;
1466                 case "running":
1467                     if (state.includes("stopped"))
1468                         return false;
1469                     break;
1470                 case "stalled":
1471                     if ((state !== "stalledUP") && (state !== "stalledDL"))
1472                         return false;
1473                     break;
1474                 case "stalled_uploading":
1475                     if (state !== "stalledUP")
1476                         return false;
1477                     break;
1478                 case "stalled_downloading":
1479                     if (state !== "stalledDL")
1480                         return false;
1481                     break;
1482                 case "inactive":
1483                     inactive = true;
1484                     // fallthrough
1485                 case "active": {
1486                     let r;
1487                     if (state === "stalledDL")
1488                         r = (row["full_data"].upspeed > 0);
1489                     else
1490                         r = (state === "metaDL") || (state === "forcedMetaDL") || (state === "downloading") || (state === "forcedDL") || (state === "uploading") || (state === "forcedUP");
1491                     if (r === inactive)
1492                         return false;
1493                     break;
1494                 }
1495                 case "checking":
1496                     if ((state !== "checkingUP") && (state !== "checkingDL") && (state !== "checkingResumeData"))
1497                         return false;
1498                     break;
1499                 case "moving":
1500                     if (state !== "moving")
1501                         return false;
1502                     break;
1503                 case "errored":
1504                     if ((state !== "error") && (state !== "unknown") && (state !== "missingFiles"))
1505                         return false;
1506                     break;
1507             }
1509             switch (categoryHash) {
1510                 case CATEGORIES_ALL:
1511                     break; // do nothing
1512                 case CATEGORIES_UNCATEGORIZED:
1513                     if (row["full_data"].category.length !== 0)
1514                         return false;
1515                     break; // do nothing
1516                 default:
1517                     if (!useSubcategories) {
1518                         if (categoryHash !== window.qBittorrent.Misc.genHash(row["full_data"].category))
1519                             return false;
1520                     }
1521                     else {
1522                         const selectedCategory = category_list.get(categoryHash);
1523                         if (selectedCategory !== undefined) {
1524                             const selectedCategoryName = selectedCategory.name + "/";
1525                             const torrentCategoryName = row["full_data"].category + "/";
1526                             if (!torrentCategoryName.startsWith(selectedCategoryName))
1527                                 return false;
1528                         }
1529                     }
1530                     break;
1531             }
1533             switch (tagHash) {
1534                 case TAGS_ALL:
1535                     break; // do nothing
1537                 case TAGS_UNTAGGED:
1538                     if (row["full_data"].tags.length !== 0)
1539                         return false;
1540                     break; // do nothing
1542                 default: {
1543                     const tagHashes = row["full_data"].tags.split(", ").map(tag => window.qBittorrent.Misc.genHash(tag));
1544                     if (!tagHashes.contains(tagHash))
1545                         return false;
1546                     break;
1547                 }
1548             }
1550             switch (trackerHash) {
1551                 case TRACKERS_ALL:
1552                     break; // do nothing
1553                 case TRACKERS_TRACKERLESS:
1554                     if (row["full_data"].trackers_count !== 0)
1555                         return false;
1556                     break;
1557                 default: {
1558                     const tracker = trackerList.get(trackerHash);
1559                     if (tracker) {
1560                         let found = false;
1561                         for (const torrents of tracker.trackerTorrentMap.values()) {
1562                             if (torrents.has(row["full_data"].rowId)) {
1563                                 found = true;
1564                                 break;
1565                             }
1566                         }
1567                         if (!found)
1568                             return false;
1569                     }
1570                     break;
1571                 }
1572             }
1574             if ((filterTerms !== undefined) && (filterTerms !== null)) {
1575                 const filterBy = document.getElementById("torrentsFilterSelect").value;
1576                 const textToSearch = row["full_data"][filterBy].toLowerCase();
1577                 if (filterTerms instanceof RegExp) {
1578                     if (!filterTerms.test(textToSearch))
1579                         return false;
1580                 }
1581                 else {
1582                     if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(textToSearch, filterTerms))
1583                         return false;
1584                 }
1585             }
1587             return true;
1588         },
1590         getFilteredTorrentsNumber: function(filterName, categoryHash, tagHash, trackerHash) {
1591             let cnt = 0;
1593             for (const row of this.rows.values()) {
1594                 if (this.applyFilter(row, filterName, categoryHash, tagHash, trackerHash, null))
1595                     ++cnt;
1596             }
1597             return cnt;
1598         },
1600         getFilteredTorrentsHashes: function(filterName, categoryHash, tagHash, trackerHash) {
1601             const rowsHashes = [];
1602             const useRegex = document.getElementById("torrentsFilterRegexBox").checked;
1603             const filterText = document.getElementById("torrentsFilterInput").value.trim().toLowerCase();
1604             let filterTerms;
1605             try {
1606                 filterTerms = (filterText.length > 0)
1607                     ? (useRegex ? new RegExp(filterText) : filterText.split(" "))
1608                     : null;
1609             }
1610             catch (e) { // SyntaxError: Invalid regex pattern
1611                 return filteredRows;
1612             }
1614             for (const row of this.rows.values()) {
1615                 if (this.applyFilter(row, filterName, categoryHash, tagHash, trackerHash, filterTerms))
1616                     rowsHashes.push(row["rowId"]);
1617             }
1619             return rowsHashes;
1620         },
1622         getFilteredAndSortedRows: function() {
1623             const filteredRows = [];
1625             const useRegex = $("torrentsFilterRegexBox").checked;
1626             const filterText = $("torrentsFilterInput").value.trim().toLowerCase();
1627             let filterTerms;
1628             try {
1629                 filterTerms = (filterText.length > 0)
1630                     ? (useRegex ? new RegExp(filterText) : filterText.split(" "))
1631                     : null;
1632             }
1633             catch (e) { // SyntaxError: Invalid regex pattern
1634                 return filteredRows;
1635             }
1637             for (const row of this.rows.values()) {
1638                 if (this.applyFilter(row, selectedStatus, selectedCategory, selectedTag, selectedTracker, filterTerms)) {
1639                     filteredRows.push(row);
1640                     filteredRows[row.rowId] = row;
1641                 }
1642             }
1644             filteredRows.sort((row1, row2) => {
1645                 const column = this.columns[this.sortedColumn];
1646                 const res = column.compareRows(row1, row2);
1647                 if (this.reverseSort === "0")
1648                     return res;
1649                 else
1650                     return -res;
1651             });
1652             return filteredRows;
1653         },
1655         setupTr: function(tr) {
1656             tr.addEventListener("dblclick", function(e) {
1657                 e.preventDefault();
1658                 e.stopPropagation();
1660                 this._this.deselectAll();
1661                 this._this.selectRow(this.rowId);
1662                 const row = this._this.rows.get(this.rowId);
1663                 const state = row["full_data"].state;
1665                 const prefKey =
1666                     (state !== "uploading")
1667                     && (state !== "stoppedUP")
1668                     && (state !== "forcedUP")
1669                     && (state !== "stalledUP")
1670                     && (state !== "queuedUP")
1671                     && (state !== "checkingUP")
1672                     ? "dblclick_download"
1673                     : "dblclick_complete";
1675                 if (LocalPreferences.get(prefKey, "1") !== "1")
1676                     return true;
1678                 if (state.includes("stopped"))
1679                     startFN();
1680                 else
1681                     stopFN();
1682                 return true;
1683             });
1684             tr.addClass("torrentsTableContextMenuTarget");
1685         },
1687         getCurrentTorrentID: function() {
1688             return this.getSelectedRowId();
1689         },
1691         onSelectedRowChanged: () => {
1692             updatePropertiesPanel();
1693         }
1694     });
1696     const TorrentPeersTable = new Class({
1697         Extends: DynamicTable,
1699         initColumns: function() {
1700             this.newColumn("country", "", "QBT_TR(Country/Region)QBT_TR[CONTEXT=PeerListWidget]", 22, true);
1701             this.newColumn("ip", "", "QBT_TR(IP)QBT_TR[CONTEXT=PeerListWidget]", 80, true);
1702             this.newColumn("port", "", "QBT_TR(Port)QBT_TR[CONTEXT=PeerListWidget]", 35, true);
1703             this.newColumn("connection", "", "QBT_TR(Connection)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1704             this.newColumn("flags", "", "QBT_TR(Flags)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1705             this.newColumn("client", "", "QBT_TR(Client)QBT_TR[CONTEXT=PeerListWidget]", 140, true);
1706             this.newColumn("peer_id_client", "", "QBT_TR(Peer ID Client)QBT_TR[CONTEXT=PeerListWidget]", 60, false);
1707             this.newColumn("progress", "", "QBT_TR(Progress)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1708             this.newColumn("dl_speed", "", "QBT_TR(Down Speed)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1709             this.newColumn("up_speed", "", "QBT_TR(Up Speed)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1710             this.newColumn("downloaded", "", "QBT_TR(Downloaded)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1711             this.newColumn("uploaded", "", "QBT_TR(Uploaded)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
1712             this.newColumn("relevance", "", "QBT_TR(Relevance)QBT_TR[CONTEXT=PeerListWidget]", 30, true);
1713             this.newColumn("files", "", "QBT_TR(Files)QBT_TR[CONTEXT=PeerListWidget]", 100, true);
1715             this.columns["country"].dataProperties.push("country_code");
1716             this.columns["flags"].dataProperties.push("flags_desc");
1717             this.initColumnsFunctions();
1718         },
1720         initColumnsFunctions: function() {
1722             // country
1723             this.columns["country"].updateTd = function(td, row) {
1724                 const country = this.getRowValue(row, 0);
1725                 const country_code = this.getRowValue(row, 1);
1727                 let span = td.firstElementChild;
1728                 if (span === null) {
1729                     span = document.createElement("span");
1730                     span.classList.add("flags");
1731                     td.append(span);
1732                 }
1734                 span.style.backgroundImage = `url('images/flags/${country_code ?? "xx"}.svg')`;
1735                 span.textContent = country;
1736                 td.title = country;
1737             };
1739             // ip
1740             this.columns["ip"].compareRows = function(row1, row2) {
1741                 const ip1 = this.getRowValue(row1);
1742                 const ip2 = this.getRowValue(row2);
1744                 const a = ip1.split(".");
1745                 const b = ip2.split(".");
1747                 for (let i = 0; i < 4; ++i) {
1748                     if (a[i] !== b[i])
1749                         return a[i] - b[i];
1750                 }
1752                 return 0;
1753             };
1755             // flags
1756             this.columns["flags"].updateTd = function(td, row) {
1757                 td.textContent = this.getRowValue(row, 0);
1758                 td.title = this.getRowValue(row, 1);
1759             };
1761             // progress
1762             this.columns["progress"].updateTd = function(td, row) {
1763                 const progress = this.getRowValue(row);
1764                 let progressFormatted = (progress * 100).round(1);
1765                 if ((progressFormatted === 100.0) && (progress !== 1.0))
1766                     progressFormatted = 99.9;
1767                 progressFormatted += "%";
1768                 td.textContent = progressFormatted;
1769                 td.title = progressFormatted;
1770             };
1772             // dl_speed, up_speed
1773             this.columns["dl_speed"].updateTd = function(td, row) {
1774                 const speed = this.getRowValue(row);
1775                 if (speed === 0) {
1776                     td.textContent = "";
1777                     td.title = "";
1778                 }
1779                 else {
1780                     const formattedSpeed = window.qBittorrent.Misc.friendlyUnit(speed, true);
1781                     td.textContent = formattedSpeed;
1782                     td.title = formattedSpeed;
1783                 }
1784             };
1785             this.columns["up_speed"].updateTd = this.columns["dl_speed"].updateTd;
1787             // downloaded, uploaded
1788             this.columns["downloaded"].updateTd = function(td, row) {
1789                 const downloaded = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false);
1790                 td.textContent = downloaded;
1791                 td.title = downloaded;
1792             };
1793             this.columns["uploaded"].updateTd = this.columns["downloaded"].updateTd;
1795             // relevance
1796             this.columns["relevance"].updateTd = this.columns["progress"].updateTd;
1797             this.columns["relevance"].staticWidth = 100;
1799             // files
1800             this.columns["files"].updateTd = function(td, row) {
1801                 const value = this.getRowValue(row, 0);
1802                 td.textContent = value.replace(/\n/g, ";");
1803                 td.title = value;
1804             };
1806         }
1807     });
1809     const SearchResultsTable = new Class({
1810         Extends: DynamicTable,
1812         initColumns: function() {
1813             this.newColumn("fileName", "", "QBT_TR(Name)QBT_TR[CONTEXT=SearchResultsTable]", 500, true);
1814             this.newColumn("fileSize", "", "QBT_TR(Size)QBT_TR[CONTEXT=SearchResultsTable]", 100, true);
1815             this.newColumn("nbSeeders", "", "QBT_TR(Seeders)QBT_TR[CONTEXT=SearchResultsTable]", 100, true);
1816             this.newColumn("nbLeechers", "", "QBT_TR(Leechers)QBT_TR[CONTEXT=SearchResultsTable]", 100, true);
1817             this.newColumn("engineName", "", "QBT_TR(Engine)QBT_TR[CONTEXT=SearchResultsTable]", 100, true);
1818             this.newColumn("siteUrl", "", "QBT_TR(Engine URL)QBT_TR[CONTEXT=SearchResultsTable]", 250, true);
1819             this.newColumn("pubDate", "", "QBT_TR(Published On)QBT_TR[CONTEXT=SearchResultsTable]", 200, true);
1821             this.initColumnsFunctions();
1822         },
1824         initColumnsFunctions: function() {
1825             const displaySize = function(td, row) {
1826                 const size = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false);
1827                 td.textContent = size;
1828                 td.title = size;
1829             };
1830             const displayNum = function(td, row) {
1831                 const value = this.getRowValue(row);
1832                 const formattedValue = (value === "-1") ? "Unknown" : value;
1833                 td.textContent = formattedValue;
1834                 td.title = formattedValue;
1835             };
1836             const displayDate = function(td, row) {
1837                 const value = this.getRowValue(row) * 1000;
1838                 const formattedValue = (isNaN(value) || (value <= 0)) ? "" : (new Date(value).toLocaleString());
1839                 td.textContent = formattedValue;
1840                 td.title = formattedValue;
1841             };
1843             this.columns["fileSize"].updateTd = displaySize;
1844             this.columns["nbSeeders"].updateTd = displayNum;
1845             this.columns["nbLeechers"].updateTd = displayNum;
1846             this.columns["pubDate"].updateTd = displayDate;
1847         },
1849         getFilteredAndSortedRows: function() {
1850             const getSizeFilters = () => {
1851                 let minSize = (window.qBittorrent.Search.searchSizeFilter.min > 0.00) ? (window.qBittorrent.Search.searchSizeFilter.min * Math.pow(1024, window.qBittorrent.Search.searchSizeFilter.minUnit)) : 0.00;
1852                 let maxSize = (window.qBittorrent.Search.searchSizeFilter.max > 0.00) ? (window.qBittorrent.Search.searchSizeFilter.max * Math.pow(1024, window.qBittorrent.Search.searchSizeFilter.maxUnit)) : 0.00;
1854                 if ((minSize > maxSize) && (maxSize > 0.00)) {
1855                     const tmp = minSize;
1856                     minSize = maxSize;
1857                     maxSize = tmp;
1858                 }
1860                 return {
1861                     min: minSize,
1862                     max: maxSize
1863                 };
1864             };
1866             const getSeedsFilters = () => {
1867                 let minSeeds = (window.qBittorrent.Search.searchSeedsFilter.min > 0) ? window.qBittorrent.Search.searchSeedsFilter.min : 0;
1868                 let maxSeeds = (window.qBittorrent.Search.searchSeedsFilter.max > 0) ? window.qBittorrent.Search.searchSeedsFilter.max : 0;
1870                 if ((minSeeds > maxSeeds) && (maxSeeds > 0)) {
1871                     const tmp = minSeeds;
1872                     minSeeds = maxSeeds;
1873                     maxSeeds = tmp;
1874                 }
1876                 return {
1877                     min: minSeeds,
1878                     max: maxSeeds
1879                 };
1880             };
1882             let filteredRows = [];
1883             const searchTerms = window.qBittorrent.Search.searchText.pattern.toLowerCase().split(" ");
1884             const filterTerms = window.qBittorrent.Search.searchText.filterPattern.toLowerCase().split(" ");
1885             const sizeFilters = getSizeFilters();
1886             const seedsFilters = getSeedsFilters();
1887             const searchInTorrentName = $("searchInTorrentName").value === "names";
1889             if (searchInTorrentName || (filterTerms.length > 0) || (window.qBittorrent.Search.searchSizeFilter.min > 0.00) || (window.qBittorrent.Search.searchSizeFilter.max > 0.00)) {
1890                 for (const row of this.getRowValues()) {
1892                     if (searchInTorrentName && !window.qBittorrent.Misc.containsAllTerms(row.full_data.fileName, searchTerms))
1893                         continue;
1894                     if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(row.full_data.fileName, filterTerms))
1895                         continue;
1896                     if ((sizeFilters.min > 0.00) && (row.full_data.fileSize < sizeFilters.min))
1897                         continue;
1898                     if ((sizeFilters.max > 0.00) && (row.full_data.fileSize > sizeFilters.max))
1899                         continue;
1900                     if ((seedsFilters.min > 0) && (row.full_data.nbSeeders < seedsFilters.min))
1901                         continue;
1902                     if ((seedsFilters.max > 0) && (row.full_data.nbSeeders > seedsFilters.max))
1903                         continue;
1905                     filteredRows.push(row);
1906                 }
1907             }
1908             else {
1909                 filteredRows = [...this.getRowValues()];
1910             }
1912             filteredRows.sort((row1, row2) => {
1913                 const column = this.columns[this.sortedColumn];
1914                 const res = column.compareRows(row1, row2);
1915                 if (this.reverseSort === "0")
1916                     return res;
1917                 else
1918                     return -res;
1919             });
1921             return filteredRows;
1922         },
1924         setupTr: (tr) => {
1925             tr.addClass("searchTableRow");
1926         }
1927     });
1929     const SearchPluginsTable = new Class({
1930         Extends: DynamicTable,
1932         initColumns: function() {
1933             this.newColumn("fullName", "", "QBT_TR(Name)QBT_TR[CONTEXT=SearchPluginsTable]", 175, true);
1934             this.newColumn("version", "", "QBT_TR(Version)QBT_TR[CONTEXT=SearchPluginsTable]", 100, true);
1935             this.newColumn("url", "", "QBT_TR(Url)QBT_TR[CONTEXT=SearchPluginsTable]", 175, true);
1936             this.newColumn("enabled", "", "QBT_TR(Enabled)QBT_TR[CONTEXT=SearchPluginsTable]", 100, true);
1938             this.initColumnsFunctions();
1939         },
1941         initColumnsFunctions: function() {
1942             this.columns["enabled"].updateTd = function(td, row) {
1943                 const value = this.getRowValue(row);
1944                 if (value) {
1945                     td.textContent = "QBT_TR(Yes)QBT_TR[CONTEXT=SearchPluginsTable]";
1946                     td.title = "QBT_TR(Yes)QBT_TR[CONTEXT=SearchPluginsTable]";
1947                     td.getParent("tr").addClass("green");
1948                     td.getParent("tr").removeClass("red");
1949                 }
1950                 else {
1951                     td.textContent = "QBT_TR(No)QBT_TR[CONTEXT=SearchPluginsTable]";
1952                     td.title = "QBT_TR(No)QBT_TR[CONTEXT=SearchPluginsTable]";
1953                     td.getParent("tr").addClass("red");
1954                     td.getParent("tr").removeClass("green");
1955                 }
1956             };
1957         },
1959         setupTr: (tr) => {
1960             tr.addClass("searchPluginsTableRow");
1961         }
1962     });
1964     const TorrentTrackersTable = new Class({
1965         Extends: DynamicTable,
1967         initColumns: function() {
1968             this.newColumn("tier", "", "QBT_TR(Tier)QBT_TR[CONTEXT=TrackerListWidget]", 35, true);
1969             this.newColumn("url", "", "QBT_TR(URL)QBT_TR[CONTEXT=TrackerListWidget]", 250, true);
1970             this.newColumn("status", "", "QBT_TR(Status)QBT_TR[CONTEXT=TrackerListWidget]", 125, true);
1971             this.newColumn("peers", "", "QBT_TR(Peers)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
1972             this.newColumn("seeds", "", "QBT_TR(Seeds)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
1973             this.newColumn("leeches", "", "QBT_TR(Leeches)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
1974             this.newColumn("downloaded", "", "QBT_TR(Times Downloaded)QBT_TR[CONTEXT=TrackerListWidget]", 100, true);
1975             this.newColumn("message", "", "QBT_TR(Message)QBT_TR[CONTEXT=TrackerListWidget]", 250, true);
1977             this.initColumnsFunctions();
1978         },
1980         initColumnsFunctions: function() {
1981             const naturalSort = function(row1, row2) {
1982                 if (!row1.full_data._sortable || !row2.full_data._sortable)
1983                     return 0;
1985                 const value1 = this.getRowValue(row1);
1986                 const value2 = this.getRowValue(row2);
1987                 return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
1988             };
1990             this.columns["url"].compareRows = naturalSort;
1991             this.columns["status"].compareRows = naturalSort;
1992             this.columns["message"].compareRows = naturalSort;
1994             const sortNumbers = function(row1, row2) {
1995                 if (!row1.full_data._sortable || !row2.full_data._sortable)
1996                     return 0;
1998                 const value1 = this.getRowValue(row1);
1999                 const value2 = this.getRowValue(row2);
2000                 if (value1 === "")
2001                     return -1;
2002                 if (value2 === "")
2003                     return 1;
2004                 return compareNumbers(value1, value2);
2005             };
2007             this.columns["tier"].compareRows = sortNumbers;
2009             const sortMixed = function(row1, row2) {
2010                 if (!row1.full_data._sortable || !row2.full_data._sortable)
2011                     return 0;
2013                 const value1 = this.getRowValue(row1);
2014                 const value2 = this.getRowValue(row2);
2015                 if (value1 === "QBT_TR(N/A)QBT_TR[CONTEXT=TrackerListWidget]")
2016                     return -1;
2017                 if (value2 === "QBT_TR(N/A)QBT_TR[CONTEXT=TrackerListWidget]")
2018                     return 1;
2019                 return compareNumbers(value1, value2);
2020             };
2022             this.columns["peers"].compareRows = sortMixed;
2023             this.columns["seeds"].compareRows = sortMixed;
2024             this.columns["leeches"].compareRows = sortMixed;
2025             this.columns["downloaded"].compareRows = sortMixed;
2026         }
2027     });
2029     const BulkRenameTorrentFilesTable = new Class({
2030         Extends: DynamicTable,
2032         filterTerms: [],
2033         prevFilterTerms: [],
2034         prevRowsString: null,
2035         prevFilteredRows: [],
2036         prevSortedColumn: null,
2037         prevReverseSort: null,
2038         fileTree: new window.qBittorrent.FileTree.FileTree(),
2040         populateTable: function(root) {
2041             this.fileTree.setRoot(root);
2042             root.children.each((node) => {
2043                 this._addNodeToTable(node, 0);
2044             });
2045         },
2047         _addNodeToTable: function(node, depth) {
2048             node.depth = depth;
2050             if (node.isFolder) {
2051                 const data = {
2052                     rowId: node.rowId,
2053                     fileId: -1,
2054                     checked: node.checked,
2055                     path: node.path,
2056                     original: node.original,
2057                     renamed: node.renamed
2058                 };
2060                 node.data = data;
2061                 node.full_data = data;
2062                 this.updateRowData(data);
2063             }
2064             else {
2065                 node.data.rowId = node.rowId;
2066                 node.full_data = node.data;
2067                 this.updateRowData(node.data);
2068             }
2070             node.children.each((child) => {
2071                 this._addNodeToTable(child, depth + 1);
2072             });
2073         },
2075         getRoot: function() {
2076             return this.fileTree.getRoot();
2077         },
2079         getNode: function(rowId) {
2080             return this.fileTree.getNode(rowId);
2081         },
2083         getRow: function(node) {
2084             const rowId = this.fileTree.getRowId(node).toString();
2085             return this.rows.get(rowId);
2086         },
2088         getSelectedRows: function() {
2089             const nodes = this.fileTree.toArray();
2091             return nodes.filter(x => x.checked === 0);
2092         },
2094         initColumns: function() {
2095             // Blocks saving header width (because window width isn't saved)
2096             LocalPreferences.remove("column_" + "checked" + "_width_" + this.dynamicTableDivId);
2097             LocalPreferences.remove("column_" + "original" + "_width_" + this.dynamicTableDivId);
2098             LocalPreferences.remove("column_" + "renamed" + "_width_" + this.dynamicTableDivId);
2099             this.newColumn("checked", "", "", 50, true);
2100             this.newColumn("original", "", "QBT_TR(Original)QBT_TR[CONTEXT=TrackerListWidget]", 270, true);
2101             this.newColumn("renamed", "", "QBT_TR(Renamed)QBT_TR[CONTEXT=TrackerListWidget]", 220, true);
2103             this.initColumnsFunctions();
2104         },
2106         /**
2107          * Toggles the global checkbox and all checkboxes underneath
2108          */
2109         toggleGlobalCheckbox: function() {
2110             const checkbox = $("rootMultiRename_cb");
2111             const checkboxes = $$("input.RenamingCB");
2113             for (let i = 0; i < checkboxes.length; ++i) {
2114                 const node = this.getNode(i);
2116                 if (checkbox.checked || checkbox.indeterminate) {
2117                     const cb = checkboxes[i];
2118                     cb.checked = true;
2119                     cb.indeterminate = false;
2120                     cb.state = "checked";
2121                     node.checked = 0;
2122                     node.full_data.checked = node.checked;
2123                 }
2124                 else {
2125                     const cb = checkboxes[i];
2126                     cb.checked = false;
2127                     cb.indeterminate = false;
2128                     cb.state = "unchecked";
2129                     node.checked = 1;
2130                     node.full_data.checked = node.checked;
2131                 }
2132             }
2134             this.updateGlobalCheckbox();
2135         },
2137         toggleNodeTreeCheckbox: function(rowId, checkState) {
2138             const node = this.getNode(rowId);
2139             node.checked = checkState;
2140             node.full_data.checked = checkState;
2141             const checkbox = $(`cbRename${rowId}`);
2142             checkbox.checked = node.checked === 0;
2143             checkbox.state = checkbox.checked ? "checked" : "unchecked";
2145             for (let i = 0; i < node.children.length; ++i)
2146                 this.toggleNodeTreeCheckbox(node.children[i].rowId, checkState);
2147         },
2149         updateGlobalCheckbox: () => {
2150             const checkbox = $("rootMultiRename_cb");
2151             const checkboxes = $$("input.RenamingCB");
2152             const isAllChecked = () => {
2153                 for (let i = 0; i < checkboxes.length; ++i) {
2154                     if (!checkboxes[i].checked)
2155                         return false;
2156                 }
2157                 return true;
2158             };
2159             const isAllUnchecked = () => {
2160                 for (let i = 0; i < checkboxes.length; ++i) {
2161                     if (checkboxes[i].checked)
2162                         return false;
2163                 }
2164                 return true;
2165             };
2166             if (isAllChecked()) {
2167                 checkbox.state = "checked";
2168                 checkbox.indeterminate = false;
2169                 checkbox.checked = true;
2170             }
2171             else if (isAllUnchecked()) {
2172                 checkbox.state = "unchecked";
2173                 checkbox.indeterminate = false;
2174                 checkbox.checked = false;
2175             }
2176             else {
2177                 checkbox.state = "partial";
2178                 checkbox.indeterminate = true;
2179                 checkbox.checked = false;
2180             }
2181         },
2183         initColumnsFunctions: function() {
2184             const that = this;
2186             // checked
2187             this.columns["checked"].updateTd = function(td, row) {
2188                 const id = row.rowId;
2189                 const value = this.getRowValue(row);
2191                 const treeImg = new Element("img", {
2192                     src: "images/L.gif",
2193                     styles: {
2194                         "margin-bottom": -2
2195                     }
2196                 });
2197                 const checkbox = new Element("input");
2198                 checkbox.type = "checkbox";
2199                 checkbox.id = "cbRename" + id;
2200                 checkbox.setAttribute("data-id", id);
2201                 checkbox.className = "RenamingCB";
2202                 checkbox.addEventListener("click", (e) => {
2203                     const node = that.getNode(id);
2204                     node.checked = e.target.checked ? 0 : 1;
2205                     node.full_data.checked = node.checked;
2206                     that.updateGlobalCheckbox();
2207                     that.onRowSelectionChange(node);
2208                     e.stopPropagation();
2209                 });
2210                 checkbox.checked = (value === 0);
2211                 checkbox.state = checkbox.checked ? "checked" : "unchecked";
2212                 checkbox.indeterminate = false;
2213                 td.adopt(treeImg, checkbox);
2214             };
2215             this.columns["checked"].staticWidth = 50;
2217             // original
2218             this.columns["original"].updateTd = function(td, row) {
2219                 const id = row.rowId;
2220                 const fileNameId = "filesTablefileName" + id;
2221                 const node = that.getNode(id);
2223                 if (node.isFolder) {
2224                     const value = this.getRowValue(row);
2225                     const dirImgId = "renameTableDirImg" + id;
2226                     if ($(dirImgId)) {
2227                         // just update file name
2228                         $(fileNameId).textContent = value;
2229                     }
2230                     else {
2231                         const span = new Element("span", {
2232                             text: value,
2233                             id: fileNameId
2234                         });
2235                         const dirImg = new Element("img", {
2236                             src: "images/directory.svg",
2237                             styles: {
2238                                 "width": 20,
2239                                 "padding-right": 5,
2240                                 "margin-bottom": -3,
2241                                 "margin-left": (node.depth * 20)
2242                             },
2243                             id: dirImgId
2244                         });
2245                         td.replaceChildren(dirImg, span);
2246                     }
2247                 }
2248                 else { // is file
2249                     const value = this.getRowValue(row);
2250                     const span = new Element("span", {
2251                         text: value,
2252                         id: fileNameId,
2253                         styles: {
2254                             "margin-left": ((node.depth + 1) * 20)
2255                         }
2256                     });
2257                     td.replaceChildren(span);
2258                 }
2259             };
2261             // renamed
2262             this.columns["renamed"].updateTd = function(td, row) {
2263                 const id = row.rowId;
2264                 const fileNameRenamedId = "filesTablefileRenamed" + id;
2265                 const value = this.getRowValue(row);
2267                 const span = new Element("span", {
2268                     text: value,
2269                     id: fileNameRenamedId,
2270                 });
2271                 td.replaceChildren(span);
2272             };
2273         },
2275         onRowSelectionChange: (row) => {},
2277         selectRow: () => {
2278             return;
2279         },
2281         reselectRows: function(rowIds) {
2282             const that = this;
2283             this.deselectAll();
2284             this.tableBody.getElements("tr").each((tr) => {
2285                 if (rowIds.includes(tr.rowId)) {
2286                     const node = that.getNode(tr.rowId);
2287                     node.checked = 0;
2288                     node.full_data.checked = 0;
2290                     const checkbox = tr.children[0].getElement("input");
2291                     checkbox.state = "checked";
2292                     checkbox.indeterminate = false;
2293                     checkbox.checked = true;
2294                 }
2295             });
2297             this.updateGlobalCheckbox();
2298         },
2300         _sortNodesByColumn: function(nodes, column) {
2301             nodes.sort((row1, row2) => {
2302                 // list folders before files when sorting by name
2303                 if (column.name === "original") {
2304                     const node1 = this.getNode(row1.data.rowId);
2305                     const node2 = this.getNode(row2.data.rowId);
2306                     if (node1.isFolder && !node2.isFolder)
2307                         return -1;
2308                     if (node2.isFolder && !node1.isFolder)
2309                         return 1;
2310                 }
2312                 const res = column.compareRows(row1, row2);
2313                 return (this.reverseSort === "0") ? res : -res;
2314             });
2316             nodes.each((node) => {
2317                 if (node.children.length > 0)
2318                     this._sortNodesByColumn(node.children, column);
2319             });
2320         },
2322         _filterNodes: function(node, filterTerms, filteredRows) {
2323             if (node.isFolder) {
2324                 const childAdded = node.children.reduce((acc, child) => {
2325                     // we must execute the function before ORing w/ acc or we'll stop checking child nodes after the first successful match
2326                     return (this._filterNodes(child, filterTerms, filteredRows) || acc);
2327                 }, false);
2329                 if (childAdded) {
2330                     const row = this.getRow(node);
2331                     filteredRows.push(row);
2332                     return true;
2333                 }
2334             }
2336             if (window.qBittorrent.Misc.containsAllTerms(node.original, filterTerms)) {
2337                 const row = this.getRow(node);
2338                 filteredRows.push(row);
2339                 return true;
2340             }
2342             return false;
2343         },
2345         setFilter: function(text) {
2346             const filterTerms = text.trim().toLowerCase().split(" ");
2347             if ((filterTerms.length === 1) && (filterTerms[0] === ""))
2348                 this.filterTerms = [];
2349             else
2350                 this.filterTerms = filterTerms;
2351         },
2353         getFilteredAndSortedRows: function() {
2354             if (this.getRoot() === null)
2355                 return [];
2357             const generateRowsSignature = () => {
2358                 const rowsData = [];
2359                 for (const { full_data } of this.getRowValues())
2360                     rowsData.push(full_data);
2361                 return JSON.stringify(rowsData);
2362             };
2364             const getFilteredRows = function() {
2365                 if (this.filterTerms.length === 0) {
2366                     const nodeArray = this.fileTree.toArray();
2367                     const filteredRows = nodeArray.map((node) => {
2368                         return this.getRow(node);
2369                     });
2370                     return filteredRows;
2371                 }
2373                 const filteredRows = [];
2374                 this.getRoot().children.each((child) => {
2375                     this._filterNodes(child, this.filterTerms, filteredRows);
2376                 });
2377                 filteredRows.reverse();
2378                 return filteredRows;
2379             }.bind(this);
2381             const hasRowsChanged = function(rowsString, prevRowsStringString) {
2382                 const rowsChanged = (rowsString !== prevRowsStringString);
2383                 const isFilterTermsChanged = this.filterTerms.reduce((acc, term, index) => {
2384                     return (acc || (term !== this.prevFilterTerms[index]));
2385                 }, false);
2386                 const isFilterChanged = ((this.filterTerms.length !== this.prevFilterTerms.length)
2387                     || ((this.filterTerms.length > 0) && isFilterTermsChanged));
2388                 const isSortedColumnChanged = (this.prevSortedColumn !== this.sortedColumn);
2389                 const isReverseSortChanged = (this.prevReverseSort !== this.reverseSort);
2391                 return (rowsChanged || isFilterChanged || isSortedColumnChanged || isReverseSortChanged);
2392             }.bind(this);
2394             const rowsString = generateRowsSignature();
2395             if (!hasRowsChanged(rowsString, this.prevRowsString))
2396                 return this.prevFilteredRows;
2398             // sort, then filter
2399             const column = this.columns[this.sortedColumn];
2400             this._sortNodesByColumn(this.getRoot().children, column);
2401             const filteredRows = getFilteredRows();
2403             this.prevFilterTerms = this.filterTerms;
2404             this.prevRowsString = rowsString;
2405             this.prevFilteredRows = filteredRows;
2406             this.prevSortedColumn = this.sortedColumn;
2407             this.prevReverseSort = this.reverseSort;
2408             return filteredRows;
2409         },
2411         setIgnored: function(rowId, ignore) {
2412             const row = this.rows.get(rowId);
2413             if (ignore)
2414                 row.full_data.remaining = 0;
2415             else
2416                 row.full_data.remaining = (row.full_data.size * (1.0 - (row.full_data.progress / 100)));
2417         },
2419         setupTr: function(tr) {
2420             tr.addEventListener("keydown", function(event) {
2421                 switch (event.key) {
2422                     case "ArrowLeft":
2423                         qBittorrent.PropFiles.collapseFolder(this._this.getSelectedRowId());
2424                         return false;
2425                     case "ArrowRight":
2426                         qBittorrent.PropFiles.expandFolder(this._this.getSelectedRowId());
2427                         return false;
2428                 }
2429             });
2430         }
2431     });
2433     const TorrentFilesTable = new Class({
2434         Extends: DynamicTable,
2436         filterTerms: [],
2437         prevFilterTerms: [],
2438         prevRowsString: null,
2439         prevFilteredRows: [],
2440         prevSortedColumn: null,
2441         prevReverseSort: null,
2442         fileTree: new window.qBittorrent.FileTree.FileTree(),
2444         populateTable: function(root) {
2445             this.fileTree.setRoot(root);
2446             root.children.each((node) => {
2447                 this._addNodeToTable(node, 0);
2448             });
2449         },
2451         _addNodeToTable: function(node, depth) {
2452             node.depth = depth;
2454             if (node.isFolder) {
2455                 const data = {
2456                     rowId: node.rowId,
2457                     size: node.size,
2458                     checked: node.checked,
2459                     remaining: node.remaining,
2460                     progress: node.progress,
2461                     priority: window.qBittorrent.PropFiles.normalizePriority(node.priority),
2462                     availability: node.availability,
2463                     fileId: -1,
2464                     name: node.name
2465                 };
2467                 node.data = data;
2468                 node.full_data = data;
2469                 this.updateRowData(data);
2470             }
2471             else {
2472                 node.data.rowId = node.rowId;
2473                 node.full_data = node.data;
2474                 this.updateRowData(node.data);
2475             }
2477             node.children.each((child) => {
2478                 this._addNodeToTable(child, depth + 1);
2479             });
2480         },
2482         getRoot: function() {
2483             return this.fileTree.getRoot();
2484         },
2486         getNode: function(rowId) {
2487             return this.fileTree.getNode(rowId);
2488         },
2490         getRow: function(node) {
2491             const rowId = this.fileTree.getRowId(node).toString();
2492             return this.rows.get(rowId);
2493         },
2495         initColumns: function() {
2496             this.newColumn("checked", "", "", 50, true);
2497             this.newColumn("name", "", "QBT_TR(Name)QBT_TR[CONTEXT=TrackerListWidget]", 300, true);
2498             this.newColumn("size", "", "QBT_TR(Total Size)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
2499             this.newColumn("progress", "", "QBT_TR(Progress)QBT_TR[CONTEXT=TrackerListWidget]", 100, true);
2500             this.newColumn("priority", "", "QBT_TR(Download Priority)QBT_TR[CONTEXT=TrackerListWidget]", 150, true);
2501             this.newColumn("remaining", "", "QBT_TR(Remaining)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
2502             this.newColumn("availability", "", "QBT_TR(Availability)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
2504             this.initColumnsFunctions();
2505         },
2507         initColumnsFunctions: function() {
2508             const that = this;
2509             const displaySize = function(td, row) {
2510                 const size = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false);
2511                 td.textContent = size;
2512                 td.title = size;
2513             };
2514             const displayPercentage = function(td, row) {
2515                 const value = window.qBittorrent.Misc.friendlyPercentage(this.getRowValue(row));
2516                 td.textContent = value;
2517                 td.title = value;
2518             };
2520             // checked
2521             this.columns["checked"].updateTd = function(td, row) {
2522                 const id = row.rowId;
2523                 const value = this.getRowValue(row);
2525                 if (window.qBittorrent.PropFiles.isDownloadCheckboxExists(id)) {
2526                     window.qBittorrent.PropFiles.updateDownloadCheckbox(id, value);
2527                 }
2528                 else {
2529                     const treeImg = new Element("img", {
2530                         src: "images/L.gif",
2531                         styles: {
2532                             "margin-bottom": -2
2533                         }
2534                     });
2535                     td.adopt(treeImg, window.qBittorrent.PropFiles.createDownloadCheckbox(id, row.full_data.fileId, value));
2536                 }
2537             };
2538             this.columns["checked"].staticWidth = 50;
2540             // name
2541             this.columns["name"].updateTd = function(td, row) {
2542                 const id = row.rowId;
2543                 const fileNameId = "filesTablefileName" + id;
2544                 const node = that.getNode(id);
2546                 if (node.isFolder) {
2547                     const value = this.getRowValue(row);
2548                     const collapseIconId = "filesTableCollapseIcon" + id;
2549                     const dirImgId = "filesTableDirImg" + id;
2550                     if ($(dirImgId)) {
2551                         // just update file name
2552                         $(fileNameId).textContent = value;
2553                     }
2554                     else {
2555                         const collapseIcon = new Element("img", {
2556                             src: "images/go-down.svg",
2557                             styles: {
2558                                 "margin-left": (node.depth * 20)
2559                             },
2560                             class: "filesTableCollapseIcon",
2561                             id: collapseIconId,
2562                             "data-id": id,
2563                             onclick: "qBittorrent.PropFiles.collapseIconClicked(this)"
2564                         });
2565                         const span = new Element("span", {
2566                             text: value,
2567                             id: fileNameId
2568                         });
2569                         const dirImg = new Element("img", {
2570                             src: "images/directory.svg",
2571                             styles: {
2572                                 "width": 20,
2573                                 "padding-right": 5,
2574                                 "margin-bottom": -3
2575                             },
2576                             id: dirImgId
2577                         });
2578                         td.replaceChildren(collapseIcon, dirImg, span);
2579                     }
2580                 }
2581                 else {
2582                     const value = this.getRowValue(row);
2583                     const span = new Element("span", {
2584                         text: value,
2585                         id: fileNameId,
2586                         styles: {
2587                             "margin-left": ((node.depth + 1) * 20)
2588                         }
2589                     });
2590                     td.replaceChildren(span);
2591                 }
2592             };
2593             this.columns["name"].calculateBuffer = (rowId) => {
2594                 const node = that.getNode(rowId);
2595                 // folders add 20px for folder icon and 15px for collapse icon
2596                 const folderBuffer = node.isFolder ? 35 : 0;
2597                 return (node.depth * 20) + folderBuffer;
2598             };
2600             // size
2601             this.columns["size"].updateTd = displaySize;
2603             // progress
2604             this.columns["progress"].updateTd = function(td, row) {
2605                 const id = row.rowId;
2606                 const value = this.getRowValue(row);
2608                 const progressBar = $("pbf_" + id);
2609                 if (progressBar === null) {
2610                     td.adopt(new window.qBittorrent.ProgressBar.ProgressBar(value.toFloat(), {
2611                         id: "pbf_" + id,
2612                         width: 80
2613                     }));
2614                 }
2615                 else {
2616                     progressBar.setValue(value.toFloat());
2617                 }
2618             };
2619             this.columns["progress"].staticWidth = 100;
2621             // priority
2622             this.columns["priority"].updateTd = function(td, row) {
2623                 const id = row.rowId;
2624                 const value = this.getRowValue(row);
2626                 if (window.qBittorrent.PropFiles.isPriorityComboExists(id))
2627                     window.qBittorrent.PropFiles.updatePriorityCombo(id, value);
2628                 else
2629                     td.adopt(window.qBittorrent.PropFiles.createPriorityCombo(id, row.full_data.fileId, value));
2630             };
2631             this.columns["priority"].staticWidth = 140;
2633             // remaining, availability
2634             this.columns["remaining"].updateTd = displaySize;
2635             this.columns["availability"].updateTd = displayPercentage;
2636         },
2638         _sortNodesByColumn: function(nodes, column) {
2639             nodes.sort((row1, row2) => {
2640                 // list folders before files when sorting by name
2641                 if (column.name === "name") {
2642                     const node1 = this.getNode(row1.data.rowId);
2643                     const node2 = this.getNode(row2.data.rowId);
2644                     if (node1.isFolder && !node2.isFolder)
2645                         return -1;
2646                     if (node2.isFolder && !node1.isFolder)
2647                         return 1;
2648                 }
2650                 const res = column.compareRows(row1, row2);
2651                 return (this.reverseSort === "0") ? res : -res;
2652             });
2654             nodes.each((node) => {
2655                 if (node.children.length > 0)
2656                     this._sortNodesByColumn(node.children, column);
2657             });
2658         },
2660         _filterNodes: function(node, filterTerms, filteredRows) {
2661             if (node.isFolder) {
2662                 const childAdded = node.children.reduce((acc, child) => {
2663                     // we must execute the function before ORing w/ acc or we'll stop checking child nodes after the first successful match
2664                     return (this._filterNodes(child, filterTerms, filteredRows) || acc);
2665                 }, false);
2667                 if (childAdded) {
2668                     const row = this.getRow(node);
2669                     filteredRows.push(row);
2670                     return true;
2671                 }
2672             }
2674             if (window.qBittorrent.Misc.containsAllTerms(node.name, filterTerms)) {
2675                 const row = this.getRow(node);
2676                 filteredRows.push(row);
2677                 return true;
2678             }
2680             return false;
2681         },
2683         setFilter: function(text) {
2684             const filterTerms = text.trim().toLowerCase().split(" ");
2685             if ((filterTerms.length === 1) && (filterTerms[0] === ""))
2686                 this.filterTerms = [];
2687             else
2688                 this.filterTerms = filterTerms;
2689         },
2691         getFilteredAndSortedRows: function() {
2692             if (this.getRoot() === null)
2693                 return [];
2695             const generateRowsSignature = () => {
2696                 const rowsData = [];
2697                 for (const { full_data } of this.getRowValues())
2698                     rowsData.push(full_data);
2699                 return JSON.stringify(rowsData);
2700             };
2702             const getFilteredRows = function() {
2703                 if (this.filterTerms.length === 0) {
2704                     const nodeArray = this.fileTree.toArray();
2705                     const filteredRows = nodeArray.map((node) => {
2706                         return this.getRow(node);
2707                     });
2708                     return filteredRows;
2709                 }
2711                 const filteredRows = [];
2712                 this.getRoot().children.each((child) => {
2713                     this._filterNodes(child, this.filterTerms, filteredRows);
2714                 });
2715                 filteredRows.reverse();
2716                 return filteredRows;
2717             }.bind(this);
2719             const hasRowsChanged = function(rowsString, prevRowsStringString) {
2720                 const rowsChanged = (rowsString !== prevRowsStringString);
2721                 const isFilterTermsChanged = this.filterTerms.reduce((acc, term, index) => {
2722                     return (acc || (term !== this.prevFilterTerms[index]));
2723                 }, false);
2724                 const isFilterChanged = ((this.filterTerms.length !== this.prevFilterTerms.length)
2725                     || ((this.filterTerms.length > 0) && isFilterTermsChanged));
2726                 const isSortedColumnChanged = (this.prevSortedColumn !== this.sortedColumn);
2727                 const isReverseSortChanged = (this.prevReverseSort !== this.reverseSort);
2729                 return (rowsChanged || isFilterChanged || isSortedColumnChanged || isReverseSortChanged);
2730             }.bind(this);
2732             const rowsString = generateRowsSignature();
2733             if (!hasRowsChanged(rowsString, this.prevRowsString))
2734                 return this.prevFilteredRows;
2736             // sort, then filter
2737             const column = this.columns[this.sortedColumn];
2738             this._sortNodesByColumn(this.getRoot().children, column);
2739             const filteredRows = getFilteredRows();
2741             this.prevFilterTerms = this.filterTerms;
2742             this.prevRowsString = rowsString;
2743             this.prevFilteredRows = filteredRows;
2744             this.prevSortedColumn = this.sortedColumn;
2745             this.prevReverseSort = this.reverseSort;
2746             return filteredRows;
2747         },
2749         setIgnored: function(rowId, ignore) {
2750             const row = this.rows.get(rowId.toString());
2751             if (ignore)
2752                 row.full_data.remaining = 0;
2753             else
2754                 row.full_data.remaining = (row.full_data.size * (1.0 - (row.full_data.progress / 100)));
2755         },
2757         setupTr: function(tr) {
2758             tr.addEventListener("keydown", function(event) {
2759                 switch (event.key) {
2760                     case "ArrowLeft":
2761                         qBittorrent.PropFiles.collapseFolder(this._this.getSelectedRowId());
2762                         return false;
2763                     case "ArrowRight":
2764                         qBittorrent.PropFiles.expandFolder(this._this.getSelectedRowId());
2765                         return false;
2766                 }
2767             });
2768         }
2769     });
2771     const RssFeedTable = new Class({
2772         Extends: DynamicTable,
2773         initColumns: function() {
2774             this.newColumn("state_icon", "", "", 30, true);
2775             this.newColumn("name", "", "QBT_TR(RSS feeds)QBT_TR[CONTEXT=FeedListWidget]", -1, true);
2777             this.columns["state_icon"].dataProperties[0] = "";
2779             // map name row to "[name] ([unread])"
2780             this.columns["name"].dataProperties.push("unread");
2781             this.columns["name"].updateTd = function(td, row) {
2782                 const name = this.getRowValue(row, 0);
2783                 const unreadCount = this.getRowValue(row, 1);
2784                 const value = name + " (" + unreadCount + ")";
2785                 td.textContent = value;
2786                 td.title = value;
2787             };
2788         },
2789         setupHeaderMenu: () => {},
2790         setupHeaderEvents: () => {},
2791         getFilteredAndSortedRows: function() {
2792             return [...this.getRowValues()];
2793         },
2794         selectRow: function(rowId) {
2795             this.selectedRows.push(rowId);
2796             this.setRowClass();
2797             this.onSelectedRowChanged();
2799             let path = "";
2800             for (const row of this.getRowValues()) {
2801                 if (row.rowId === rowId) {
2802                     path = row.full_data.dataPath;
2803                     break;
2804                 }
2805             }
2806             window.qBittorrent.Rss.showRssFeed(path);
2807         },
2808         setupTr: function(tr) {
2809             tr.addEventListener("dblclick", function(e) {
2810                 if (this.rowId !== 0) {
2811                     window.qBittorrent.Rss.moveItem(this._this.rows.get(this.rowId).full_data.dataPath);
2812                     return true;
2813                 }
2814             });
2815         },
2816         updateRow: function(tr, fullUpdate) {
2817             const row = this.rows.get(tr.rowId);
2818             const data = row[fullUpdate ? "full_data" : "data"];
2820             const tds = tr.getElements("td");
2821             for (let i = 0; i < this.columns.length; ++i) {
2822                 if (Object.hasOwn(data, this.columns[i].dataProperties[0]))
2823                     this.columns[i].updateTd(tds[i], row);
2824             }
2825             row["data"] = {};
2826             tds[0].style.overflow = "visible";
2827             const indentation = row.full_data.indentation;
2828             tds[0].style.paddingLeft = (indentation * 32 + 4) + "px";
2829             tds[1].style.paddingLeft = (indentation * 32 + 4) + "px";
2830         },
2831         updateIcons: function() {
2832             // state_icon
2833             for (const row of this.getRowValues()) {
2834                 let img_path;
2835                 switch (row.full_data.status) {
2836                     case "default":
2837                         img_path = "images/application-rss.svg";
2838                         break;
2839                     case "hasError":
2840                         img_path = "images/task-reject.svg";
2841                         break;
2842                     case "isLoading":
2843                         img_path = "images/spinner.gif";
2844                         break;
2845                     case "unread":
2846                         img_path = "images/mail-inbox.svg";
2847                         break;
2848                     case "isFolder":
2849                         img_path = "images/folder-documents.svg";
2850                         break;
2851                 }
2852                 let td;
2853                 for (let i = 0; i < this.tableBody.rows.length; ++i) {
2854                     if (this.tableBody.rows[i].rowId === row.rowId) {
2855                         td = this.tableBody.rows[i].children[0];
2856                         break;
2857                     }
2858                 }
2859                 if (td.getChildren("img").length > 0) {
2860                     const img = td.getChildren("img")[0];
2861                     if (!img.src.includes(img_path)) {
2862                         img.src = img_path;
2863                         img.title = status;
2864                     }
2865                 }
2866                 else {
2867                     td.adopt(new Element("img", {
2868                         "src": img_path,
2869                         "class": "stateIcon",
2870                         "height": "22px",
2871                         "width": "22px"
2872                     }));
2873                 }
2874             };
2875         },
2876         newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
2877             const column = {};
2878             column["name"] = name;
2879             column["title"] = name;
2880             column["visible"] = defaultVisible;
2881             column["force_hide"] = false;
2882             column["caption"] = caption;
2883             column["style"] = style;
2884             if (defaultWidth !== -1)
2885                 column["width"] = defaultWidth;
2887             column["dataProperties"] = [name];
2888             column["getRowValue"] = function(row, pos) {
2889                 if (pos === undefined)
2890                     pos = 0;
2891                 return row["full_data"][this.dataProperties[pos]];
2892             };
2893             column["compareRows"] = function(row1, row2) {
2894                 const value1 = this.getRowValue(row1);
2895                 const value2 = this.getRowValue(row2);
2896                 if ((typeof(value1) === "number") && (typeof(value2) === "number"))
2897                     return compareNumbers(value1, value2);
2898                 return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
2899             };
2900             column["updateTd"] = function(td, row) {
2901                 const value = this.getRowValue(row);
2902                 td.textContent = value;
2903                 td.title = value;
2904             };
2905             column["onResize"] = null;
2906             this.columns.push(column);
2907             this.columns[name] = column;
2909             this.hiddenTableHeader.appendChild(new Element("th"));
2910             this.fixedTableHeader.appendChild(new Element("th"));
2911         }
2912     });
2914     const RssArticleTable = new Class({
2915         Extends: DynamicTable,
2916         initColumns: function() {
2917             this.newColumn("name", "", "QBT_TR(Torrents: (double-click to download))QBT_TR[CONTEXT=RSSWidget]", -1, true);
2918         },
2919         setupHeaderMenu: () => {},
2920         setupHeaderEvents: () => {},
2921         getFilteredAndSortedRows: function() {
2922             return [...this.getRowValues()];
2923         },
2924         selectRow: function(rowId) {
2925             this.selectedRows.push(rowId);
2926             this.setRowClass();
2927             this.onSelectedRowChanged();
2929             let articleId = "";
2930             let feedUid = "";
2931             for (const row of this.getRowValues()) {
2932                 if (row.rowId === rowId) {
2933                     articleId = row.full_data.dataId;
2934                     feedUid = row.full_data.feedUid;
2935                     this.tableBody.rows[row.rowId].removeClass("unreadArticle");
2936                     break;
2937                 }
2938             }
2939             window.qBittorrent.Rss.showDetails(feedUid, articleId);
2940         },
2941         setupTr: function(tr) {
2942             tr.addEventListener("dblclick", function(e) {
2943                 showDownloadPage([this._this.rows.get(this.rowId).full_data.torrentURL]);
2944                 return true;
2945             });
2946             tr.addClass("torrentsTableContextMenuTarget");
2947         },
2948         updateRow: function(tr, fullUpdate) {
2949             const row = this.rows.get(tr.rowId);
2950             const data = row[fullUpdate ? "full_data" : "data"];
2951             if (!row.full_data.isRead)
2952                 tr.addClass("unreadArticle");
2953             else
2954                 tr.removeClass("unreadArticle");
2956             const tds = tr.getElements("td");
2957             for (let i = 0; i < this.columns.length; ++i) {
2958                 if (Object.hasOwn(data, this.columns[i].dataProperties[0]))
2959                     this.columns[i].updateTd(tds[i], row);
2960             }
2961             row["data"] = {};
2962         },
2963         newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
2964             const column = {};
2965             column["name"] = name;
2966             column["title"] = name;
2967             column["visible"] = defaultVisible;
2968             column["force_hide"] = false;
2969             column["caption"] = caption;
2970             column["style"] = style;
2971             if (defaultWidth !== -1)
2972                 column["width"] = defaultWidth;
2974             column["dataProperties"] = [name];
2975             column["getRowValue"] = function(row, pos) {
2976                 if (pos === undefined)
2977                     pos = 0;
2978                 return row["full_data"][this.dataProperties[pos]];
2979             };
2980             column["compareRows"] = function(row1, row2) {
2981                 const value1 = this.getRowValue(row1);
2982                 const value2 = this.getRowValue(row2);
2983                 if ((typeof(value1) === "number") && (typeof(value2) === "number"))
2984                     return compareNumbers(value1, value2);
2985                 return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
2986             };
2987             column["updateTd"] = function(td, row) {
2988                 const value = this.getRowValue(row);
2989                 td.textContent = value;
2990                 td.title = value;
2991             };
2992             column["onResize"] = null;
2993             this.columns.push(column);
2994             this.columns[name] = column;
2996             this.hiddenTableHeader.appendChild(new Element("th"));
2997             this.fixedTableHeader.appendChild(new Element("th"));
2998         }
2999     });
3001     const RssDownloaderRulesTable = new Class({
3002         Extends: DynamicTable,
3003         initColumns: function() {
3004             this.newColumn("checked", "", "", 30, true);
3005             this.newColumn("name", "", "", -1, true);
3007             this.columns["checked"].updateTd = function(td, row) {
3008                 if ($("cbRssDlRule" + row.rowId) === null) {
3009                     const checkbox = new Element("input");
3010                     checkbox.type = "checkbox";
3011                     checkbox.id = "cbRssDlRule" + row.rowId;
3012                     checkbox.checked = row.full_data.checked;
3014                     checkbox.addEventListener("click", function(e) {
3015                         window.qBittorrent.RssDownloader.rssDownloaderRulesTable.updateRowData({
3016                             rowId: row.rowId,
3017                             checked: this.checked
3018                         });
3019                         window.qBittorrent.RssDownloader.modifyRuleState(row.full_data.name, "enabled", this.checked);
3020                         e.stopPropagation();
3021                     });
3023                     td.append(checkbox);
3024                 }
3025                 else {
3026                     $("cbRssDlRule" + row.rowId).checked = row.full_data.checked;
3027                 }
3028             };
3029             this.columns["checked"].staticWidth = 50;
3030         },
3031         setupHeaderMenu: () => {},
3032         setupHeaderEvents: () => {},
3033         getFilteredAndSortedRows: function() {
3034             return [...this.getRowValues()];
3035         },
3036         setupTr: function(tr) {
3037             tr.addEventListener("dblclick", function(e) {
3038                 window.qBittorrent.RssDownloader.renameRule(this._this.rows.get(this.rowId).full_data.name);
3039                 return true;
3040             });
3041         },
3042         newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
3043             const column = {};
3044             column["name"] = name;
3045             column["title"] = name;
3046             column["visible"] = defaultVisible;
3047             column["force_hide"] = false;
3048             column["caption"] = caption;
3049             column["style"] = style;
3050             if (defaultWidth !== -1)
3051                 column["width"] = defaultWidth;
3053             column["dataProperties"] = [name];
3054             column["getRowValue"] = function(row, pos) {
3055                 if (pos === undefined)
3056                     pos = 0;
3057                 return row["full_data"][this.dataProperties[pos]];
3058             };
3059             column["compareRows"] = function(row1, row2) {
3060                 const value1 = this.getRowValue(row1);
3061                 const value2 = this.getRowValue(row2);
3062                 if ((typeof(value1) === "number") && (typeof(value2) === "number"))
3063                     return compareNumbers(value1, value2);
3064                 return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
3065             };
3066             column["updateTd"] = function(td, row) {
3067                 const value = this.getRowValue(row);
3068                 td.textContent = value;
3069                 td.title = value;
3070             };
3071             column["onResize"] = null;
3072             this.columns.push(column);
3073             this.columns[name] = column;
3075             this.hiddenTableHeader.appendChild(new Element("th"));
3076             this.fixedTableHeader.appendChild(new Element("th"));
3077         },
3078         selectRow: function(rowId) {
3079             this.selectedRows.push(rowId);
3080             this.setRowClass();
3081             this.onSelectedRowChanged();
3083             let name = "";
3084             for (const row of this.getRowValues()) {
3085                 if (row.rowId === rowId) {
3086                     name = row.full_data.name;
3087                     break;
3088                 }
3089             }
3090             window.qBittorrent.RssDownloader.showRule(name);
3091         }
3092     });
3094     const RssDownloaderFeedSelectionTable = new Class({
3095         Extends: DynamicTable,
3096         initColumns: function() {
3097             this.newColumn("checked", "", "", 30, true);
3098             this.newColumn("name", "", "", -1, true);
3100             this.columns["checked"].updateTd = function(td, row) {
3101                 if ($("cbRssDlFeed" + row.rowId) === null) {
3102                     const checkbox = new Element("input");
3103                     checkbox.type = "checkbox";
3104                     checkbox.id = "cbRssDlFeed" + row.rowId;
3105                     checkbox.checked = row.full_data.checked;
3107                     checkbox.addEventListener("click", function(e) {
3108                         window.qBittorrent.RssDownloader.rssDownloaderFeedSelectionTable.updateRowData({
3109                             rowId: row.rowId,
3110                             checked: this.checked
3111                         });
3112                         e.stopPropagation();
3113                     });
3115                     td.append(checkbox);
3116                 }
3117                 else {
3118                     $("cbRssDlFeed" + row.rowId).checked = row.full_data.checked;
3119                 }
3120             };
3121             this.columns["checked"].staticWidth = 50;
3122         },
3123         setupHeaderMenu: () => {},
3124         setupHeaderEvents: () => {},
3125         getFilteredAndSortedRows: function() {
3126             return [...this.getRowValues()];
3127         },
3128         newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
3129             const column = {};
3130             column["name"] = name;
3131             column["title"] = name;
3132             column["visible"] = defaultVisible;
3133             column["force_hide"] = false;
3134             column["caption"] = caption;
3135             column["style"] = style;
3136             if (defaultWidth !== -1)
3137                 column["width"] = defaultWidth;
3139             column["dataProperties"] = [name];
3140             column["getRowValue"] = function(row, pos) {
3141                 if (pos === undefined)
3142                     pos = 0;
3143                 return row["full_data"][this.dataProperties[pos]];
3144             };
3145             column["compareRows"] = function(row1, row2) {
3146                 const value1 = this.getRowValue(row1);
3147                 const value2 = this.getRowValue(row2);
3148                 if ((typeof(value1) === "number") && (typeof(value2) === "number"))
3149                     return compareNumbers(value1, value2);
3150                 return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
3151             };
3152             column["updateTd"] = function(td, row) {
3153                 const value = this.getRowValue(row);
3154                 td.textContent = value;
3155                 td.title = value;
3156             };
3157             column["onResize"] = null;
3158             this.columns.push(column);
3159             this.columns[name] = column;
3161             this.hiddenTableHeader.appendChild(new Element("th"));
3162             this.fixedTableHeader.appendChild(new Element("th"));
3163         },
3164         selectRow: () => {}
3165     });
3167     const RssDownloaderArticlesTable = new Class({
3168         Extends: DynamicTable,
3169         initColumns: function() {
3170             this.newColumn("name", "", "", -1, true);
3171         },
3172         setupHeaderMenu: () => {},
3173         setupHeaderEvents: () => {},
3174         getFilteredAndSortedRows: function() {
3175             return [...this.getRowValues()];
3176         },
3177         newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
3178             const column = {};
3179             column["name"] = name;
3180             column["title"] = name;
3181             column["visible"] = defaultVisible;
3182             column["force_hide"] = false;
3183             column["caption"] = caption;
3184             column["style"] = style;
3185             if (defaultWidth !== -1)
3186                 column["width"] = defaultWidth;
3188             column["dataProperties"] = [name];
3189             column["getRowValue"] = function(row, pos) {
3190                 if (pos === undefined)
3191                     pos = 0;
3192                 return row["full_data"][this.dataProperties[pos]];
3193             };
3194             column["compareRows"] = function(row1, row2) {
3195                 const value1 = this.getRowValue(row1);
3196                 const value2 = this.getRowValue(row2);
3197                 if ((typeof(value1) === "number") && (typeof(value2) === "number"))
3198                     return compareNumbers(value1, value2);
3199                 return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
3200             };
3201             column["updateTd"] = function(td, row) {
3202                 const value = this.getRowValue(row);
3203                 td.textContent = value;
3204                 td.title = value;
3205             };
3206             column["onResize"] = null;
3207             this.columns.push(column);
3208             this.columns[name] = column;
3210             this.hiddenTableHeader.appendChild(new Element("th"));
3211             this.fixedTableHeader.appendChild(new Element("th"));
3212         },
3213         selectRow: () => {},
3214         updateRow: function(tr, fullUpdate) {
3215             const row = this.rows.get(tr.rowId);
3216             const data = row[fullUpdate ? "full_data" : "data"];
3218             if (row.full_data.isFeed) {
3219                 tr.addClass("articleTableFeed");
3220                 tr.removeClass("articleTableArticle");
3221             }
3222             else {
3223                 tr.removeClass("articleTableFeed");
3224                 tr.addClass("articleTableArticle");
3225             }
3227             const tds = tr.getElements("td");
3228             for (let i = 0; i < this.columns.length; ++i) {
3229                 if (Object.hasOwn(data, this.columns[i].dataProperties[0]))
3230                     this.columns[i].updateTd(tds[i], row);
3231             }
3232             row["data"] = {};
3233         }
3234     });
3236     const LogMessageTable = new Class({
3237         Extends: DynamicTable,
3239         filterText: "",
3241         filteredLength: function() {
3242             return this.tableBody.getElements("tr").length;
3243         },
3245         initColumns: function() {
3246             this.newColumn("rowId", "", "QBT_TR(ID)QBT_TR[CONTEXT=ExecutionLogWidget]", 50, true);
3247             this.newColumn("message", "", "QBT_TR(Message)QBT_TR[CONTEXT=ExecutionLogWidget]", 350, true);
3248             this.newColumn("timestamp", "", "QBT_TR(Timestamp)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);
3249             this.newColumn("type", "", "QBT_TR(Log Type)QBT_TR[CONTEXT=ExecutionLogWidget]", 100, true);
3250             this.initColumnsFunctions();
3251         },
3253         initColumnsFunctions: function() {
3254             this.columns["timestamp"].updateTd = function(td, row) {
3255                 const date = new Date(this.getRowValue(row) * 1000).toLocaleString();
3256                 td.set({ "text": date, "title": date });
3257             };
3259             this.columns["type"].updateTd = function(td, row) {
3260                 // Type of the message: Log::NORMAL: 1, Log::INFO: 2, Log::WARNING: 4, Log::CRITICAL: 8
3261                 let logLevel, addClass;
3262                 switch (this.getRowValue(row).toInt()) {
3263                     case 1:
3264                         logLevel = "QBT_TR(Normal)QBT_TR[CONTEXT=ExecutionLogWidget]";
3265                         addClass = "logNormal";
3266                         break;
3267                     case 2:
3268                         logLevel = "QBT_TR(Info)QBT_TR[CONTEXT=ExecutionLogWidget]";
3269                         addClass = "logInfo";
3270                         break;
3271                     case 4:
3272                         logLevel = "QBT_TR(Warning)QBT_TR[CONTEXT=ExecutionLogWidget]";
3273                         addClass = "logWarning";
3274                         break;
3275                     case 8:
3276                         logLevel = "QBT_TR(Critical)QBT_TR[CONTEXT=ExecutionLogWidget]";
3277                         addClass = "logCritical";
3278                         break;
3279                     default:
3280                         logLevel = "QBT_TR(Unknown)QBT_TR[CONTEXT=ExecutionLogWidget]";
3281                         addClass = "logUnknown";
3282                         break;
3283                 }
3284                 td.set({ "text": logLevel, "title": logLevel });
3285                 td.getParent("tr").className = `logTableRow${addClass}`;
3286             };
3287         },
3289         getFilteredAndSortedRows: function() {
3290             let filteredRows = [];
3291             this.filterText = window.qBittorrent.Log.getFilterText();
3292             const filterTerms = (this.filterText.length > 0) ? this.filterText.toLowerCase().split(" ") : [];
3293             const logLevels = window.qBittorrent.Log.getSelectedLevels();
3294             if ((filterTerms.length > 0) || (logLevels.length < 4)) {
3295                 for (const row of this.getRowValues()) {
3296                     if (!logLevels.includes(row.full_data.type.toString()))
3297                         continue;
3299                     if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(row.full_data.message, filterTerms))
3300                         continue;
3302                     filteredRows.push(row);
3303                 }
3304             }
3305             else {
3306                 filteredRows = [...this.getRowValues()];
3307             }
3309             filteredRows.sort((row1, row2) => {
3310                 const column = this.columns[this.sortedColumn];
3311                 const res = column.compareRows(row1, row2);
3312                 return (this.reverseSort === "0") ? res : -res;
3313             });
3315             return filteredRows;
3316         },
3318         setupCommonEvents: () => {},
3320         setupTr: (tr) => {
3321             tr.addClass("logTableRow");
3322         }
3323     });
3325     const LogPeerTable = new Class({
3326         Extends: LogMessageTable,
3328         initColumns: function() {
3329             this.newColumn("rowId", "", "QBT_TR(ID)QBT_TR[CONTEXT=ExecutionLogWidget]", 50, true);
3330             this.newColumn("ip", "", "QBT_TR(IP)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);
3331             this.newColumn("timestamp", "", "QBT_TR(Timestamp)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);
3332             this.newColumn("blocked", "", "QBT_TR(Status)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);
3333             this.newColumn("reason", "", "QBT_TR(Reason)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);
3335             this.columns["timestamp"].updateTd = function(td, row) {
3336                 const date = new Date(this.getRowValue(row) * 1000).toLocaleString();
3337                 td.set({ "text": date, "title": date });
3338             };
3340             this.columns["blocked"].updateTd = function(td, row) {
3341                 let status, addClass;
3342                 if (this.getRowValue(row)) {
3343                     status = "QBT_TR(Blocked)QBT_TR[CONTEXT=ExecutionLogWidget]";
3344                     addClass = "peerBlocked";
3345                 }
3346                 else {
3347                     status = "QBT_TR(Banned)QBT_TR[CONTEXT=ExecutionLogWidget]";
3348                     addClass = "peerBanned";
3349                 }
3350                 td.set({ "text": status, "title": status });
3351                 td.getParent("tr").className = `logTableRow${addClass}`;
3352             };
3353         },
3355         getFilteredAndSortedRows: function() {
3356             let filteredRows = [];
3357             this.filterText = window.qBittorrent.Log.getFilterText();
3358             const filterTerms = (this.filterText.length > 0) ? this.filterText.toLowerCase().split(" ") : [];
3359             if (filterTerms.length > 0) {
3360                 for (const row of this.getRowValues()) {
3361                     if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(row.full_data.ip, filterTerms))
3362                         continue;
3364                     filteredRows.push(row);
3365                 }
3366             }
3367             else {
3368                 filteredRows = [...this.getRowValues()];
3369             }
3371             filteredRows.sort((row1, row2) => {
3372                 const column = this.columns[this.sortedColumn];
3373                 const res = column.compareRows(row1, row2);
3374                 return (this.reverseSort === "0") ? res : -res;
3375             });
3377             return filteredRows;
3378         }
3379     });
3381     const TorrentWebseedsTable = new Class({
3382         Extends: DynamicTable,
3384         initColumns: function() {
3385             this.newColumn("url", "", "QBT_TR(URL)QBT_TR[CONTEXT=HttpServer]", 500, true);
3386         },
3387     });
3389     return exports();
3390 })();
3391 Object.freeze(window.qBittorrent.DynamicTable);
3393 /*************************************************************/