3 * Copyright (c) 2008 Ishan Arora <ishan@qbittorrent.org> & Christophe Dumez <chris@qbittorrent.org>
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:
12 * The above copyright notice and this permission notice shall be included in
13 * all copies or substantial portions of the Software.
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
24 /**************************************************************
26 Script : Dynamic Table
28 Authors : Ishan Arora & Christophe Dumez
29 Desc : Programmable sortable table
30 Licence : Open Source MIT Licence
32 **************************************************************/
36 window.qBittorrent ??= {};
37 window.qBittorrent.DynamicTable ??= (() => {
38 const exports = () => {
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
58 const compareNumbers = (val1, val2) => {
66 let DynamicTableHeaderContextMenuClass = null;
67 let ProgressColumnWidth = -1;
69 const DynamicTable = new Class({
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 = [];
82 this.contextMenu = contextMenu;
83 this.sortedColumn = LocalPreferences.get("sorted_column_" + this.dynamicTableDivId, 0);
84 this.reverseSort = LocalPreferences.get("reverse_sort_" + this.dynamicTableDivId, "0");
86 this.loadColumnsOrder();
87 this.updateTableHeaders();
88 this.setupCommonEvents();
89 this.setupHeaderEvents();
90 this.setupHeaderMenu();
91 this.setSortedColumnIcon(this.sortedColumn, null, (this.reverseSort === "1"));
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`;
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 = "";
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";
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";
131 this.canResize = false;
132 e.target.getParent("tr").style.cursor = "";
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";
145 this.dropSide = "left";
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";
157 const borderStyle = "initial solid #e60";
158 if (changeBorderSide === "left")
159 borderChangeElement.style.borderLeft = borderStyle;
161 borderChangeElement.style.borderRight = borderStyle;
163 resetElementBorderStyle(borderChangeElement, ((changeBorderSide === "right") ? "left" : "right"));
165 borderChangeElement.getSiblings('[class=""]').each((el) => {
166 resetElementBorderStyle(el);
169 this.lastHoverTh = e.target;
170 this.lastClientX = e.clientX;
173 const mouseOutFn = (e) => {
174 resetElementBorderStyle(e.target);
177 const onBeforeStart = function(el) {
179 this.currentHeaderAction = "start";
180 this.dragMovement = false;
181 this.dragStartX = this.lastClientX;
184 const onStart = function(el, event) {
185 if (this.canResize) {
186 this.currentHeaderAction = "resize";
187 this.startWidth = parseInt(this.resizeTh.style.width, 10);
190 this.currentHeaderAction = "drag";
191 el.style.backgroundColor = "#C1D5E7";
195 const onDrag = function(el, event) {
196 if (this.currentHeaderAction === "resize") {
197 let width = this.startWidth + (event.event.pageX - this.dragStartX);
200 this.columns[this.resizeTh.columnName].width = width;
201 this.updateColumn(this.resizeTh.columnName);
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")
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);
225 if (this.currentHeaderAction === "drag") {
226 resetElementBorderStyle(el);
227 el.getSiblings('[class=""]').each((el) => {
228 resetElementBorderStyle(el);
231 this.currentHeaderAction = "";
234 const onCancel = function(el) {
235 this.currentHeaderAction = "";
237 // ignore click/touch events performed when on the column's resize area
239 this.setSortedColumn(el.columnName);
242 const onTouch = function(e) {
243 const column = e.target.columnName;
244 this.currentHeaderAction = "";
245 this.setSortedColumn(column);
248 const onDoubleClick = function(e) {
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);
260 const ths = this.fixedTableHeader.getElements("th");
262 for (let i = 0; i < ths.length; ++i) {
264 th.addEventListener("mousemove", mouseMoveFn);
265 th.addEventListener("mouseout", mouseOutFn);
266 th.addEventListener("touchend", onTouch, { passive: true });
267 th.addEventListener("dblclick", onDoubleClick);
273 onBeforeStart: onBeforeStart,
276 onComplete: onComplete,
282 setupDynamicTableHeaderContextMenuClass: function() {
283 DynamicTableHeaderContextMenuClass ??= class extends window.qBittorrent.ContextMenu.ContextMenu {
285 for (let i = 0; i < this.dynamicTable.columns.length; ++i) {
286 if (this.dynamicTable.columns[i].caption === "")
288 if (this.dynamicTable.columns[i].visible !== "0")
289 this.setItemChecked(this.dynamicTable.columns[i].name, true);
291 this.setItemChecked(this.dynamicTable.columns[i].name, false);
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);
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;
323 // slight buffer to prevent clipping
324 return longestTd.width + 10;
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);
348 column.width = width;
349 this.updateColumn(column.name);
350 this.saveColumnWidth(column.name);
353 saveColumnWidth: function(columnName) {
354 LocalPreferences.set(`column_${columnName}_width_${this.dynamicTableDivId}`, this.columns[columnName].width);
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", {
365 class: "contextMenu scrollableMenu"
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";
377 const listItem = document.createElement("li");
378 listItem.appendChild(anchor);
384 autoResizeAction: function(element, ref, action) {
385 this.autoResizeColumn(element.columnName);
388 autoResizeAllAction: function(element, ref, action) {
389 for (const { name } of this.columns)
390 this.autoResizeColumn(name);
394 const onMenuItemClicked = function(element, ref, action) {
395 this.showColumn(action, this.columns[action].visible === "0");
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);
404 for (let i = 0; i < this.columns.length; ++i) {
405 const text = this.columns[i].caption;
408 ul.appendChild(createLi(this.columns[i].name, text));
409 actions[this.columns[i].name] = onMenuItemClicked;
412 const createResizeElement = (text, href) => {
413 const anchor = document.createElement("a");
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);
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",
444 this.headerContextMenu.dynamicTable = this;
447 initColumns: () => {},
449 newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
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)
462 return row["full_data"][this.dataProperties[pos]];
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);
471 column["updateTd"] = function(td, row) {
472 const value = this.getRowValue(row);
473 td.textContent = value;
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"));
486 loadColumnsOrder: function() {
487 const columnsOrder = [];
488 const val = LocalPreferences.get("columns_order_" + this.dynamicTableDivId);
489 if ((val === null) || (val === undefined))
491 val.split(",").forEach((v) => {
492 if ((v in this.columns) && (!columnsOrder.contains(v)))
493 columnsOrder.push(v);
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);
501 for (let i = 0; i < this.columns.length; ++i)
502 this.columns[i] = this.columns[columnsOrder[i]];
505 saveColumnsOrder: function() {
507 for (let i = 0; i < this.columns.length; ++i) {
510 val += this.columns[i].name;
512 LocalPreferences.set("columns_order_" + this.dynamicTableDivId, val);
515 updateTableHeaders: function() {
516 this.updateHeader(this.hiddenTableHeader);
517 this.updateHeader(this.fixedTableHeader);
520 updateHeader: function(header) {
521 const ths = header.getElements("th");
523 for (let i = 0; i < ths.length; ++i) {
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");
534 th.removeClass("invisible");
538 getColumnPos: function(columnName) {
539 for (let i = 0; i < this.columns.length; ++i) {
540 if (this.columns[i].name === columnName)
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);
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");
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");
569 if (this.columns[pos].onResize !== null)
570 this.columns[pos].onResize(columnName);
573 getSortedColumn: function() {
574 return LocalPreferences.get("sorted_column_" + this.dynamicTableDivId);
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.
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);
590 this.reverseSort = reverse ?? (this.reverseSort === "0" ? "1" : "0");
591 this.setSortedColumnIcon(column, null, (this.reverseSort === "1"));
593 LocalPreferences.set("sorted_column_" + this.dynamicTableDivId, column);
594 LocalPreferences.set("reverse_sort_" + this.dynamicTableDivId, this.reverseSort);
595 this.updateTable(false);
598 setSortedColumnIcon: function(newColumn, oldColumn, isReverse) {
599 const getCol = (headerDivId, colName) => {
600 const colElem = $$("#" + headerDivId + " .column_" + colName);
601 if (colElem.length === 1)
606 const colElem = getCol(this.dynamicTableFixedHeaderDivId, newColumn);
607 if (colElem !== null) {
608 colElem.addClass("sorted");
610 colElem.addClass("reverse");
612 colElem.removeClass("reverse");
614 const oldColElem = getCol(this.dynamicTableFixedHeaderDivId, oldColumn);
615 if (oldColElem !== null) {
616 oldColElem.removeClass("sorted");
617 oldColElem.removeClass("reverse");
621 getSelectedRowId: function() {
622 if (this.selectedRows.length > 0)
623 return this.selectedRows[0];
627 isRowSelected: function(rowId) {
628 return this.selectedRows.contains(rowId);
631 setupAltRow: function() {
632 const useAltRowColors = (LocalPreferences.get("use_alt_row_colors", "true") === "true");
634 document.getElementById(this.dynamicTableDivId).classList.add("altRowColors");
637 selectAll: function() {
640 const trs = this.tableBody.getElements("tr");
641 for (let i = 0; i < trs.length; ++i) {
643 this.selectedRows.push(tr.rowId);
644 if (!tr.hasClass("selected"))
645 tr.addClass("selected");
649 deselectAll: function() {
650 this.selectedRows.empty();
653 selectRow: function(rowId) {
654 this.selectedRows.push(rowId);
656 this.onSelectedRowChanged();
659 deselectRow: function(rowId) {
660 this.selectedRows.erase(rowId);
662 this.onSelectedRowChanged();
665 selectRows: function(rowId1, rowId2) {
667 if (rowId1 === rowId2) {
668 this.selectRow(rowId1);
674 this.tableBody.getElements("tr").each((tr) => {
675 if ((tr.rowId === rowId1) || (tr.rowId === rowId2)) {
677 that.selectedRows.push(tr.rowId);
680 that.selectedRows.push(tr.rowId);
684 this.onSelectedRowChanged();
687 reselectRows: function(rowIds) {
689 this.selectedRows = rowIds.slice();
690 this.tableBody.getElements("tr").each((tr) => {
691 if (rowIds.includes(tr.rowId))
692 tr.addClass("selected");
696 setRowClass: function() {
698 this.tableBody.getElements("tr").each((tr) => {
699 if (that.isRowSelected(tr.rowId))
700 tr.addClass("selected");
702 tr.removeClass("selected");
706 onSelectedRowChanged: () => {},
708 updateRowData: function(data) {
709 // ensure rowId is a string
710 const rowId = `${data["rowId"]}`;
713 if (!this.rows.has(rowId)) {
718 this.rows.set(rowId, row);
721 row = this.rows.get(rowId);
725 for (const x in data) {
726 if (!Object.hasOwn(data, x))
728 row["full_data"][x] = data[x];
732 getRow: function(rowId) {
733 return this.rows.get(rowId);
736 getFilteredAndSortedRows: function() {
737 const filteredRows = [];
739 for (const row of this.getRowValues()) {
740 filteredRows.push(row);
741 filteredRows[row.rowId] = row;
744 filteredRows.sort((row1, row2) => {
745 const column = this.columns[this.sortedColumn];
746 const res = column.compareRows(row1, row2);
747 if (this.reverseSort === "0")
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)
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);
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) {
784 trs[j].inject(trs[rowPos], "before");
785 const tmpTr = trs[j];
787 trs.splice(rowPos, 0, tmpTr);
791 if (tr_found) { // row already exists in the table
792 this.updateRow(trs[rowPos], fullUpdate);
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
800 const rowId = rows[rowPos]["rowId"];
801 tr.setAttribute("data-row-id", rowId);
805 tr.addEventListener("contextmenu", function(e) {
806 if (!this._this.isRowSelected(this.rowId)) {
807 this._this.deselectAll();
808 this._this.selectRow(this.rowId);
812 tr.addEventListener("click", function(e) {
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);
820 this._this.selectRow(this.rowId);
822 else if (e.shiftKey && (this._this.selectedRows.length === 1)) {
823 // Shift key was pressed
824 this._this.selectRows(this._this.getSelectedRowId(), this.rowId);
828 this._this.deselectAll();
829 this._this.selectRow(this.rowId);
833 tr.addEventListener("touchstart", function(e) {
834 if (!this._this.isRowSelected(this.rowId)) {
835 this._this.deselectAll();
836 this._this.selectRow(this.rowId);
838 }, { passive: true });
839 tr.addEventListener("keydown", function(event) {
842 this._this.selectPreviousRow();
845 this._this.selectNextRow();
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");
860 if (rowPos >= trs.length) {
861 tr.inject(this.tableBody);
865 tr.inject(trs[rowPos], "before");
866 trs.splice(rowPos, 0, tr);
869 // Update context menu
870 if (this.contextMenu)
871 this.contextMenu.addTarget(tr);
873 this.updateRow(tr, true);
877 const rowPos = rows.length;
879 while ((rowPos < trs.length) && (trs.length > 0))
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);
897 removeRow: function(rowId) {
898 this.selectedRows.erase(rowId);
899 this.rows.delete(rowId);
900 const tr = this.getTrByRowId(rowId);
907 const trs = this.tableBody.getElements("tr");
908 while (trs.length > 0)
912 selectedRowsIds: function() {
913 return this.selectedRows.slice();
916 getRowIds: function() {
917 return this.rows.keys();
920 getRowValues: function() {
921 return this.rows.values();
924 getRowItems: function() {
925 return this.rows.entries();
928 getRowSize: function() {
929 return this.rows.size;
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) {
945 const isLastRowSelected = (selectedIndex >= (visibleRows.length - 1));
946 if (!isLastRowSelected) {
949 const newRow = visibleRows[selectedIndex + 1];
950 this.selectRow(newRow.getAttribute("data-row-id"));
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) {
967 const isFirstRowSelected = selectedIndex <= 0;
968 if (!isFirstRowSelected) {
971 const newRow = visibleRows[selectedIndex - 1];
972 this.selectRow(newRow.getAttribute("data-row-id"));
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();
1030 initColumnsFunctions: function() {
1033 this.columns["state_icon"].updateTd = function(td, row) {
1034 let state = this.getRowValue(row);
1040 case "forcedMetaDL":
1042 state = "downloading";
1043 img_path = "images/downloading.svg";
1047 state = "uploading";
1048 img_path = "images/upload.svg";
1051 state = "stalledUP";
1052 img_path = "images/stalledUP.svg";
1055 state = "stalledDL";
1056 img_path = "images/stalledDL.svg";
1059 state = "torrent-stop";
1060 img_path = "images/stopped.svg";
1063 state = "checked-completed";
1064 img_path = "images/checked-completed.svg";
1069 img_path = "images/queued.svg";
1073 case "queuedForChecking":
1074 case "checkingResumeData":
1075 state = "force-recheck";
1076 img_path = "images/force-recheck.svg";
1080 img_path = "images/set-location.svg";
1084 case "missingFiles":
1086 img_path = "images/error.svg";
1089 break; // do nothing
1092 if (td.getChildren("img").length > 0) {
1093 const img = td.getChildren("img")[0];
1094 if (!img.src.includes(img_path)) {
1100 td.adopt(new Element("img", {
1102 "class": "stateIcon",
1109 this.columns["status"].updateTd = function(td, row) {
1110 const state = this.getRowValue(row);
1117 status = "QBT_TR(Downloading)QBT_TR[CONTEXT=TransferListDelegate]";
1120 status = "QBT_TR(Stalled)QBT_TR[CONTEXT=TransferListDelegate]";
1123 status = "QBT_TR(Downloading metadata)QBT_TR[CONTEXT=TransferListDelegate]";
1125 case "forcedMetaDL":
1126 status = "QBT_TR([F] Downloading metadata)QBT_TR[CONTEXT=TransferListDelegate]";
1129 status = "QBT_TR([F] Downloading)QBT_TR[CONTEXT=TransferListDelegate]";
1133 status = "QBT_TR(Seeding)QBT_TR[CONTEXT=TransferListDelegate]";
1136 status = "QBT_TR([F] Seeding)QBT_TR[CONTEXT=TransferListDelegate]";
1140 status = "QBT_TR(Queued)QBT_TR[CONTEXT=TransferListDelegate]";
1144 status = "QBT_TR(Checking)QBT_TR[CONTEXT=TransferListDelegate]";
1146 case "queuedForChecking":
1147 status = "QBT_TR(Queued for checking)QBT_TR[CONTEXT=TransferListDelegate]";
1149 case "checkingResumeData":
1150 status = "QBT_TR(Checking resume data)QBT_TR[CONTEXT=TransferListDelegate]";
1153 status = "QBT_TR(Stopped)QBT_TR[CONTEXT=TransferListDelegate]";
1156 status = "QBT_TR(Completed)QBT_TR[CONTEXT=TransferListDelegate]";
1159 status = "QBT_TR(Moving)QBT_TR[CONTEXT=TransferListDelegate]";
1161 case "missingFiles":
1162 status = "QBT_TR(Missing Files)QBT_TR[CONTEXT=TransferListDelegate]";
1165 status = "QBT_TR(Errored)QBT_TR[CONTEXT=TransferListDelegate]";
1168 status = "QBT_TR(Unknown)QBT_TR[CONTEXT=HttpServer]";
1171 td.textContent = status;
1175 this.columns["status"].compareRows = (row1, row2) => {
1176 return compareNumbers(row1.full_data._statusOrder, row2.full_data._statusOrder);
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;
1187 this.columns["priority"].compareRows = function(row1, row2) {
1188 let row1_val = this.getRowValue(row1);
1189 let row2_val = this.getRowValue(row2);
1194 return compareNumbers(row1_val, row2_val);
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" });
1203 this.columns["category"].compareRows = this.columns["name"].compareRows;
1204 this.columns["tags"].compareRows = this.columns["name"].compareRows;
1207 this.columns["size"].updateTd = function(td, row) {
1208 const size = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false);
1209 td.textContent = size;
1212 this.columns["total_size"].updateTd = this.columns["size"].updateTd;
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];
1225 div.setWidth(ProgressColumnWidth - 5);
1227 if (div.getValue() !== progressFormatted)
1228 div.setValue(progressFormatted);
1231 if (ProgressColumnWidth < 0)
1232 ProgressColumnWidth = td.offsetWidth;
1233 td.adopt(new window.qBittorrent.ProgressBar.ProgressBar(progressFormatted.toFloat(), {
1234 "width": ProgressColumnWidth - 5
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;
1249 this.columns[columnName].updateTd(td, this.rows.get(trs[i].rowId));
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;
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);
1273 return compareNumbers(num_seeds1, num_seeds2);
1277 this.columns["num_leechs"].updateTd = this.columns["num_seeds"].updateTd;
1278 this.columns["num_leechs"].compareRows = this.columns["num_seeds"].compareRows;
1281 this.columns["dlspeed"].updateTd = function(td, row) {
1282 const speed = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), true);
1283 td.textContent = speed;
1288 this.columns["upspeed"].updateTd = this.columns["dlspeed"].updateTd;
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;
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;
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;
1314 this.columns["added_on"].updateTd = function(td, row) {
1315 const date = new Date(this.getRowValue(row) * 1000).toLocaleString();
1316 td.textContent = date;
1321 this.columns["completion_on"].updateTd = function(td, row) {
1322 const val = this.getRowValue(row);
1323 if ((val === 0xffffffff) || (val < 0)) {
1324 td.textContent = "";
1328 const date = new Date(this.getRowValue(row) * 1000).toLocaleString();
1329 td.textContent = date;
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;
1342 // dl_limit, up_limit
1343 this.columns["dl_limit"].updateTd = function(td, row) {
1344 const speed = this.getRowValue(row);
1346 td.textContent = "∞";
1350 const formattedSpeed = window.qBittorrent.Misc.friendlyUnit(speed, true);
1351 td.textContent = formattedSpeed;
1352 td.title = formattedSpeed;
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;
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;
1379 this.columns["completed"].updateTd = this.columns["size"].updateTd;
1382 this.columns["max_ratio"].updateTd = this.columns["ratio"].updateTd;
1385 this.columns["seen_complete"].updateTd = this.columns["completion_on"].updateTd;
1388 this.columns["last_activity"].updateTd = function(td, row) {
1389 const val = this.getRowValue(row);
1391 td.textContent = "∞";
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;
1402 this.columns["availability"].updateTd = function(td, row) {
1403 const value = window.qBittorrent.Misc.toFixedPointString(this.getRowValue(row), 3);
1404 td.textContent = value;
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;
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;
1425 this.columns["reannounce"].updateTd = function(td, row) {
1426 const time = window.qBittorrent.Misc.friendlyDuration(this.getRowValue(row));
1427 td.textContent = time;
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
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;
1445 applyFilter: (row, filterName, categoryHash, tagHash, trackerHash, filterTerms) => {
1446 const state = row["full_data"].state;
1447 let inactive = false;
1449 switch (filterName) {
1451 if ((state !== "downloading") && !state.includes("DL"))
1455 if ((state !== "uploading") && (state !== "forcedUP") && (state !== "stalledUP") && (state !== "queuedUP") && (state !== "checkingUP"))
1459 if ((state !== "uploading") && !state.includes("UP"))
1463 if (!state.includes("stopped"))
1467 if (state.includes("stopped"))
1471 if ((state !== "stalledUP") && (state !== "stalledDL"))
1474 case "stalled_uploading":
1475 if (state !== "stalledUP")
1478 case "stalled_downloading":
1479 if (state !== "stalledDL")
1487 if (state === "stalledDL")
1488 r = (row["full_data"].upspeed > 0);
1490 r = (state === "metaDL") || (state === "forcedMetaDL") || (state === "downloading") || (state === "forcedDL") || (state === "uploading") || (state === "forcedUP");
1496 if ((state !== "checkingUP") && (state !== "checkingDL") && (state !== "checkingResumeData"))
1500 if (state !== "moving")
1504 if ((state !== "error") && (state !== "unknown") && (state !== "missingFiles"))
1509 switch (categoryHash) {
1510 case CATEGORIES_ALL:
1511 break; // do nothing
1512 case CATEGORIES_UNCATEGORIZED:
1513 if (row["full_data"].category.length !== 0)
1515 break; // do nothing
1517 if (!useSubcategories) {
1518 if (categoryHash !== window.qBittorrent.Misc.genHash(row["full_data"].category))
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))
1535 break; // do nothing
1538 if (row["full_data"].tags.length !== 0)
1540 break; // do nothing
1543 const tagHashes = row["full_data"].tags.split(", ").map(tag => window.qBittorrent.Misc.genHash(tag));
1544 if (!tagHashes.contains(tagHash))
1550 switch (trackerHash) {
1552 break; // do nothing
1553 case TRACKERS_TRACKERLESS:
1554 if (row["full_data"].trackers_count !== 0)
1558 const tracker = trackerList.get(trackerHash);
1561 for (const torrents of tracker.trackerTorrentMap.values()) {
1562 if (torrents.has(row["full_data"].rowId)) {
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))
1582 if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(textToSearch, filterTerms))
1590 getFilteredTorrentsNumber: function(filterName, categoryHash, tagHash, trackerHash) {
1593 for (const row of this.rows.values()) {
1594 if (this.applyFilter(row, filterName, categoryHash, tagHash, trackerHash, null))
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();
1606 filterTerms = (filterText.length > 0)
1607 ? (useRegex ? new RegExp(filterText) : filterText.split(" "))
1610 catch (e) { // SyntaxError: Invalid regex pattern
1611 return filteredRows;
1614 for (const row of this.rows.values()) {
1615 if (this.applyFilter(row, filterName, categoryHash, tagHash, trackerHash, filterTerms))
1616 rowsHashes.push(row["rowId"]);
1622 getFilteredAndSortedRows: function() {
1623 const filteredRows = [];
1625 const useRegex = $("torrentsFilterRegexBox").checked;
1626 const filterText = $("torrentsFilterInput").value.trim().toLowerCase();
1629 filterTerms = (filterText.length > 0)
1630 ? (useRegex ? new RegExp(filterText) : filterText.split(" "))
1633 catch (e) { // SyntaxError: Invalid regex pattern
1634 return filteredRows;
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;
1644 filteredRows.sort((row1, row2) => {
1645 const column = this.columns[this.sortedColumn];
1646 const res = column.compareRows(row1, row2);
1647 if (this.reverseSort === "0")
1652 return filteredRows;
1655 setupTr: function(tr) {
1656 tr.addEventListener("dblclick", function(e) {
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;
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")
1678 if (state.includes("stopped"))
1684 tr.addClass("torrentsTableContextMenuTarget");
1687 getCurrentTorrentID: function() {
1688 return this.getSelectedRowId();
1691 onSelectedRowChanged: () => {
1692 updatePropertiesPanel();
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();
1720 initColumnsFunctions: function() {
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");
1734 span.style.backgroundImage = `url('images/flags/${country_code ?? "xx"}.svg')`;
1735 span.textContent = country;
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) {
1756 this.columns["flags"].updateTd = function(td, row) {
1757 td.textContent = this.getRowValue(row, 0);
1758 td.title = this.getRowValue(row, 1);
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;
1772 // dl_speed, up_speed
1773 this.columns["dl_speed"].updateTd = function(td, row) {
1774 const speed = this.getRowValue(row);
1776 td.textContent = "";
1780 const formattedSpeed = window.qBittorrent.Misc.friendlyUnit(speed, true);
1781 td.textContent = formattedSpeed;
1782 td.title = formattedSpeed;
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;
1793 this.columns["uploaded"].updateTd = this.columns["downloaded"].updateTd;
1796 this.columns["relevance"].updateTd = this.columns["progress"].updateTd;
1797 this.columns["relevance"].staticWidth = 100;
1800 this.columns["files"].updateTd = function(td, row) {
1801 const value = this.getRowValue(row, 0);
1802 td.textContent = value.replace(/\n/g, ";");
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();
1824 initColumnsFunctions: function() {
1825 const displaySize = function(td, row) {
1826 const size = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false);
1827 td.textContent = size;
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;
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;
1843 this.columns["fileSize"].updateTd = displaySize;
1844 this.columns["nbSeeders"].updateTd = displayNum;
1845 this.columns["nbLeechers"].updateTd = displayNum;
1846 this.columns["pubDate"].updateTd = displayDate;
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;
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;
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))
1894 if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(row.full_data.fileName, filterTerms))
1896 if ((sizeFilters.min > 0.00) && (row.full_data.fileSize < sizeFilters.min))
1898 if ((sizeFilters.max > 0.00) && (row.full_data.fileSize > sizeFilters.max))
1900 if ((seedsFilters.min > 0) && (row.full_data.nbSeeders < seedsFilters.min))
1902 if ((seedsFilters.max > 0) && (row.full_data.nbSeeders > seedsFilters.max))
1905 filteredRows.push(row);
1909 filteredRows = [...this.getRowValues()];
1912 filteredRows.sort((row1, row2) => {
1913 const column = this.columns[this.sortedColumn];
1914 const res = column.compareRows(row1, row2);
1915 if (this.reverseSort === "0")
1921 return filteredRows;
1925 tr.addClass("searchTableRow");
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();
1941 initColumnsFunctions: function() {
1942 this.columns["enabled"].updateTd = function(td, row) {
1943 const value = this.getRowValue(row);
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");
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");
1960 tr.addClass("searchPluginsTableRow");
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();
1980 initColumnsFunctions: function() {
1981 const naturalSort = function(row1, row2) {
1982 if (!row1.full_data._sortable || !row2.full_data._sortable)
1985 const value1 = this.getRowValue(row1);
1986 const value2 = this.getRowValue(row2);
1987 return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
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)
1998 const value1 = this.getRowValue(row1);
1999 const value2 = this.getRowValue(row2);
2004 return compareNumbers(value1, value2);
2007 this.columns["tier"].compareRows = sortNumbers;
2009 const sortMixed = function(row1, row2) {
2010 if (!row1.full_data._sortable || !row2.full_data._sortable)
2013 const value1 = this.getRowValue(row1);
2014 const value2 = this.getRowValue(row2);
2015 if (value1 === "QBT_TR(N/A)QBT_TR[CONTEXT=TrackerListWidget]")
2017 if (value2 === "QBT_TR(N/A)QBT_TR[CONTEXT=TrackerListWidget]")
2019 return compareNumbers(value1, value2);
2022 this.columns["peers"].compareRows = sortMixed;
2023 this.columns["seeds"].compareRows = sortMixed;
2024 this.columns["leeches"].compareRows = sortMixed;
2025 this.columns["downloaded"].compareRows = sortMixed;
2029 const BulkRenameTorrentFilesTable = new Class({
2030 Extends: DynamicTable,
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);
2047 _addNodeToTable: function(node, depth) {
2050 if (node.isFolder) {
2054 checked: node.checked,
2056 original: node.original,
2057 renamed: node.renamed
2061 node.full_data = data;
2062 this.updateRowData(data);
2065 node.data.rowId = node.rowId;
2066 node.full_data = node.data;
2067 this.updateRowData(node.data);
2070 node.children.each((child) => {
2071 this._addNodeToTable(child, depth + 1);
2075 getRoot: function() {
2076 return this.fileTree.getRoot();
2079 getNode: function(rowId) {
2080 return this.fileTree.getNode(rowId);
2083 getRow: function(node) {
2084 const rowId = this.fileTree.getRowId(node).toString();
2085 return this.rows.get(rowId);
2088 getSelectedRows: function() {
2089 const nodes = this.fileTree.toArray();
2091 return nodes.filter(x => x.checked === 0);
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();
2107 * Toggles the global checkbox and all checkboxes underneath
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];
2119 cb.indeterminate = false;
2120 cb.state = "checked";
2122 node.full_data.checked = node.checked;
2125 const cb = checkboxes[i];
2127 cb.indeterminate = false;
2128 cb.state = "unchecked";
2130 node.full_data.checked = node.checked;
2134 this.updateGlobalCheckbox();
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);
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)
2159 const isAllUnchecked = () => {
2160 for (let i = 0; i < checkboxes.length; ++i) {
2161 if (checkboxes[i].checked)
2166 if (isAllChecked()) {
2167 checkbox.state = "checked";
2168 checkbox.indeterminate = false;
2169 checkbox.checked = true;
2171 else if (isAllUnchecked()) {
2172 checkbox.state = "unchecked";
2173 checkbox.indeterminate = false;
2174 checkbox.checked = false;
2177 checkbox.state = "partial";
2178 checkbox.indeterminate = true;
2179 checkbox.checked = false;
2183 initColumnsFunctions: function() {
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",
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();
2210 checkbox.checked = (value === 0);
2211 checkbox.state = checkbox.checked ? "checked" : "unchecked";
2212 checkbox.indeterminate = false;
2213 td.adopt(treeImg, checkbox);
2215 this.columns["checked"].staticWidth = 50;
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;
2227 // just update file name
2228 $(fileNameId).textContent = value;
2231 const span = new Element("span", {
2235 const dirImg = new Element("img", {
2236 src: "images/directory.svg",
2240 "margin-bottom": -3,
2241 "margin-left": (node.depth * 20)
2245 td.replaceChildren(dirImg, span);
2249 const value = this.getRowValue(row);
2250 const span = new Element("span", {
2254 "margin-left": ((node.depth + 1) * 20)
2257 td.replaceChildren(span);
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", {
2269 id: fileNameRenamedId,
2271 td.replaceChildren(span);
2275 onRowSelectionChange: (row) => {},
2281 reselectRows: function(rowIds) {
2284 this.tableBody.getElements("tr").each((tr) => {
2285 if (rowIds.includes(tr.rowId)) {
2286 const node = that.getNode(tr.rowId);
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;
2297 this.updateGlobalCheckbox();
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)
2308 if (node2.isFolder && !node1.isFolder)
2312 const res = column.compareRows(row1, row2);
2313 return (this.reverseSort === "0") ? res : -res;
2316 nodes.each((node) => {
2317 if (node.children.length > 0)
2318 this._sortNodesByColumn(node.children, column);
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);
2330 const row = this.getRow(node);
2331 filteredRows.push(row);
2336 if (window.qBittorrent.Misc.containsAllTerms(node.original, filterTerms)) {
2337 const row = this.getRow(node);
2338 filteredRows.push(row);
2345 setFilter: function(text) {
2346 const filterTerms = text.trim().toLowerCase().split(" ");
2347 if ((filterTerms.length === 1) && (filterTerms[0] === ""))
2348 this.filterTerms = [];
2350 this.filterTerms = filterTerms;
2353 getFilteredAndSortedRows: function() {
2354 if (this.getRoot() === null)
2357 const generateRowsSignature = () => {
2358 const rowsData = [];
2359 for (const { full_data } of this.getRowValues())
2360 rowsData.push(full_data);
2361 return JSON.stringify(rowsData);
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);
2370 return filteredRows;
2373 const filteredRows = [];
2374 this.getRoot().children.each((child) => {
2375 this._filterNodes(child, this.filterTerms, filteredRows);
2377 filteredRows.reverse();
2378 return filteredRows;
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]));
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);
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;
2411 setIgnored: function(rowId, ignore) {
2412 const row = this.rows.get(rowId);
2414 row.full_data.remaining = 0;
2416 row.full_data.remaining = (row.full_data.size * (1.0 - (row.full_data.progress / 100)));
2419 setupTr: function(tr) {
2420 tr.addEventListener("keydown", function(event) {
2421 switch (event.key) {
2423 qBittorrent.PropFiles.collapseFolder(this._this.getSelectedRowId());
2426 qBittorrent.PropFiles.expandFolder(this._this.getSelectedRowId());
2433 const TorrentFilesTable = new Class({
2434 Extends: DynamicTable,
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);
2451 _addNodeToTable: function(node, depth) {
2454 if (node.isFolder) {
2458 checked: node.checked,
2459 remaining: node.remaining,
2460 progress: node.progress,
2461 priority: window.qBittorrent.PropFiles.normalizePriority(node.priority),
2462 availability: node.availability,
2468 node.full_data = data;
2469 this.updateRowData(data);
2472 node.data.rowId = node.rowId;
2473 node.full_data = node.data;
2474 this.updateRowData(node.data);
2477 node.children.each((child) => {
2478 this._addNodeToTable(child, depth + 1);
2482 getRoot: function() {
2483 return this.fileTree.getRoot();
2486 getNode: function(rowId) {
2487 return this.fileTree.getNode(rowId);
2490 getRow: function(node) {
2491 const rowId = this.fileTree.getRowId(node).toString();
2492 return this.rows.get(rowId);
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();
2507 initColumnsFunctions: function() {
2509 const displaySize = function(td, row) {
2510 const size = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false);
2511 td.textContent = size;
2514 const displayPercentage = function(td, row) {
2515 const value = window.qBittorrent.Misc.friendlyPercentage(this.getRowValue(row));
2516 td.textContent = value;
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);
2529 const treeImg = new Element("img", {
2530 src: "images/L.gif",
2535 td.adopt(treeImg, window.qBittorrent.PropFiles.createDownloadCheckbox(id, row.full_data.fileId, value));
2538 this.columns["checked"].staticWidth = 50;
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;
2551 // just update file name
2552 $(fileNameId).textContent = value;
2555 const collapseIcon = new Element("img", {
2556 src: "images/go-down.svg",
2558 "margin-left": (node.depth * 20)
2560 class: "filesTableCollapseIcon",
2563 onclick: "qBittorrent.PropFiles.collapseIconClicked(this)"
2565 const span = new Element("span", {
2569 const dirImg = new Element("img", {
2570 src: "images/directory.svg",
2578 td.replaceChildren(collapseIcon, dirImg, span);
2582 const value = this.getRowValue(row);
2583 const span = new Element("span", {
2587 "margin-left": ((node.depth + 1) * 20)
2590 td.replaceChildren(span);
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;
2601 this.columns["size"].updateTd = displaySize;
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(), {
2616 progressBar.setValue(value.toFloat());
2619 this.columns["progress"].staticWidth = 100;
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);
2629 td.adopt(window.qBittorrent.PropFiles.createPriorityCombo(id, row.full_data.fileId, value));
2631 this.columns["priority"].staticWidth = 140;
2633 // remaining, availability
2634 this.columns["remaining"].updateTd = displaySize;
2635 this.columns["availability"].updateTd = displayPercentage;
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)
2646 if (node2.isFolder && !node1.isFolder)
2650 const res = column.compareRows(row1, row2);
2651 return (this.reverseSort === "0") ? res : -res;
2654 nodes.each((node) => {
2655 if (node.children.length > 0)
2656 this._sortNodesByColumn(node.children, column);
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);
2668 const row = this.getRow(node);
2669 filteredRows.push(row);
2674 if (window.qBittorrent.Misc.containsAllTerms(node.name, filterTerms)) {
2675 const row = this.getRow(node);
2676 filteredRows.push(row);
2683 setFilter: function(text) {
2684 const filterTerms = text.trim().toLowerCase().split(" ");
2685 if ((filterTerms.length === 1) && (filterTerms[0] === ""))
2686 this.filterTerms = [];
2688 this.filterTerms = filterTerms;
2691 getFilteredAndSortedRows: function() {
2692 if (this.getRoot() === null)
2695 const generateRowsSignature = () => {
2696 const rowsData = [];
2697 for (const { full_data } of this.getRowValues())
2698 rowsData.push(full_data);
2699 return JSON.stringify(rowsData);
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);
2708 return filteredRows;
2711 const filteredRows = [];
2712 this.getRoot().children.each((child) => {
2713 this._filterNodes(child, this.filterTerms, filteredRows);
2715 filteredRows.reverse();
2716 return filteredRows;
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]));
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);
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;
2749 setIgnored: function(rowId, ignore) {
2750 const row = this.rows.get(rowId.toString());
2752 row.full_data.remaining = 0;
2754 row.full_data.remaining = (row.full_data.size * (1.0 - (row.full_data.progress / 100)));
2757 setupTr: function(tr) {
2758 tr.addEventListener("keydown", function(event) {
2759 switch (event.key) {
2761 qBittorrent.PropFiles.collapseFolder(this._this.getSelectedRowId());
2764 qBittorrent.PropFiles.expandFolder(this._this.getSelectedRowId());
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;
2789 setupHeaderMenu: () => {},
2790 setupHeaderEvents: () => {},
2791 getFilteredAndSortedRows: function() {
2792 return [...this.getRowValues()];
2794 selectRow: function(rowId) {
2795 this.selectedRows.push(rowId);
2797 this.onSelectedRowChanged();
2800 for (const row of this.getRowValues()) {
2801 if (row.rowId === rowId) {
2802 path = row.full_data.dataPath;
2806 window.qBittorrent.Rss.showRssFeed(path);
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);
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);
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";
2831 updateIcons: function() {
2833 for (const row of this.getRowValues()) {
2835 switch (row.full_data.status) {
2837 img_path = "images/application-rss.svg";
2840 img_path = "images/task-reject.svg";
2843 img_path = "images/spinner.gif";
2846 img_path = "images/mail-inbox.svg";
2849 img_path = "images/folder-documents.svg";
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];
2859 if (td.getChildren("img").length > 0) {
2860 const img = td.getChildren("img")[0];
2861 if (!img.src.includes(img_path)) {
2867 td.adopt(new Element("img", {
2869 "class": "stateIcon",
2876 newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
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)
2891 return row["full_data"][this.dataProperties[pos]];
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);
2900 column["updateTd"] = function(td, row) {
2901 const value = this.getRowValue(row);
2902 td.textContent = value;
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"));
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);
2919 setupHeaderMenu: () => {},
2920 setupHeaderEvents: () => {},
2921 getFilteredAndSortedRows: function() {
2922 return [...this.getRowValues()];
2924 selectRow: function(rowId) {
2925 this.selectedRows.push(rowId);
2927 this.onSelectedRowChanged();
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");
2939 window.qBittorrent.Rss.showDetails(feedUid, articleId);
2941 setupTr: function(tr) {
2942 tr.addEventListener("dblclick", function(e) {
2943 showDownloadPage([this._this.rows.get(this.rowId).full_data.torrentURL]);
2946 tr.addClass("torrentsTableContextMenuTarget");
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");
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);
2963 newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
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)
2978 return row["full_data"][this.dataProperties[pos]];
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);
2987 column["updateTd"] = function(td, row) {
2988 const value = this.getRowValue(row);
2989 td.textContent = value;
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"));
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({
3017 checked: this.checked
3019 window.qBittorrent.RssDownloader.modifyRuleState(row.full_data.name, "enabled", this.checked);
3020 e.stopPropagation();
3023 td.append(checkbox);
3026 $("cbRssDlRule" + row.rowId).checked = row.full_data.checked;
3029 this.columns["checked"].staticWidth = 50;
3031 setupHeaderMenu: () => {},
3032 setupHeaderEvents: () => {},
3033 getFilteredAndSortedRows: function() {
3034 return [...this.getRowValues()];
3036 setupTr: function(tr) {
3037 tr.addEventListener("dblclick", function(e) {
3038 window.qBittorrent.RssDownloader.renameRule(this._this.rows.get(this.rowId).full_data.name);
3042 newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
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)
3057 return row["full_data"][this.dataProperties[pos]];
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);
3066 column["updateTd"] = function(td, row) {
3067 const value = this.getRowValue(row);
3068 td.textContent = value;
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"));
3078 selectRow: function(rowId) {
3079 this.selectedRows.push(rowId);
3081 this.onSelectedRowChanged();
3084 for (const row of this.getRowValues()) {
3085 if (row.rowId === rowId) {
3086 name = row.full_data.name;
3090 window.qBittorrent.RssDownloader.showRule(name);
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({
3110 checked: this.checked
3112 e.stopPropagation();
3115 td.append(checkbox);
3118 $("cbRssDlFeed" + row.rowId).checked = row.full_data.checked;
3121 this.columns["checked"].staticWidth = 50;
3123 setupHeaderMenu: () => {},
3124 setupHeaderEvents: () => {},
3125 getFilteredAndSortedRows: function() {
3126 return [...this.getRowValues()];
3128 newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
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)
3143 return row["full_data"][this.dataProperties[pos]];
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);
3152 column["updateTd"] = function(td, row) {
3153 const value = this.getRowValue(row);
3154 td.textContent = value;
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"));
3167 const RssDownloaderArticlesTable = new Class({
3168 Extends: DynamicTable,
3169 initColumns: function() {
3170 this.newColumn("name", "", "", -1, true);
3172 setupHeaderMenu: () => {},
3173 setupHeaderEvents: () => {},
3174 getFilteredAndSortedRows: function() {
3175 return [...this.getRowValues()];
3177 newColumn: function(name, style, caption, defaultWidth, defaultVisible) {
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)
3192 return row["full_data"][this.dataProperties[pos]];
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);
3201 column["updateTd"] = function(td, row) {
3202 const value = this.getRowValue(row);
3203 td.textContent = value;
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"));
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");
3223 tr.removeClass("articleTableFeed");
3224 tr.addClass("articleTableArticle");
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);
3236 const LogMessageTable = new Class({
3237 Extends: DynamicTable,
3241 filteredLength: function() {
3242 return this.tableBody.getElements("tr").length;
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();
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 });
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()) {
3264 logLevel = "QBT_TR(Normal)QBT_TR[CONTEXT=ExecutionLogWidget]";
3265 addClass = "logNormal";
3268 logLevel = "QBT_TR(Info)QBT_TR[CONTEXT=ExecutionLogWidget]";
3269 addClass = "logInfo";
3272 logLevel = "QBT_TR(Warning)QBT_TR[CONTEXT=ExecutionLogWidget]";
3273 addClass = "logWarning";
3276 logLevel = "QBT_TR(Critical)QBT_TR[CONTEXT=ExecutionLogWidget]";
3277 addClass = "logCritical";
3280 logLevel = "QBT_TR(Unknown)QBT_TR[CONTEXT=ExecutionLogWidget]";
3281 addClass = "logUnknown";
3284 td.set({ "text": logLevel, "title": logLevel });
3285 td.getParent("tr").className = `logTableRow${addClass}`;
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()))
3299 if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(row.full_data.message, filterTerms))
3302 filteredRows.push(row);
3306 filteredRows = [...this.getRowValues()];
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;
3315 return filteredRows;
3318 setupCommonEvents: () => {},
3321 tr.addClass("logTableRow");
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 });
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";
3347 status = "QBT_TR(Banned)QBT_TR[CONTEXT=ExecutionLogWidget]";
3348 addClass = "peerBanned";
3350 td.set({ "text": status, "title": status });
3351 td.getParent("tr").className = `logTableRow${addClass}`;
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))
3364 filteredRows.push(row);
3368 filteredRows = [...this.getRowValues()];
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;
3377 return filteredRows;
3381 const TorrentWebseedsTable = new Class({
3382 Extends: DynamicTable,
3384 initColumns: function() {
3385 this.newColumn("url", "", "QBT_TR(URL)QBT_TR[CONTEXT=HttpServer]", 500, true);
3391 Object.freeze(window.qBittorrent.DynamicTable);
3393 /*************************************************************/