/* * MIT License * Copyright (c) 2008 Ishan Arora & Christophe Dumez * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ /************************************************************** Script : Dynamic Table Version : 0.5 Authors : Ishan Arora & Christophe Dumez Desc : Programmable sortable table Licence : Open Source MIT Licence **************************************************************/ "use strict"; window.qBittorrent ??= {}; window.qBittorrent.DynamicTable ??= (() => { const exports = () => { return { TorrentsTable: TorrentsTable, TorrentPeersTable: TorrentPeersTable, SearchResultsTable: SearchResultsTable, SearchPluginsTable: SearchPluginsTable, TorrentTrackersTable: TorrentTrackersTable, TorrentFilesTable: TorrentFilesTable, BulkRenameTorrentFilesTable: BulkRenameTorrentFilesTable, AddTorrentFilesTable: AddTorrentFilesTable, LogMessageTable: LogMessageTable, LogPeerTable: LogPeerTable, RssFeedTable: RssFeedTable, RssArticleTable: RssArticleTable, RssDownloaderRulesTable: RssDownloaderRulesTable, RssDownloaderFeedSelectionTable: RssDownloaderFeedSelectionTable, RssDownloaderArticlesTable: RssDownloaderArticlesTable, TorrentWebseedsTable: TorrentWebseedsTable, TorrentCreationTasksTable: TorrentCreationTasksTable, }; }; const compareNumbers = (val1, val2) => { if (val1 < val2) return -1; if (val1 > val2) return 1; return 0; }; const toDateString = (value) => { return (value > 0) ? window.qBittorrent.Misc.formatDate(new Date(value * 1000)) : ""; }; const localPreferences = new window.qBittorrent.LocalPreferences.LocalPreferences(); const clientData = window.qBittorrent.ClientData ?? window.parent.qBittorrent.ClientData; class DynamicTable { #DynamicTableHeaderContextMenuClass = null; setup(dynamicTableDivId, dynamicTableFixedHeaderDivId, contextMenu) { this.dynamicTableDivId = dynamicTableDivId; this.dynamicTableFixedHeaderDivId = dynamicTableFixedHeaderDivId; this.dynamicTableDiv = document.getElementById(dynamicTableDivId); this.useVirtualList = clientData.get("use_virtual_list") ?? true; this.fixedTableHeader = document.querySelector(`#${dynamicTableFixedHeaderDivId} thead tr`); // the body table is the direct child; the fixed header table is nested in a child div this.table = this.dynamicTableDiv.querySelector(":scope > table"); this.colgroup = document.createElement("colgroup"); this.table.prepend(this.colgroup); // the fixed header table needs its own colgroup so its columns size identically to the body this.fixedTableHeaderColgroup = document.createElement("colgroup"); this.fixedTableHeader.closest("table").prepend(this.fixedTableHeaderColgroup); this.tableBody = this.table.querySelector("tbody"); this.rowHeight = (clientData.get("display_density") === "compact") ? 22 : 26; this.rows = new Map(); this.cachedElements = []; this.selectedRows = []; this.columns = []; this.contextMenu = contextMenu; this.sortedColumn = localPreferences.get(`sorted_column_${this.dynamicTableDivId}`, 0); this.reverseSort = localPreferences.get(`reverse_sort_${this.dynamicTableDivId}`, "0"); this.secondarySortedColumn = localPreferences.get(`secondary_sorted_column_${this.dynamicTableDivId}`) || null; this.secondaryReverseSort = localPreferences.get(`secondary_reverse_sort_${this.dynamicTableDivId}`, "0"); this.initColumns(); this.loadColumnsOrder(); this.updateTableHeaders(); this.setupCommonEvents(); this.setupHeaderEvents(); this.setupHeaderMenu(); this.setupAltRow(); this.setupVirtualList(); } setupVirtualList() { if (!this.useVirtualList) return; this.table.style.position = "relative"; this.renderedOffset = this.dynamicTableDiv.scrollTop; this.renderedHeight = this.dynamicTableDiv.offsetHeight; const resizeCallback = window.qBittorrent.Misc.createDebounceHandler(100, () => { const height = this.dynamicTableDiv.offsetHeight; const needRerender = this.renderedHeight < height; this.renderedHeight = height; if (needRerender) this.rerender(); }); new ResizeObserver(resizeCallback).observe(this.dynamicTableDiv); } setupCommonEvents() { this.dynamicTableDiv.addEventListener("scroll", (e) => { // rerender on vertical scroll if (this.useVirtualList) { const scrollTop = this.dynamicTableDiv.scrollTop; if (scrollTop !== this.renderedOffset) { this.renderedOffset = scrollTop; this.rerender(); } } }); this.dynamicTableDiv.addEventListener("click", (e) => { const tr = e.target.closest("tr"); if ((tr === null) || (tr.rowId === undefined)) { // clicking on the table body deselects all rows this.deselectAll(); this.setRowClass(); return; } if (e.ctrlKey || e.metaKey) { // CTRL/CMD ⌘ key was pressed if (this.isRowSelected(tr.rowId)) this.deselectRow(tr.rowId); else this.selectRow(tr.rowId); } else if (e.shiftKey && (this.selectedRows.length === 1)) { // Shift key was pressed this.selectRows(this.getSelectedRowId(), tr.rowId); } else { // Simple selection this.deselectAll(); this.selectRow(tr.rowId); } }); this.dynamicTableDiv.addEventListener("contextmenu", (e) => { const tr = e.target.closest("tr"); if (!tr) return; if (!this.isRowSelected(tr.rowId)) { this.deselectAll(); this.selectRow(tr.rowId); } }, true); this.dynamicTableDiv.addEventListener("touchstart", (e) => { const tr = e.target.closest("tr"); if (!tr) return; if (!this.isRowSelected(tr.rowId)) { this.deselectAll(); this.selectRow(tr.rowId); } }, { passive: true }); this.dynamicTableDiv.addEventListener("keydown", (e) => { const tr = e.target.closest("tr"); if (!tr) return; switch (e.key) { case "ArrowUp": e.preventDefault(); this.selectPreviousRow(); this.dynamicTableDiv.querySelector(".selected").scrollIntoView({ block: "nearest" }); break; case "ArrowDown": e.preventDefault(); this.selectNextRow(); this.dynamicTableDiv.querySelector(".selected").scrollIntoView({ block: "nearest" }); break; } }); } setupHeaderEvents() { this.currentHeaderAction = ""; this.canResize = false; const resetElementBorderStyle = (el, side = "both") => { if ((side === "left") || (side === "both")) el.style.borderLeft = ""; if ((side === "right") || (side === "both")) el.style.borderRight = ""; }; const mouseMoveFn = function(e) { const brect = e.target.getBoundingClientRect(); const mouseXRelative = e.clientX - brect.left; if (this.currentHeaderAction === "") { if ((brect.width - mouseXRelative) < 5) { this.resizeTh = e.target; this.canResize = true; e.target.closest("tr").style.cursor = "col-resize"; } else if ((mouseXRelative < 5) && e.target.getPrevious('[class=""]')) { this.resizeTh = e.target.getPrevious('[class=""]'); this.canResize = true; e.target.closest("tr").style.cursor = "col-resize"; } else { this.canResize = false; e.target.closest("tr").style.cursor = ""; } } if (this.currentHeaderAction === "drag") { const previousVisibleSibling = e.target.getPrevious('[class=""]'); let borderChangeElement = previousVisibleSibling; let changeBorderSide = "right"; if (mouseXRelative > (brect.width / 2)) { borderChangeElement = e.target; this.dropSide = "right"; } else { this.dropSide = "left"; } e.target.closest("tr").style.cursor = "move"; if (!previousVisibleSibling) { // right most column borderChangeElement = e.target; if (mouseXRelative <= (brect.width / 2)) changeBorderSide = "left"; } const borderStyle = "solid #e60"; if (changeBorderSide === "left") { borderChangeElement.style.borderLeft = borderStyle; borderChangeElement.style.borderLeftWidth = "initial"; } else { borderChangeElement.style.borderRight = borderStyle; borderChangeElement.style.borderRightWidth = "initial"; } resetElementBorderStyle(borderChangeElement, ((changeBorderSide === "right") ? "left" : "right")); for (const el of borderChangeElement.parentElement.children) { if ((el !== borderChangeElement) && (el.classList.length === 0)) resetElementBorderStyle(el); } } this.lastHoverTh = e.target; this.lastClientX = e.clientX; }.bind(this); const mouseOutFn = (e) => { resetElementBorderStyle(e.target); }; const onBeforeStart = function(el) { this.clickedTh = el; this.currentHeaderAction = "start"; this.dragMovement = false; this.dragStartX = this.lastClientX; }.bind(this); const onStart = function(el, event) { if (this.canResize) { this.currentHeaderAction = "resize"; this.startWidth = Number.parseInt(this.resizeTh.style.width, 10); } else { this.currentHeaderAction = "drag"; el.style.backgroundColor = "#C1D5E7"; } }.bind(this); const onDrag = function(el, event) { if (this.currentHeaderAction === "resize") { let width = this.startWidth + (event.event.pageX - this.dragStartX); if (width < 16) width = 16; this.#setColumnWidth(this.resizeTh.columnName, width); } }.bind(this); const onComplete = function(el, event) { resetElementBorderStyle(this.lastHoverTh); el.style.backgroundColor = ""; if (this.currentHeaderAction === "resize") this.saveColumnWidth(this.resizeTh.columnName); if ((this.currentHeaderAction === "drag") && (el !== this.lastHoverTh)) { this.saveColumnsOrder(); const val = localPreferences.get(`columns_order_${this.dynamicTableDivId}`).split(","); val.erase(el.columnName); let pos = val.indexOf(this.lastHoverTh.columnName); if (this.dropSide === "right") ++pos; val.splice(pos, 0, el.columnName); localPreferences.set(`columns_order_${this.dynamicTableDivId}`, val.join(",")); const oldIndexByName = new Map(this.columns.map((col, i) => [col.name, i])); this.loadColumnsOrder(); this.updateTableHeaders(); for (const tr of this.cachedElements) { if (!tr) continue; const tds = [...tr.children]; tr.replaceChildren(...this.columns.map(col => tds[oldIndexByName.get(col.name)])); } this.tableBody.replaceChildren(); this.updateTable(true); this.reselectRows(this.selectedRowsIds()); } if (this.currentHeaderAction === "drag") { resetElementBorderStyle(el); for (const e of el.parentElement.children) { if ((e !== el) && (e.classList.length === 0)) resetElementBorderStyle(e); } } this.currentHeaderAction = ""; }.bind(this); const onCancel = function(el) { this.currentHeaderAction = ""; // ignore click/touch events performed when on the column's resize area if (!this.canResize) this.setSortedColumn(el.columnName); }.bind(this); const onTouch = function(e) { const column = e.target.columnName; this.currentHeaderAction = ""; this.setSortedColumn(column); }.bind(this); const onDoubleClick = function(e) { e.preventDefault(); this.currentHeaderAction = ""; // only resize when hovering on the column's resize area if (this.canResize) { this.currentHeaderAction = "resize"; this.autoResizeColumn(e.target.columnName); onComplete(e.target); } }.bind(this); for (const th of this.getRowCells(this.fixedTableHeader)) { th.addEventListener("mousemove", (event) => mouseMoveFn(event)); th.addEventListener("mouseout", (event) => mouseOutFn(event)); th.addEventListener("touchend", ((event) => onTouch(event)), { passive: true }); th.addEventListener("dblclick", (event) => onDoubleClick(event)); th.makeResizable({ modifiers: { x: "", y: "" }, onBeforeStart: onBeforeStart, onStart: onStart, onDrag: onDrag, onComplete: onComplete, onCancel: onCancel }); } } setupDynamicTableHeaderContextMenuClass() { this.#DynamicTableHeaderContextMenuClass ??= class extends window.qBittorrent.ContextMenu.ContextMenu { updateMenuItems() { for (let i = 0; i < this.dynamicTable.columns.length; ++i) { if (this.dynamicTable.columns[i].caption === "") continue; if (this.dynamicTable.columns[i].visible !== "0") this.setItemChecked(this.dynamicTable.columns[i].name, true); else this.setItemChecked(this.dynamicTable.columns[i].name, false); } } }; } showColumn(columnName, show) { this.columns[columnName].visible = show ? "1" : "0"; localPreferences.set(`column_${columnName}_visible_${this.dynamicTableDivId}`, show ? "1" : "0"); this.updateColumn(columnName); this.columns[columnName].onVisibilityChange?.(columnName); } #calculateColumnBodyWidth(column) { const columnIndex = this.getColumnPos(column.name); const bodyColumn = this.getRowCells(this.fixedTableHeader)[columnIndex]; const canvas = document.createElement("canvas"); const context = canvas.getContext("2d"); context.font = window.getComputedStyle(bodyColumn, null).getPropertyValue("font"); const longestTd = { value: "", width: 0 }; for (const tr of this.getTrs()) { const tds = this.getRowCells(tr); const td = tds[columnIndex]; const buffer = column.calculateBuffer(tr.rowId); const valueWidth = context.measureText(td.textContent).width; if ((valueWidth + buffer) > (longestTd.width)) { longestTd.value = td.textContent; longestTd.width = valueWidth + buffer; } } // slight buffer to prevent clipping return longestTd.width + 10; } #setColumnWidth(columnName, width) { const column = this.columns[columnName]; column.width = width; const pos = this.getColumnPos(column.name); const style = `width: ${column.width}px; ${column.style}`; this.getRowCells(this.fixedTableHeader)[pos].style.cssText = style; this.colgroup.children[pos].style.width = `${column.width}px`; this.fixedTableHeaderColgroup.children[pos].style.width = `${column.width}px`; // rerender on column resize if (this.useVirtualList) this.rerender(); column.onResize?.(column.name); } autoResizeColumn(columnName) { const column = this.columns[columnName]; let width = column.staticWidth ?? 0; if (column.staticWidth === null) { // check required min body width const bodyTextWidth = this.#calculateColumnBodyWidth(column); // check required min header width const columnIndex = this.getColumnPos(column.name); const headColumn = document.getElementById(this.dynamicTableFixedHeaderDivId).querySelectorAll("tr>th")[columnIndex]; const canvas = document.createElement("canvas"); const context = canvas.getContext("2d"); context.font = window.getComputedStyle(headColumn, null).getPropertyValue("font"); const columnTitle = column.caption; const sortedIconWidth = 20; const headTextWidth = context.measureText(columnTitle).width + sortedIconWidth; width = Math.max(headTextWidth, bodyTextWidth); } this.#setColumnWidth(column.name, width); this.saveColumnWidth(column.name); } saveColumnWidth(columnName) { localPreferences.set(`column_${columnName}_width_${this.dynamicTableDivId}`, this.columns[columnName].width); } setupHeaderMenu() { this.setupDynamicTableHeaderContextMenuClass(); const menuId = `${this.dynamicTableDivId}_headerMenu`; // reuse menu if already exists let ul = document.getElementById(menuId); if (ul === null) { ul = document.createElement("ul"); ul.id = menuId; ul.className = "contextMenu scrollableMenu"; } const createLi = (columnName, text) => { const anchor = document.createElement("a"); anchor.href = `#${columnName}`; anchor.textContent = text; const img = document.createElement("img"); img.src = "images/checked-completed.svg"; anchor.prepend(img); const listItem = document.createElement("li"); listItem.appendChild(anchor); return listItem; }; const actions = { autoResizeAction: function(element, ref, action) { this.autoResizeColumn(element.columnName); }.bind(this), autoResizeAllAction: function(element, ref, action) { for (const { name } of this.columns) this.autoResizeColumn(name); }.bind(this), }; const onMenuItemClicked = function(element, ref, action) { this.showColumn(action, this.columns[action].visible === "0"); }.bind(this); // recreate child elements when reusing (enables the context menu to work correctly) ul.replaceChildren(); for (let i = 0; i < this.columns.length; ++i) { const text = this.columns[i].caption; if (text === "") continue; ul.appendChild(createLi(this.columns[i].name, text)); actions[this.columns[i].name] = onMenuItemClicked; } const createResizeElement = (text, href) => { const anchor = document.createElement("a"); anchor.href = href; anchor.textContent = text; const spacer = document.createElement("span"); spacer.style = "display: inline-block; width: calc(.5em + 16px);"; anchor.prepend(spacer); const li = document.createElement("li"); li.appendChild(anchor); return li; }; const autoResizeAllElement = createResizeElement("QBT_TR(Resize All)QBT_TR[CONTEXT=ListWidget]", "#autoResizeAllAction"); const autoResizeElement = createResizeElement("QBT_TR(Resize)QBT_TR[CONTEXT=ListWidget]", "#autoResizeAction"); ul.firstElementChild.classList.add("separator"); ul.insertBefore(autoResizeAllElement, ul.firstElementChild); ul.insertBefore(autoResizeElement, ul.firstElementChild); document.body.append(ul); this.headerContextMenu = new this.#DynamicTableHeaderContextMenuClass({ targets: `#${this.dynamicTableFixedHeaderDivId} tr th`, actions: actions, menu: menuId, offsets: { x: 0, y: 2 } }); this.headerContextMenu.dynamicTable = this; } initColumns() {} newColumn(name, style, caption, defaultWidth, defaultVisible) { const column = {}; column["name"] = name; column["title"] = name; column["visible"] = localPreferences.get(`column_${name}_visible_${this.dynamicTableDivId}`, (defaultVisible ? "1" : "0")); column["force_hide"] = false; column["caption"] = caption; column["style"] = style; if (defaultWidth !== -1) column["width"] = Number(localPreferences.get(`column_${name}_width_${this.dynamicTableDivId}`, defaultWidth)); column["dataProperties"] = [name]; column["getRowValue"] = function(row, pos) { if (pos === undefined) pos = 0; return row["full_data"][this.dataProperties[pos]]; }; column["compareRows"] = function(row1, row2) { const value1 = this.getRowValue(row1); const value2 = this.getRowValue(row2); if ((typeof(value1) === "number") && (typeof(value2) === "number")) return compareNumbers(value1, value2); return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2); }; column["updateTd"] = function(td, row) { const value = this.getRowValue(row); td.textContent = value; td.title = value; }; column["isVisible"] = function() { return (this.visible === "1") && !this.force_hide; }; column["onResize"] = null; column["onVisibilityChange"] = null; column["staticWidth"] = null; column["calculateBuffer"] = () => 0; this.columns.push(column); this.columns[name] = column; const col = document.createElement("col"); col.style.width = `${column.width}px`; this.colgroup.append(col); const headerCol = document.createElement("col"); headerCol.style.width = `${column.width}px`; this.fixedTableHeaderColgroup.append(headerCol); this.fixedTableHeader.append(document.createElement("th")); } loadColumnsOrder() { const savedOrder = localPreferences.get(`columns_order_${this.dynamicTableDivId}`); if ((savedOrder === null) || (savedOrder === undefined)) return; const byName = new Map(this.columns.map(column => [column.name, column])); // Build the desired column order. A Set keeps names unique while // preserving their saved order. const order = new Set(); for (const name of savedOrder.split(",")) { // skip names that no longer exist as columns if (byName.has(name)) order.add(name); } // Append columns that were added after the order was saved so that // every column appears exactly once. for (const column of this.columns) order.add(column.name); // Reorder the columns array in place to match the desired order. // The array is mutated rather than replaced so its named properties // are preserved. const orderedNames = [...order]; for (let i = 0; i < this.columns.length; ++i) this.columns[i] = byName.get(orderedNames[i]); } saveColumnsOrder() { let val = ""; for (let i = 0; i < this.columns.length; ++i) { if (i > 0) val += ","; val += this.columns[i].name; } localPreferences.set(`columns_order_${this.dynamicTableDivId}`, val); } updateTableHeaders() { this.updateHeader(this.fixedTableHeader); const cols = this.colgroup.children; const headerCols = this.fixedTableHeaderColgroup.children; for (let i = 0; i < this.columns.length; ++i) { const width = `${this.columns[i].width}px`; const visible = this.columns[i].isVisible(); cols[i].style.width = width; cols[i].classList.toggle("invisible", !visible); headerCols[i].style.width = width; headerCols[i].classList.toggle("invisible", !visible); } this.setSortedColumnIcon(this.sortedColumn, null, (this.reverseSort === "1")); } updateHeader(header) { const ths = this.getRowCells(header); for (const [i, th] of ths.entries()) { if (th.columnName !== this.columns[i].name) { th.title = this.columns[i].caption; th.textContent = this.columns[i].caption; th.style.cssText = `width: ${this.columns[i].width}px; ${this.columns[i].style}`; th.columnName = this.columns[i].name; th.className = `column_${th.columnName}`; th.classList.toggle("invisible", ((this.columns[i].visible === "0") || this.columns[i].force_hide)); } } } getColumnPos(columnName) { for (let i = 0; i < this.columns.length; ++i) { if (this.columns[i].name === columnName) return i; } return -1; } updateColumn(columnName, updateCellData = false) { const column = this.columns[columnName]; const pos = this.getColumnPos(columnName); const fths = this.getRowCells(this.fixedTableHeader); const action = column.isVisible() ? "remove" : "add"; fths[pos].classList[action]("invisible"); this.colgroup.children[pos].classList[action]("invisible"); this.fixedTableHeaderColgroup.children[pos].classList[action]("invisible"); for (const tr of this.getTrs()) { const td = this.getRowCells(tr)[pos]; td.classList[action]("invisible"); if (updateCellData) column.updateTd(td, this.rows.get(tr.rowId)); } } getSortedColumn() { return localPreferences.get(`sorted_column_${this.dynamicTableDivId}`); } /** * @param {string} column name to sort by * @param {string|null} reverse defaults to implementation-specific behavior when not specified. Should only be passed when restoring previous state. */ setSortedColumn(column, reverse = null) { if (column !== this.sortedColumn) { const oldColumn = this.sortedColumn; this.secondarySortedColumn = oldColumn; this.secondaryReverseSort = this.reverseSort; this.sortedColumn = column; this.reverseSort = reverse ?? "0"; this.setSortedColumnIcon(column, oldColumn, false); } else { // Toggle sort order this.reverseSort = reverse ?? (this.reverseSort === "0" ? "1" : "0"); this.setSortedColumnIcon(column, null, (this.reverseSort === "1")); } localPreferences.set(`sorted_column_${this.dynamicTableDivId}`, column); localPreferences.set(`reverse_sort_${this.dynamicTableDivId}`, this.reverseSort); if (this.secondarySortedColumn !== null) { localPreferences.set(`secondary_sorted_column_${this.dynamicTableDivId}`, this.secondarySortedColumn); localPreferences.set(`secondary_reverse_sort_${this.dynamicTableDivId}`, this.secondaryReverseSort); } this.updateTable(false); } rowComparator() { const primary = this.columns[this.sortedColumn]; const isPrimaryReverse = this.reverseSort === "1"; const secondaryName = this.secondarySortedColumn; const secondary = (secondaryName && (secondaryName !== this.sortedColumn)) ? this.columns[secondaryName] : null; const isSecondaryReverse = this.secondaryReverseSort === "1"; return (row1, row2) => { const result = primary.compareRows(row1, row2); if (result !== 0) return isPrimaryReverse ? -result : result; if (!secondary) return 0; const subResult = secondary.compareRows(row1, row2); return isSecondaryReverse ? -subResult : subResult; }; } setSortedColumnIcon(newColumn, oldColumn, isReverse) { const getCol = (headerDivId, colName) => { const colElem = document.querySelectorAll(`#${headerDivId} .column_${colName}`); if (colElem.length === 1) return colElem[0]; return null; }; const colElem = getCol(this.dynamicTableFixedHeaderDivId, newColumn); if (colElem !== null) { colElem.classList.add("sorted"); colElem.classList.toggle("reverse", isReverse); } const oldColElem = getCol(this.dynamicTableFixedHeaderDivId, oldColumn); if (oldColElem !== null) oldColElem.classList.remove("sorted", "reverse"); } getSelectedRowId() { if (this.selectedRows.length > 0) return this.selectedRows[0]; return ""; } isRowSelected(rowId) { return this.selectedRows.contains(rowId); } setupAltRow() { const useAltRowColors = (clientData.get("use_alt_row_colors") ?? true) === true; if (useAltRowColors) document.getElementById(this.dynamicTableDivId).classList.add("altRowColors"); } selectAll() { this.deselectAll(); for (const row of this.getFilteredAndSortedRows()) this.selectedRows.push(row.rowId); this.setRowClass(); } deselectAll() { this.selectedRows.length = 0; } selectRow(rowId) { this.selectedRows.push(rowId); this.setRowClass(); this.onSelectedRowChanged(); } deselectRow(rowId) { this.selectedRows.erase(rowId); this.setRowClass(); this.onSelectedRowChanged(); } selectRows(rowId1, rowId2) { this.deselectAll(); if (rowId1 === rowId2) { this.selectRow(rowId1); return; } let select = false; for (const row of this.getFilteredAndSortedRows()) { if ((row.rowId === rowId1) || (row.rowId === rowId2)) { select = !select; this.selectedRows.push(row.rowId); } else if (select) { if (this.useVirtualList) { this.selectedRows.push(row.rowId); } else { const tr = this.getTrByRowId(row.rowId); if ((tr !== null) && !tr.classList.contains("invisible")) this.selectedRows.push(row.rowId); } } } this.setRowClass(); this.onSelectedRowChanged(); } reselectRows(rowIds) { this.deselectAll(); this.selectedRows = rowIds.slice(); this.setRowClass(); } selectInverse() { const currentSelectedRows = new Set(this.selectedRows); const newSelectedRows = this.getFilteredAndSortedRows() .filter(row => !currentSelectedRows.has(row.rowId)) .map(row => row.rowId); this.reselectRows(newSelectedRows); } setRowClass() { for (const tr of this.getTrs()) tr.classList.toggle("selected", this.isRowSelected(tr.rowId)); } onSelectedRowChanged() {} updateRowData(data) { // ensure rowId is a string const rowId = `${data["rowId"]}`; let row; if (!this.rows.has(rowId)) { row = { full_data: {}, rowId: rowId }; this.rows.set(rowId, row); } else { row = this.rows.get(rowId); } row["data"] = data; for (const x in data) { if (!Object.hasOwn(data, x)) continue; row["full_data"][x] = data[x]; } } /** * Replaces all rows with the provided set. * More efficient than calling clear() + updateRowData() because existing * elements are reused. * @param {Map} rows - A Map with rowId as key */ setRows(rows) { for (const existingId of this.rows.keys()) { const newVal = rows.get(existingId); if (newVal !== undefined) { this.updateRowData(newVal); rows.delete(existingId); } else { this.rows.delete(existingId); } } for (const data of rows.values()) this.updateRowData(data); } getTrs() { return this.tableBody.querySelectorAll("tr"); } getRowCells(tr) { return tr.querySelectorAll("td, th"); } getRow(rowId) { return this.rows.get(rowId); } getFilteredAndSortedRows() { const filteredRows = []; for (const row of this.getRowValues()) { filteredRows.push(row); filteredRows[row.rowId] = row; } filteredRows.sort(this.rowComparator()); return filteredRows; } getTrByRowId(rowId) { return Array.prototype.find.call(this.getTrs(), (tr => tr.rowId === rowId)); } updateTable(fullUpdate = false) { const rows = this.getFilteredAndSortedRows(); const rowIds = new Set(rows.map(row => row.rowId)); window.qBittorrent.Misc.filterInPlace(this.selectedRows, (selectedRow => rowIds.has(selectedRow))); if (this.useVirtualList) { // rerender on table update this.rerender(rows); } else { const trs = [...this.getTrs()]; const trMap = new Map(trs.map(tr => [tr.rowId, tr])); for (const row of rows) { const rowId = row.rowId; const existingTr = trMap.get(rowId); if (existingTr !== undefined) { this.updateRow(existingTr, fullUpdate); } else { const tr = this.createRowElement(row); // TODO look into using DocumentFragment or appending all trs at once for add'l performance gains // add to end of table - we'll move into the proper order later this.tableBody.appendChild(tr); trMap.set(rowId, tr); this.updateRow(tr, true); } } // reorder table rows let prevTr = null; for (const [rowPos, { rowId }] of rows.entries()) { const tr = trMap.get(rowId); trMap.delete(rowId); const isInCorrectLocation = rowId === trs[rowPos]?.rowId; if (!isInCorrectLocation) { // move row into correct location if (prevTr === null) { // insert as first row in table if (trs.length === 0) this.tableBody.append(tr); else trs[0].before(tr); } else { prevTr.after(tr); } } prevTr = tr; } for (const tr of trMap.values()) tr.remove(); } } rerender(rows = this.getFilteredAndSortedRows()) { // set the scrollable height const tableHeight = rows.length * this.rowHeight; if (tableHeight !== this.previousTableHeight) { this.previousTableHeight = tableHeight; this.table.style.height = `${tableHeight}px`; } if (this.renderedHeight === 0) return; // show extra rows at top/bottom to reduce flickering const extraRowCount = 20; // how many rows can be shown in the visible area const visibleRowCount = Math.ceil(this.renderedHeight / this.rowHeight) + (extraRowCount * 2); // start position of visible rows, offsetted by renderedOffset let startRow = Math.max((Math.trunc(this.renderedOffset / this.rowHeight) - extraRowCount), 0); // ensure startRow is even if ((startRow % 2) === 1) startRow = Math.max(0, startRow - 1); const endRow = Math.min((startRow + visibleRowCount), rows.length); const elements = []; for (let i = startRow; i < endRow; ++i) { const row = rows[i]; if (row === undefined) continue; const offset = i * this.rowHeight; const position = i - startRow; // reuse existing elements let element = this.cachedElements[position]; if (element !== undefined) this.updateRowElement(element, row.rowId, offset); else element = this.cachedElements[position] = this.createRowElement(row, offset); elements.push(element); } const hadFocus = this.dynamicTableDiv.contains(document.activeElement); this.tableBody.replaceChildren(...elements); // update row classes this.setRowClass(); // update visible rows for (const row of this.tableBody.children) this.updateRow(row, true); // restore focus so keyboard navigation continues working if (hadFocus) this.getTrByRowId(this.getSelectedRowId())?.focus({ preventScroll: true }); } createRowElement(row, top = -1) { const tr = document.createElement("tr"); // set tabindex so element receives keydown events // more info: https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event tr.tabIndex = -1; for (let k = 0; k < this.columns.length; ++k) { const td = document.createElement("td"); if ((this.columns[k].visible === "0") || this.columns[k].force_hide) td.classList.add("invisible"); tr.append(td); } this.updateRowElement(tr, row.rowId, top); // update context menu this.contextMenu?.addTarget(tr); return tr; } updateRowElement(tr, rowId, top) { tr.dataset.rowId = rowId; tr.rowId = rowId; tr.className = ""; if (this.useVirtualList) { tr.style.position = "absolute"; tr.style.top = `${top}px`; tr.style.height = `${this.rowHeight}px`; } } getRowData(row, fullUpdate) { return row[fullUpdate ? "full_data" : "data"]; } updateRow(tr, fullUpdate) { const row = this.rows.get(tr.rowId); const data = this.getRowData(row, fullUpdate); const tds = this.getRowCells(tr); for (let i = 0; i < this.columns.length; ++i) { // required due to position: absolute breaks table layout if (this.useVirtualList) { tds[i].style.width = `${this.columns[i].width}px`; tds[i].style.maxWidth = `${this.columns[i].width}px`; } if (this.columns[i].dataProperties.some(prop => Object.hasOwn(data, prop))) this.columns[i].updateTd(tds[i], row); } row.data = {}; } removeRow(rowId) { this.selectedRows.erase(rowId); this.rows.delete(rowId); if (this.useVirtualList) { this.rerender(); } else { const tr = this.getTrByRowId(rowId); tr?.remove(); } } clear() { this.deselectAll(); this.rows.clear(); if (this.useVirtualList) { this.rerender(); } else { for (const tr of this.getTrs()) tr.remove(); } } selectedRowsIds() { return this.selectedRows.slice(); } getRowIds() { return this.rows.keys(); } getRowValues() { return this.rows.values(); } getRowItems() { return this.rows.entries(); } getRowSize() { return this.rows.size; } selectNextRow() { const visibleRows = Array.prototype.filter.call(this.getTrs(), (tr => !tr.classList.contains("invisible") && (tr.style.display !== "none"))); const selectedRowId = this.getSelectedRowId(); const selectedIndex = visibleRows.findIndex(row => (row.getAttribute("data-row-id") === selectedRowId)); const isLastRowSelected = (selectedIndex >= (visibleRows.length - 1)); if (!isLastRowSelected) { this.deselectAll(); const newRow = visibleRows[selectedIndex + 1]; this.selectRow(newRow.getAttribute("data-row-id")); newRow.focus({ preventScroll: true }); } } selectPreviousRow() { const visibleRows = Array.prototype.filter.call(this.getTrs(), (tr => !tr.classList.contains("invisible") && (tr.style.display !== "none"))); const selectedRowId = this.getSelectedRowId(); const selectedIndex = visibleRows.findIndex(row => (row.getAttribute("data-row-id") === selectedRowId)); const isFirstRowSelected = selectedIndex <= 0; if (!isFirstRowSelected) { this.deselectAll(); const newRow = visibleRows[selectedIndex - 1]; this.selectRow(newRow.getAttribute("data-row-id")); newRow.focus({ preventScroll: true }); } } } class TorrentsTable extends DynamicTable { setupVirtualList() { super.setupVirtualList(); this.rowHeight = (clientData.get("display_density") === "compact") ? 18 : 22; } initColumns() { this.newColumn("priority", "", "#", 30, true); this.newColumn("state_icon", "", "QBT_TR(Status Icon)QBT_TR[CONTEXT=TransferListModel]", 30, false); this.newColumn("name", "", "QBT_TR(Name)QBT_TR[CONTEXT=TransferListModel]", 200, true); this.newColumn("size", "", "QBT_TR(Size)QBT_TR[CONTEXT=TransferListModel]", 100, true); this.newColumn("total_size", "", "QBT_TR(Total Size)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("progress", "", "QBT_TR(Progress)QBT_TR[CONTEXT=TransferListModel]", 85, true); this.newColumn("status", "", "QBT_TR(Status)QBT_TR[CONTEXT=TransferListModel]", 100, true); this.newColumn("num_seeds", "", "QBT_TR(Seeds)QBT_TR[CONTEXT=TransferListModel]", 100, true); this.newColumn("num_leechs", "", "QBT_TR(Peers)QBT_TR[CONTEXT=TransferListModel]", 100, true); this.newColumn("dlspeed", "", "QBT_TR(Down Speed)QBT_TR[CONTEXT=TransferListModel]", 100, true); this.newColumn("upspeed", "", "QBT_TR(Up Speed)QBT_TR[CONTEXT=TransferListModel]", 100, true); this.newColumn("eta", "", "QBT_TR(ETA)QBT_TR[CONTEXT=TransferListModel]", 100, true); this.newColumn("ratio", "", "QBT_TR(Ratio)QBT_TR[CONTEXT=TransferListModel]", 100, true); this.newColumn("popularity", "", "QBT_TR(Popularity)QBT_TR[CONTEXT=TransferListModel]", 100, true); this.newColumn("category", "", "QBT_TR(Category)QBT_TR[CONTEXT=TransferListModel]", 100, true); this.newColumn("tags", "", "QBT_TR(Tags)QBT_TR[CONTEXT=TransferListModel]", 100, true); this.newColumn("creation_date", "", "QBT_TR(Created On)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("added_on", "", "QBT_TR(Added On)QBT_TR[CONTEXT=TransferListModel]", 100, true); this.newColumn("completion_on", "", "QBT_TR(Completed On)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("tracker", "", "QBT_TR(Tracker)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("dl_limit", "", "QBT_TR(Down Limit)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("up_limit", "", "QBT_TR(Up Limit)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("downloaded", "", "QBT_TR(Downloaded)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("uploaded", "", "QBT_TR(Uploaded)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("downloaded_session", "", "QBT_TR(Session Downloaded)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("uploaded_session", "", "QBT_TR(Session Uploaded)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("amount_left", "", "QBT_TR(Remaining)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("time_active", "", "QBT_TR(Time Active)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("save_path", "", "QBT_TR(Save path)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("completed", "", "QBT_TR(Completed)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("max_ratio", "", "QBT_TR(Ratio Limit)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("seen_complete", "", "QBT_TR(Last Seen Complete)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("last_activity", "", "QBT_TR(Last Activity)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("availability", "", "QBT_TR(Availability)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("download_path", "", "QBT_TR(Incomplete Save Path)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("infohash_v1", "", "QBT_TR(Info Hash v1)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("infohash_v2", "", "QBT_TR(Info Hash v2)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("reannounce", "", "QBT_TR(Reannounce In)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.newColumn("private", "", "QBT_TR(Private)QBT_TR[CONTEXT=TransferListModel]", 100, false); this.columns["state_icon"].dataProperties[0] = "state"; this.columns["name"].dataProperties.push("state"); this.columns["num_seeds"].dataProperties.push("num_complete"); this.columns["num_leechs"].dataProperties.push("num_incomplete"); this.columns["time_active"].dataProperties.push("seeding_time"); this.initColumnsFunctions(); } initColumnsFunctions() { const getStateIconClasses = (state) => { let stateClass = "stateUnknown"; // normalize states switch (state) { case "forcedDL": case "metaDL": case "forcedMetaDL": case "downloading": stateClass = "stateDownloading"; break; case "forcedUP": case "uploading": stateClass = "stateUploading"; break; case "stalledUP": stateClass = "stateStalledUP"; break; case "stalledDL": stateClass = "stateStalledDL"; break; case "stoppedDL": stateClass = "stateStoppedDL"; break; case "stoppedUP": stateClass = "stateStoppedUP"; break; case "queuedDL": case "queuedUP": stateClass = "stateQueued"; break; case "checkingDL": case "checkingUP": case "queuedForChecking": case "checkingResumeData": stateClass = "stateChecking"; break; case "moving": stateClass = "stateMoving"; break; case "error": case "unknown": case "missingFiles": stateClass = "stateError"; break; default: break; // do nothing } return `stateIcon ${stateClass}`; }; const displaySize = function(td, row) { const size = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false); td.textContent = size; td.title = size; }; const displaySeeds = function(td, row) { const num_seeds = this.getRowValue(row, 0); const num_complete = this.getRowValue(row, 1); let value = num_seeds; if (num_complete !== -1) value += ` (${num_complete})`; td.textContent = value; td.title = value; }; const compareSeeds = function(row1, row2) { const num_seeds1 = this.getRowValue(row1, 0); const num_complete1 = this.getRowValue(row1, 1); const num_seeds2 = this.getRowValue(row2, 0); const num_complete2 = this.getRowValue(row2, 1); const result = compareNumbers(num_complete1, num_complete2); if (result !== 0) return result; return compareNumbers(num_seeds1, num_seeds2); }; const displaySpeed = function(td, row) { const speed = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), true); td.textContent = speed; td.title = speed; }; const displaySpeedOrInfinity = function(td, row) { const speed = this.getRowValue(row); const formattedSpeed = (speed === 0) ? "∞" : window.qBittorrent.Misc.friendlyUnit(speed, true); td.textContent = formattedSpeed; td.title = formattedSpeed; }; const displayRatio = function(td, row) { const ratio = this.getRowValue(row); const string = (ratio === -1) ? "∞" : window.qBittorrent.Misc.toFixedPointString(ratio, 2); td.textContent = string; td.title = string; }; const displayDate = function(td, row) { const date = toDateString(this.getRowValue(row)); td.textContent = date; td.title = date; }; const displayInfohash = function(td, row) { const sourceInfohash = this.getRowValue(row); const infohash = (sourceInfohash !== "") ? sourceInfohash : "QBT_TR(N/A)QBT_TR[CONTEXT=TransferListDelegate]"; td.textContent = infohash; td.title = infohash; }; // state_icon this.columns["state_icon"].updateTd = function(td, row) { const state = this.getRowValue(row); let div = td.firstElementChild; if (div === null) { div = document.createElement("div"); td.append(div); } div.className = `${getStateIconClasses(state)} stateIconColumn`; }; this.columns["state_icon"].onVisibilityChange = (columnName) => { // show state icon in name column only when standalone // state icon column is hidden this.updateColumn("name", true); }; // name this.columns["name"].updateTd = function(td, row) { const name = this.getRowValue(row, 0); const state = this.getRowValue(row, 1); let span = td.firstElementChild; if (span === null) { span = document.createElement("span"); td.append(span); } span.className = this.isStateIconShown() ? `${getStateIconClasses(state)}` : ""; span.textContent = name; td.title = name; }; this.columns["name"].isStateIconShown = () => !this.columns["state_icon"].isVisible(); // status this.columns["status"].updateTd = function(td, row) { const state = this.getRowValue(row); if (!state) return; let status; switch (state) { case "downloading": status = "QBT_TR(Downloading)QBT_TR[CONTEXT=TransferListDelegate]"; break; case "stalledDL": status = "QBT_TR(Stalled)QBT_TR[CONTEXT=TransferListDelegate]"; break; case "metaDL": status = "QBT_TR(Downloading metadata)QBT_TR[CONTEXT=TransferListDelegate]"; break; case "forcedMetaDL": status = "QBT_TR([F] Downloading metadata)QBT_TR[CONTEXT=TransferListDelegate]"; break; case "forcedDL": status = "QBT_TR([F] Downloading)QBT_TR[CONTEXT=TransferListDelegate]"; break; case "uploading": case "stalledUP": status = "QBT_TR(Seeding)QBT_TR[CONTEXT=TransferListDelegate]"; break; case "forcedUP": status = "QBT_TR([F] Seeding)QBT_TR[CONTEXT=TransferListDelegate]"; break; case "queuedDL": case "queuedUP": status = "QBT_TR(Queued)QBT_TR[CONTEXT=TransferListDelegate]"; break; case "checkingDL": case "checkingUP": status = "QBT_TR(Checking)QBT_TR[CONTEXT=TransferListDelegate]"; break; case "queuedForChecking": status = "QBT_TR(Queued for checking)QBT_TR[CONTEXT=TransferListDelegate]"; break; case "checkingResumeData": status = "QBT_TR(Checking resume data)QBT_TR[CONTEXT=TransferListDelegate]"; break; case "stoppedDL": status = "QBT_TR(Stopped)QBT_TR[CONTEXT=TransferListDelegate]"; break; case "stoppedUP": status = "QBT_TR(Completed)QBT_TR[CONTEXT=TransferListDelegate]"; break; case "moving": status = "QBT_TR(Moving)QBT_TR[CONTEXT=TransferListDelegate]"; break; case "missingFiles": status = "QBT_TR(Missing Files)QBT_TR[CONTEXT=TransferListDelegate]"; break; case "error": status = "QBT_TR(Errored)QBT_TR[CONTEXT=TransferListDelegate]"; break; default: status = "QBT_TR(Unknown)QBT_TR[CONTEXT=HttpServer]"; } td.textContent = status; td.title = status; }; this.columns["status"].compareRows = (row1, row2) => { return compareNumbers(row1.full_data._statusOrder, row2.full_data._statusOrder); }; // priority this.columns["priority"].updateTd = function(td, row) { const queuePos = this.getRowValue(row); const formattedQueuePos = (queuePos < 1) ? "*" : queuePos; td.textContent = formattedQueuePos; td.title = formattedQueuePos; }; this.columns["priority"].compareRows = function(row1, row2) { let row1_val = this.getRowValue(row1); let row2_val = this.getRowValue(row2); if (row1_val < 1) row1_val = 1000000; if (row2_val < 1) row2_val = 1000000; return compareNumbers(row1_val, row2_val); }; const compareNames = function(row1, row2) { const row1Val = this.getRowValue(row1); const row2Val = this.getRowValue(row2); return row1Val.localeCompare(row2Val, undefined, { numeric: true, sensitivity: "base" }); }; // name, category, tags this.columns["name"].compareRows = compareNames; this.columns["category"].compareRows = compareNames; this.columns["tags"].compareRows = compareNames; // size, total_size this.columns["size"].updateTd = displaySize; this.columns["total_size"].updateTd = displaySize; // progress this.columns["progress"].updateTd = function(td, row) { const progress = this.getRowValue(row); const progressFormatted = window.qBittorrent.Misc.toFixedPointString((progress * 100), 1); const div = td.firstElementChild; if (div !== null) { if (div.getValue() !== progressFormatted) div.setValue(progressFormatted); } else { td.append(new window.qBittorrent.ProgressBar.ProgressBar(progressFormatted)); } }; this.columns["progress"].staticWidth = 100; // num_seeds this.columns["num_seeds"].updateTd = displaySeeds; this.columns["num_seeds"].compareRows = compareSeeds; // num_leechs this.columns["num_leechs"].updateTd = displaySeeds; this.columns["num_leechs"].compareRows = compareSeeds; // dlspeed, upspeed this.columns["dlspeed"].updateTd = displaySpeed; this.columns["upspeed"].updateTd = displaySpeed; // eta this.columns["eta"].updateTd = function(td, row) { const eta = window.qBittorrent.Misc.friendlyDuration(this.getRowValue(row), window.qBittorrent.Misc.MAX_ETA); td.textContent = eta; td.title = eta; }; // ratio this.columns["ratio"].updateTd = displayRatio; // popularity this.columns["popularity"].updateTd = displayRatio; // creation_date this.columns["creation_date"].updateTd = displayDate; // added on this.columns["added_on"].updateTd = displayDate; // completion_on this.columns["completion_on"].updateTd = displayDate; // tracker this.columns["tracker"].updateTd = function(td, row) { const value = this.getRowValue(row); const tracker = displayFullURLTrackerColumn ? value : window.qBittorrent.Misc.getHost(value); td.textContent = tracker; td.title = value; }; // dl_limit, up_limit this.columns["dl_limit"].updateTd = displaySpeedOrInfinity; this.columns["up_limit"].updateTd = displaySpeedOrInfinity; // downloaded, uploaded, downloaded_session, uploaded_session, amount_left this.columns["downloaded"].updateTd = displaySize; this.columns["uploaded"].updateTd = displaySize; this.columns["downloaded_session"].updateTd = displaySize; this.columns["uploaded_session"].updateTd = displaySize; this.columns["amount_left"].updateTd = displaySize; // time active this.columns["time_active"].updateTd = function(td, row) { const activeTime = this.getRowValue(row, 0); const seedingTime = this.getRowValue(row, 1); const time = (seedingTime > 0) ? ("QBT_TR(%1 (seeded for %2))QBT_TR[CONTEXT=TransferListDelegate]" .replace("%1", window.qBittorrent.Misc.friendlyDuration(activeTime)) .replace("%2", window.qBittorrent.Misc.friendlyDuration(seedingTime))) : window.qBittorrent.Misc.friendlyDuration(activeTime); td.textContent = time; td.title = time; }; // completed this.columns["completed"].updateTd = displaySize; // max_ratio this.columns["max_ratio"].updateTd = displayRatio; // seen_complete this.columns["seen_complete"].updateTd = displayDate; // last_activity this.columns["last_activity"].updateTd = function(td, row) { const val = this.getRowValue(row); if (val < 1) { td.textContent = "∞"; td.title = "∞"; } else { const formattedVal = "QBT_TR(%1 ago)QBT_TR[CONTEXT=TransferListDelegate]".replace("%1", window.qBittorrent.Misc.friendlyDuration((Date.now() / 1000) - val)); td.textContent = formattedVal; td.title = formattedVal; } }; // availability this.columns["availability"].updateTd = function(td, row) { const value = window.qBittorrent.Misc.toFixedPointString(this.getRowValue(row), 3); td.textContent = value; td.title = value; }; // infohash_v1, infohash_v2 this.columns["infohash_v1"].updateTd = displayInfohash; this.columns["infohash_v2"].updateTd = displayInfohash; // reannounce this.columns["reannounce"].updateTd = function(td, row) { const time = window.qBittorrent.Misc.friendlyDuration(this.getRowValue(row)); td.textContent = time; td.title = time; }; // private this.columns["private"].updateTd = function(td, row) { const hasMetadata = row["full_data"].has_metadata; const isPrivate = this.getRowValue(row); const string = hasMetadata ? (isPrivate ? "QBT_TR(Yes)QBT_TR[CONTEXT=PropertiesWidget]" : "QBT_TR(No)QBT_TR[CONTEXT=PropertiesWidget]") : "QBT_TR(N/A)QBT_TR[CONTEXT=PropertiesWidget]"; td.textContent = string; td.title = string; }; } applyFilter(row, filterName, category, tag, trackerHost, filterTerms) { const { state, upspeed, dlspeed } = row["full_data"]; let inactive = false; switch (filterName) { case "downloading": if ((state !== "downloading") && !state.includes("DL")) return false; break; case "seeding": if ((state !== "uploading") && (state !== "forcedUP") && (state !== "stalledUP") && (state !== "queuedUP") && (state !== "checkingUP")) return false; break; case "completed": if ((state !== "uploading") && !state.includes("UP")) return false; break; case "stopped": if (!state.includes("stopped")) return false; break; case "running": if (state.includes("stopped")) return false; break; case "stalled": if ((state !== "stalledUP") && (state !== "stalledDL")) return false; break; case "stalled_uploading": if (state !== "stalledUP") return false; break; case "stalled_downloading": if (state !== "stalledDL") return false; break; case "inactive": inactive = true; // fallthrough case "active": { const r = (upspeed > 0) || (dlspeed > 0); if (r === inactive) return false; break; } case "checking": if ((state !== "checkingUP") && (state !== "checkingDL") && (state !== "checkingResumeData")) return false; break; case "moving": if (state !== "moving") return false; break; case "errored": if ((state !== "error") && (state !== "unknown") && (state !== "missingFiles")) return false; break; } switch (category) { case CATEGORIES_ALL: break; // do nothing case CATEGORIES_UNCATEGORIZED: if (row["full_data"].category.length > 0) return false; break; // do nothing default: { const selectedCategory = window.qBittorrent.Client.categoryMap.get(category); if (selectedCategory !== undefined) { const selectedCategoryName = `${category}/`; const torrentCategoryName = `${row["full_data"].category}/`; if (!torrentCategoryName.startsWith(selectedCategoryName)) return false; } break; } } switch (tag) { case TAGS_ALL: break; // do nothing case TAGS_UNTAGGED: if (row["full_data"].tags.length > 0) return false; break; // do nothing default: { const tags = row["full_data"].tags.split(", "); if (!tags.contains(tag)) return false; break; } } switch (trackerHost) { case TRACKERS_ALL: break; // do nothing case TRACKERS_ANNOUNCE_ERROR: if (!row["full_data"]["has_other_announce_error"]) return false; break; case TRACKERS_ERROR: if (!row["full_data"]["has_tracker_error"]) return false; break; case TRACKERS_TRACKERLESS: if (row["full_data"].trackers_count > 0) return false; break; case TRACKERS_WARNING: if (!row["full_data"]["has_tracker_warning"]) return false; break; default: { const trackerTorrentMap = trackerMap.get(trackerHost); if (trackerTorrentMap !== undefined) { let found = false; for (const torrents of trackerTorrentMap.values()) { if (torrents.has(row["full_data"].rowId)) { found = true; break; } } if (!found) return false; } break; } } if ((filterTerms !== undefined) && (filterTerms !== null)) { const filterBy = document.getElementById("torrentsFilterSelect").value; const textToSearch = row["full_data"][filterBy].toLowerCase(); if (filterTerms instanceof RegExp) { if (!filterTerms.test(textToSearch)) return false; } else { if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(textToSearch, filterTerms)) return false; } } return true; } getFilteredTorrentsNumber(filterName, category, tag, tracker) { let cnt = 0; for (const row of this.rows.values()) { if (this.applyFilter(row, filterName, category, tag, tracker, null)) ++cnt; } return cnt; } getFilteredTorrentsHashes(filterName, category, tag, tracker) { const rowsHashes = []; const useRegex = document.getElementById("torrentsFilterRegexBox").checked; const filterText = document.getElementById("torrentsFilterInput").value.trim().toLowerCase(); let filterTerms; try { filterTerms = (filterText.length > 0) ? (useRegex ? new RegExp(filterText) : filterText.split(" ")) : null; } catch (e) { // SyntaxError: Invalid regex pattern return filteredRows; } for (const row of this.rows.values()) { if (this.applyFilter(row, filterName, category, tag, tracker, filterTerms)) rowsHashes.push(row["rowId"]); } return rowsHashes; } getFilteredAndSortedRows() { const filteredRows = []; const useRegex = document.getElementById("torrentsFilterRegexBox").checked; const filterText = document.getElementById("torrentsFilterInput").value.trim().toLowerCase(); let filterTerms; try { filterTerms = (filterText.length > 0) ? (useRegex ? new RegExp(filterText) : filterText.split(" ")) : null; } catch (e) { // SyntaxError: Invalid regex pattern return filteredRows; } for (const row of this.rows.values()) { if (this.applyFilter(row, selectedStatus, selectedCategory, selectedTag, selectedTracker, filterTerms)) { filteredRows.push(row); filteredRows[row.rowId] = row; } } filteredRows.sort(this.rowComparator()); return filteredRows; } setupCommonEvents() { super.setupCommonEvents(); this.dynamicTableDiv.addEventListener("dblclick", (e) => { const tr = e.target.closest("tr"); if ((tr === null) || (tr.rowId === undefined)) return; this.deselectAll(); this.selectRow(tr.rowId); const row = this.getRow(tr.rowId); const state = row["full_data"].state; const prefKey = (state !== "uploading") && (state !== "stoppedUP") && (state !== "forcedUP") && (state !== "stalledUP") && (state !== "queuedUP") && (state !== "checkingUP") ? "dblclick_download" : "dblclick_complete"; if (clientData.get(prefKey) !== "1") return true; if (state.includes("stopped")) startFN(); else stopFN(); }); } getCurrentTorrentID() { return this.getSelectedRowId(); } onSelectedRowChanged() { updatePropertiesPanel(); } isStopped(hash) { const row = this.getRow(hash); return (row === undefined) ? true : row.full_data.state.includes("stopped"); } } class TorrentPeersTable extends DynamicTable { initColumns() { this.newColumn("country", "", "QBT_TR(Country/Region)QBT_TR[CONTEXT=PeerListWidget]", 22, true); this.newColumn("ip", "", "QBT_TR(IP/Address)QBT_TR[CONTEXT=PeerListWidget]", 80, true); this.newColumn("port", "", "QBT_TR(Port)QBT_TR[CONTEXT=PeerListWidget]", 35, true); this.newColumn("connection", "", "QBT_TR(Connection)QBT_TR[CONTEXT=PeerListWidget]", 50, true); this.newColumn("flags", "", "QBT_TR(Flags)QBT_TR[CONTEXT=PeerListWidget]", 50, true); this.newColumn("client", "", "QBT_TR(Client)QBT_TR[CONTEXT=PeerListWidget]", 140, true); this.newColumn("peer_id_client", "", "QBT_TR(Peer ID Client)QBT_TR[CONTEXT=PeerListWidget]", 60, false); this.newColumn("progress", "", "QBT_TR(Progress)QBT_TR[CONTEXT=PeerListWidget]", 50, true); this.newColumn("dl_speed", "", "QBT_TR(Down Speed)QBT_TR[CONTEXT=PeerListWidget]", 50, true); this.newColumn("up_speed", "", "QBT_TR(Up Speed)QBT_TR[CONTEXT=PeerListWidget]", 50, true); this.newColumn("downloaded", "", "QBT_TR(Downloaded)QBT_TR[CONTEXT=PeerListWidget]", 50, true); this.newColumn("uploaded", "", "QBT_TR(Uploaded)QBT_TR[CONTEXT=PeerListWidget]", 50, true); this.newColumn("relevance", "", "QBT_TR(Relevance)QBT_TR[CONTEXT=PeerListWidget]", 30, true); this.newColumn("contribution", "", "QBT_TR(Contribution)QBT_TR[CONTEXT=PeerListWidget]", 30, true); this.newColumn("files", "", "QBT_TR(Files)QBT_TR[CONTEXT=PeerListWidget]", 100, true); this.columns["country"].dataProperties.push("country_code"); this.columns["ip"].dataProperties.push("host_name"); this.columns["flags"].dataProperties.push("flags_desc"); this.initColumnsFunctions(); } initColumnsFunctions() { const displayProgress = function(td, row) { const progress = this.getRowValue(row); const progressFormatted = `${window.qBittorrent.Misc.toFixedPointString((progress * 100), 1)}%`; td.textContent = progressFormatted; td.title = progressFormatted; }; const displaySpeed = function(td, row) { const speed = this.getRowValue(row); const formattedSpeed = (speed === 0) ? "" : window.qBittorrent.Misc.friendlyUnit(speed, true); td.textContent = formattedSpeed; td.title = formattedSpeed; }; const displaySize = function(td, row) { const size = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false); td.textContent = size; td.title = size; }; // country this.columns["country"].updateTd = function(td, row) { const country = this.getRowValue(row, 0); const country_code = this.getRowValue(row, 1); let span = td.firstElementChild; if (span === null) { span = document.createElement("span"); span.classList.add("flags"); td.append(span); } span.style.backgroundImage = `url('images/flags/${country_code || "xx"}.svg')`; span.textContent = country; td.title = country; }; // ip this.columns["ip"].updateTd = function(td, row) { const ip = this.getRowValue(row, 0); const hostname = this.getRowValue(row, 1); const displayValue = (hostname?.length > 0) ? hostname : ip; td.textContent = displayValue; td.title = displayValue; }; this.columns["ip"].compareRows = function(row1, row2) { const ip1 = this.getRowValue(row1); const ip2 = this.getRowValue(row2); return window.qBittorrent.Misc.naturalSortCollator.compare(ip1, ip2); }; // flags this.columns["flags"].updateTd = function(td, row) { td.textContent = this.getRowValue(row, 0); td.title = this.getRowValue(row, 1); }; // progress this.columns["progress"].updateTd = displayProgress; // dl_speed, up_speed this.columns["dl_speed"].updateTd = displaySpeed; this.columns["up_speed"].updateTd = displaySpeed; // downloaded, uploaded this.columns["downloaded"].updateTd = displaySize; this.columns["uploaded"].updateTd = displaySize; // relevance this.columns["relevance"].updateTd = displayProgress; this.columns["relevance"].staticWidth = 100; // contribution this.columns["contribution"].updateTd = displayProgress; this.columns["contribution"].staticWidth = 100; // files this.columns["files"].updateTd = function(td, row) { const value = this.getRowValue(row, 0); td.textContent = value.replace(/\n/g, ";"); td.title = value; }; } } class SearchResultsTable extends DynamicTable { initColumns() { this.newColumn("fileName", "", "QBT_TR(Name)QBT_TR[CONTEXT=SearchResultsTable]", 500, true); this.newColumn("fileSize", "", "QBT_TR(Size)QBT_TR[CONTEXT=SearchResultsTable]", 100, true); this.newColumn("nbSeeders", "", "QBT_TR(Seeders)QBT_TR[CONTEXT=SearchResultsTable]", 100, true); this.newColumn("nbLeechers", "", "QBT_TR(Leechers)QBT_TR[CONTEXT=SearchResultsTable]", 100, true); this.newColumn("engineName", "", "QBT_TR(Engine)QBT_TR[CONTEXT=SearchResultsTable]", 100, true); this.newColumn("siteUrl", "", "QBT_TR(Engine URL)QBT_TR[CONTEXT=SearchResultsTable]", 250, true); this.newColumn("pubDate", "", "QBT_TR(Published On)QBT_TR[CONTEXT=SearchResultsTable]", 200, true); this.initColumnsFunctions(); } initColumnsFunctions() { const displaySize = function(td, row) { const size = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false); td.textContent = size; td.title = size; }; const displayNum = function(td, row) { const value = this.getRowValue(row); const formattedValue = (value === "-1") ? "Unknown" : value; td.textContent = formattedValue; td.title = formattedValue; }; const displayDate = function(td, row) { const date = toDateString(this.getRowValue(row)); td.textContent = date; td.title = date; }; this.columns["fileSize"].updateTd = displaySize; this.columns["nbSeeders"].updateTd = displayNum; this.columns["nbLeechers"].updateTd = displayNum; this.columns["pubDate"].updateTd = displayDate; } getFilteredAndSortedRows() { const getSizeFilters = () => { let minSize = (window.qBittorrent.Search.searchSizeFilter.min > 0) ? (window.qBittorrent.Search.searchSizeFilter.min * Math.pow(1024, window.qBittorrent.Search.searchSizeFilter.minUnit)) : 0; let maxSize = (window.qBittorrent.Search.searchSizeFilter.max > 0) ? (window.qBittorrent.Search.searchSizeFilter.max * Math.pow(1024, window.qBittorrent.Search.searchSizeFilter.maxUnit)) : 0; if ((minSize > maxSize) && (maxSize > 0)) { const tmp = minSize; minSize = maxSize; maxSize = tmp; } return { min: minSize, max: maxSize }; }; const getSeedsFilters = () => { let minSeeds = (window.qBittorrent.Search.searchSeedsFilter.min > 0) ? window.qBittorrent.Search.searchSeedsFilter.min : 0; let maxSeeds = (window.qBittorrent.Search.searchSeedsFilter.max > 0) ? window.qBittorrent.Search.searchSeedsFilter.max : 0; if ((minSeeds > maxSeeds) && (maxSeeds > 0)) { const tmp = minSeeds; minSeeds = maxSeeds; maxSeeds = tmp; } return { min: minSeeds, max: maxSeeds }; }; let filteredRows = []; const searchTerms = window.qBittorrent.Search.searchText.pattern.toLowerCase().split(" "); const filterTerms = window.qBittorrent.Search.searchText.filterPattern.toLowerCase().split(" "); const sizeFilters = getSizeFilters(); const seedsFilters = getSeedsFilters(); const searchInTorrentName = document.getElementById("searchInTorrentName").value === "names"; if (searchInTorrentName || (filterTerms.length > 0) || (window.qBittorrent.Search.searchSizeFilter.min > 0) || (window.qBittorrent.Search.searchSizeFilter.max > 0)) { for (const row of this.getRowValues()) { if (searchInTorrentName && !window.qBittorrent.Misc.containsAllTerms(row.full_data.fileName, searchTerms)) continue; if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(row.full_data.fileName, filterTerms)) continue; if ((sizeFilters.min > 0) && (row.full_data.fileSize < sizeFilters.min)) continue; if ((sizeFilters.max > 0) && (row.full_data.fileSize > sizeFilters.max)) continue; if ((seedsFilters.min > 0) && (row.full_data.nbSeeders < seedsFilters.min)) continue; if ((seedsFilters.max > 0) && (row.full_data.nbSeeders > seedsFilters.max)) continue; filteredRows.push(row); } } else { filteredRows = [...this.getRowValues()]; } filteredRows.sort(this.rowComparator()); return filteredRows; } } class SearchPluginsTable extends DynamicTable { initColumns() { this.newColumn("fullName", "", "QBT_TR(Name)QBT_TR[CONTEXT=SearchPluginsTable]", 175, true); this.newColumn("version", "", "QBT_TR(Version)QBT_TR[CONTEXT=SearchPluginsTable]", 100, true); this.newColumn("url", "", "QBT_TR(Url)QBT_TR[CONTEXT=SearchPluginsTable]", 175, true); this.newColumn("enabled", "", "QBT_TR(Enabled)QBT_TR[CONTEXT=SearchPluginsTable]", 100, true); this.initColumnsFunctions(); } initColumnsFunctions() { this.columns["enabled"].updateTd = function(td, row) { const value = this.getRowValue(row); if (value) { td.textContent = "QBT_TR(Yes)QBT_TR[CONTEXT=SearchPluginsTable]"; td.title = "QBT_TR(Yes)QBT_TR[CONTEXT=SearchPluginsTable]"; td.closest("tr").classList.add("green"); td.closest("tr").classList.remove("red"); } else { td.textContent = "QBT_TR(No)QBT_TR[CONTEXT=SearchPluginsTable]"; td.title = "QBT_TR(No)QBT_TR[CONTEXT=SearchPluginsTable]"; td.closest("tr").classList.add("red"); td.closest("tr").classList.remove("green"); } }; } } class TorrentTrackersTable extends DynamicTable { collapseState = new Map(); // { rowId: String, isCollapsed: bool } isTrackerCollapsed(id) { return this.collapseState.get(id) ?? true; } toggleTrackerCollapsed(id) { this.collapseState.set(id, !this.isTrackerCollapsed(id)); this.#updateTrackerRowState(id, this.isTrackerCollapsed(id)); } #updateEndpointVisibility(endpoint, shouldHide) { const span = document.getElementById(`trackersTableTrackerUrl${endpoint}`); // span won't exist if row has been filtered out if (span === null) return; const tr = span.parentElement.parentElement; tr.classList.toggle("invisible", shouldHide); } #updateTrackerCollapseIcon(tracker, isCollapsed) { const span = document.getElementById(`trackersTableTrackerUrl${tracker}`); // span won't exist if row has been filtered out if (span === null) return; const td = span.parentElement; // rotate the collapse icon const collapseIcon = td.firstElementChild; collapseIcon.classList.toggle("rotate", isCollapsed); } #updateTrackerRowState(id, shouldCollapse) { // collapsed rows will be filtered out when using virtual list if (this.useVirtualList) return; this.#updateTrackerCollapseIcon(id, shouldCollapse); for (const row of this.getRowValues()) { const parentId = row.full_data._tracker; if (parentId === id) this.#updateEndpointVisibility(row.rowId, shouldCollapse); } } clearCollapseState() { this.collapseState.clear(); } initColumns() { this.newColumn("url", "", "QBT_TR(URL/Announce Endpoint)QBT_TR[CONTEXT=TrackerListWidget]", 250, true); this.newColumn("tier", "", "QBT_TR(Tier)QBT_TR[CONTEXT=TrackerListWidget]", 35, true); this.newColumn("btVersion", "", "QBT_TR(BT Protocol)QBT_TR[CONTEXT=TrackerListWidget]", 35, true); this.newColumn("status", "", "QBT_TR(Status)QBT_TR[CONTEXT=TrackerListWidget]", 125, true); this.newColumn("peers", "", "QBT_TR(Peers)QBT_TR[CONTEXT=TrackerListWidget]", 75, true); this.newColumn("seeds", "", "QBT_TR(Seeds)QBT_TR[CONTEXT=TrackerListWidget]", 75, true); this.newColumn("leeches", "", "QBT_TR(Leeches)QBT_TR[CONTEXT=TrackerListWidget]", 75, true); this.newColumn("downloaded", "", "QBT_TR(Times Downloaded)QBT_TR[CONTEXT=TrackerListWidget]", 100, true); this.newColumn("message", "", "QBT_TR(Message)QBT_TR[CONTEXT=TrackerListWidget]", 250, true); this.newColumn("nextAnnounce", "", "QBT_TR(Next Announce)QBT_TR[CONTEXT=TrackerListWidget]", 150, true); this.newColumn("minAnnounce", "", "QBT_TR(Min Announce)QBT_TR[CONTEXT=TrackerListWidget]", 150, true); this.initColumnsFunctions(); } initColumnsFunctions() { const naturalSort = function(row1, row2) { if (!row1.full_data._sortable || !row2.full_data._sortable) return 0; const value1 = this.getRowValue(row1); const value2 = this.getRowValue(row2); return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2); }; this.columns["url"].updateTd = (td, row) => { const id = row.rowId; const data = row.full_data; let collapseIcon = td.firstElementChild; if (collapseIcon === null) { collapseIcon = document.createElement("img"); collapseIcon.src = "images/go-down.svg"; collapseIcon.className = "filesTableCollapseIcon"; collapseIcon.addEventListener("click", (e) => { const id = collapseIcon.dataset.id; this.toggleTrackerCollapsed(id); if (this.useVirtualList) this.rerender(); }); td.append(collapseIcon); } if (data._isTracker) { collapseIcon.style.display = "inline"; collapseIcon.style.visibility = data._hasEndpoints ? "visible" : "hidden"; collapseIcon.dataset.id = id; collapseIcon.classList.toggle("rotate", this.isTrackerCollapsed(id)); } else { collapseIcon.style.display = "none"; } let span = td.children[1]; if (span === undefined) { span = document.createElement("span"); td.append(span); } span.id = `trackersTableTrackerUrl${id}`; span.textContent = data.url; span.style.marginLeft = data._isTracker ? "0" : "20px"; }; this.columns["url"].compareRows = naturalSort; this.columns["status"].compareRows = naturalSort; this.columns["message"].compareRows = naturalSort; const sortNumbers = function(row1, row2) { if (!row1.full_data._sortable || !row2.full_data._sortable) return 0; const value1 = this.getRowValue(row1); const value2 = this.getRowValue(row2); if (value1 === "") return -1; if (value2 === "") return 1; return compareNumbers(value1, value2); }; this.columns["tier"].compareRows = sortNumbers; const sortMixed = function(row1, row2) { if (!row1.full_data._sortable || !row2.full_data._sortable) return 0; const value1 = this.getRowValue(row1); const value2 = this.getRowValue(row2); if (value1 === "QBT_TR(N/A)QBT_TR[CONTEXT=TrackerListWidget]") return -1; if (value2 === "QBT_TR(N/A)QBT_TR[CONTEXT=TrackerListWidget]") return 1; return compareNumbers(value1, value2); }; this.columns["peers"].compareRows = sortMixed; this.columns["seeds"].compareRows = sortMixed; this.columns["leeches"].compareRows = sortMixed; this.columns["downloaded"].compareRows = sortMixed; this.columns["status"].updateTd = function(td, row) { let statusClass = "trackerUnknown"; const status = this.getRowValue(row); switch (status) { case "QBT_TR(Disabled)QBT_TR[CONTEXT=TrackerListWidget]": statusClass = "trackerDisabled"; break; case "QBT_TR(Not contacted yet)QBT_TR[CONTEXT=TrackerListWidget]": statusClass = "trackerNotContacted"; break; case "QBT_TR(Working)QBT_TR[CONTEXT=TrackerListWidget]": statusClass = "trackerWorking"; break; case "QBT_TR(Updating...)QBT_TR[CONTEXT=TrackerListWidget]": statusClass = "trackerUpdating"; break; case "QBT_TR(Not working)QBT_TR[CONTEXT=TrackerListWidget]": case "QBT_TR(Tracker error)QBT_TR[CONTEXT=TrackerListWidget]": case "QBT_TR(Unreachable)QBT_TR[CONTEXT=TrackerListWidget]": statusClass = "trackerNotWorking"; break; } for (const c of [...td.classList]) { if (c.startsWith("tracker")) td.classList.remove(c); } td.classList.add(statusClass); td.textContent = status; td.title = status; }; const friendlyDuration = function(td, row) { const value = this.getRowValue(row) ?? 0; const seconds = Math.max(value - (Date.now() / 1000), 0); const duration = window.qBittorrent.Misc.friendlyDuration(seconds, window.qBittorrent.Misc.MAX_ETA); td.textContent = duration; td.title = duration; }; this.columns["nextAnnounce"].updateTd = friendlyDuration; this.columns["minAnnounce"].updateTd = friendlyDuration; } getFilteredAndSortedRows() { const trackers = []; const trakcerEndpoints = new Map(); for (const row of this.getRowValues()) { const tracker = row.full_data._tracker; if (tracker) { if (this.useVirtualList && this.isTrackerCollapsed(tracker)) continue; const endpoints = trakcerEndpoints.get(tracker); if (endpoints === undefined) trakcerEndpoints.set(tracker, [row]); else endpoints.push(row); } else { trackers.push(row); } } const sortRows = this.rowComparator(); const result = []; for (const tracker of trackers.sort(sortRows)) { result.push(tracker); const endpoints = trakcerEndpoints.get(tracker.rowId) || []; result.push(...endpoints.sort(sortRows)); } return result; } updateTable(fullUpdate = false) { super.updateTable(fullUpdate); if (!this.useVirtualList) { for (const row of this.getRowValues()) { if (row.full_data._isTracker) continue; this.#updateEndpointVisibility(row.rowId, this.isTrackerCollapsed(row.full_data._tracker)); } } } setupCommonEvents() { super.setupCommonEvents(); this.dynamicTableDiv.addEventListener("dblclick", (e) => { const tr = e.target.closest("tr"); if (!tr || (tr.rowId.startsWith("** [") || tr.rowId.startsWith("endpoint|"))) return; window.qBittorrent.PropTrackers.editTracker(tr); }); } } class TorrentFilesTable extends DynamicTable { filterTerms = []; prevFilterTerms = []; prevRowsString = null; prevFilteredRows = []; prevSortedColumn = null; prevReverseSort = null; fileTree = new window.qBittorrent.FileTree.FileTree(); supportCollapsing = true; collapseState = new Map(); fileNameColumn = "name"; headerCheckboxClickHandler = null; headerCheckboxId = "tristateCb"; updateHeaderCheckbox() { const checkbox = document.getElementById(this.headerCheckboxId); if (checkbox === null) return; if (this.getRowSize() === 0) return; const TriState = window.qBittorrent.FileTree.TriState; if (this.isAllCheckboxesChecked()) { checkbox.state = TriState.Checked; checkbox.indeterminate = false; checkbox.checked = true; } else if (this.isAllCheckboxesUnchecked()) { checkbox.state = TriState.Unchecked; checkbox.indeterminate = false; checkbox.checked = false; } else { checkbox.state = TriState.Partial; checkbox.indeterminate = true; } } injectHeaderCheckbox() { document.getElementById(this.headerCheckboxId)?.remove(); if (!this.headerCheckboxClickHandler) return; const checkboxTH = document.querySelector(`#${this.dynamicTableFixedHeaderDivId} .column_checked`); if (checkboxTH === null) return; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.id = this.headerCheckboxId; checkbox.addEventListener("click", this.headerCheckboxClickHandler); checkboxTH.appendChild(checkbox); this.updateHeaderCheckbox(); } updateTableHeaders() { super.updateTableHeaders(); this.injectHeaderCheckbox(); } isCollapsed(id) { if (!this.supportCollapsing) return false; return this.collapseState.get(id)?.collapsed ?? false; } expandNode(id) { if (!this.supportCollapsing) return; const state = this.collapseState.get(id); if (state !== undefined) state.collapsed = false; this.#updateNodeState(id, false); } collapseNode(id) { if (!this.supportCollapsing) return; const state = this.collapseState.get(id); if (state !== undefined) state.collapsed = true; this.#updateNodeState(id, true); } expandAllNodes() { if (!this.supportCollapsing) return; for (const [key, _] of this.collapseState) this.expandNode(key); if (this.useVirtualList) this.rerender(); } collapseAllNodes() { if (!this.supportCollapsing) return; for (const [key, state] of this.collapseState) { // collapse all nodes except root if (state.depth >= 1) this.collapseNode(key); } if (this.useVirtualList) this.rerender(); } #updateNodeVisibility(node, shouldHide) { const span = document.getElementById(`filesTablefileName${node.rowId}`); // span won't exist if row has been filtered out if (span === null) return; const tr = span.parentElement.parentElement; tr.classList.toggle("invisible", shouldHide); } #updateNodeCollapseIcon(node, isCollapsed) { const span = document.getElementById(`filesTablefileName${node.rowId}`); // span won't exist if row has been filtered out if (span === null) return; const td = span.parentElement; // rotate the collapse icon const collapseIcon = td.firstElementChild; collapseIcon.classList.toggle("rotate", isCollapsed); } #updateNodeState(id, shouldCollapse) { // collapsed rows will be filtered out when using virtual list if (this.useVirtualList) return; const node = this.getNode(id); if (!node.isFolder) return; this.#updateNodeCollapseIcon(node, shouldCollapse); this.#updateNodeChildVisibility(node, shouldCollapse); } #updateNodeChildVisibility(root, shouldHide) { const stack = [...root.children]; while (stack.length > 0) { const node = stack.pop(); this.#updateNodeVisibility(node, (shouldHide ? shouldHide : this.isCollapsed(node.root.rowId))); stack.push(...node.children); } } clear() { super.clear(); this.collapseState.clear(); } expandFolder(id) { if (!this.supportCollapsing) return; const node = this.getNode(id); if (node.isFolder) { this.expandNode(node.rowId); if (this.useVirtualList) this.rerender(); } } collapseFolder(id) { if (!this.supportCollapsing) return; const node = this.getNode(id); if (node.isFolder && !this.isCollapsed(node.rowId)) { this.collapseNode(node.rowId); if (this.useVirtualList) this.rerender(); return; } // when node is already collapsed or is a file, select parent const parent = node.root; if ((parent === null) || (parent.rowId === null)) return; const parentRowId = parent.rowId.toString(); this.deselectAll(); this.selectRow(parentRowId); this.getTrByRowId(parentRowId)?.focus({ preventScroll: true }); } isAllCheckboxesChecked() { return this.fileTree.toArray().every((node) => node.checked === window.qBittorrent.FileTree.TriState.Checked); } isAllCheckboxesUnchecked() { return this.fileTree.toArray().every((node) => node.checked !== window.qBittorrent.FileTree.TriState.Checked); } populateTable(root) { this.fileTree.setRoot(root); for (const node of root.children) this.#addNodeToTable(node, 0, root); } #addNodeToTable(node, depth, parent) { node.depth = depth; node.parent = parent; if (node.isFolder && this.supportCollapsing && !this.collapseState.has(node.rowId)) this.collapseState.set(node.rowId, { depth: depth, collapsed: false }); this.updateRowData({ rowId: node.rowId, }); for (const child of node.children) this.#addNodeToTable(child, depth + 1, node); } getFileTreeArray() { return this.fileTree.toArray(); } getRoot() { return this.fileTree.getRoot(); } getNode(rowId) { return this.fileTree.getNode(rowId); } getRow(node) { const rowId = this.fileTree.getRowId(node).toString(); return this.rows.get(rowId); } getRowFileId(rowId) { const node = this.getNode(rowId); return node.fileId; } getRowData(row, fullUpdate) { return this.getNode(row.rowId); } calculateRemaining() { this.fileTree.getRoot().calculateRemaining(); } initColumns() { this.newColumn("checked", "", "", 50, true); this.newColumn("name", "", "QBT_TR(Name)QBT_TR[CONTEXT=TrackerListWidget]", 300, true); this.newColumn("size", "", "QBT_TR(Total Size)QBT_TR[CONTEXT=TrackerListWidget]", 75, true); this.newColumn("progress", "", "QBT_TR(Progress)QBT_TR[CONTEXT=TrackerListWidget]", 100, true); this.newColumn("priority", "", "QBT_TR(Download Priority)QBT_TR[CONTEXT=TrackerListWidget]", 150, true); this.newColumn("remaining", "", "QBT_TR(Remaining)QBT_TR[CONTEXT=TrackerListWidget]", 75, true); this.newColumn("availability", "", "QBT_TR(Availability)QBT_TR[CONTEXT=TrackerListWidget]", 75, true); this.initColumnsFunctions(); } initColumnsFunctions() { const that = this; const displaySize = function(td, row) { const size = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false); td.textContent = size; td.title = size; }; const displayPercentage = function(td, row) { const value = window.qBittorrent.Misc.friendlyPercentage(this.getRowValue(row)); td.textContent = value; td.title = value; }; // checked this.columns["checked"].updateTd = function(td, row) { const id = row.rowId; const value = this.getRowValue(row); const fileId = that.getRowFileId(id); if (td.firstElementChild === null) { const treeImg = document.createElement("img"); treeImg.src = "images/L.svg"; treeImg.style.verticalAlign = "top"; td.append(treeImg); } const downloadCheckbox = td.children[1]; if (downloadCheckbox === undefined) td.append(window.qBittorrent.TorrentContent.createDownloadCheckbox(id, fileId, value)); else window.qBittorrent.TorrentContent.updateDownloadCheckbox(downloadCheckbox, id, fileId, value); }; this.columns["checked"].staticWidth = 50; // name this.columns["name"].updateTd = function(td, row) { const id = row.rowId; const fileNameId = `filesTablefileName${id}`; const node = that.getNode(id); const value = this.getRowValue(row); let collapseIcon = td.firstElementChild; if (collapseIcon === null) { collapseIcon = document.createElement("img"); collapseIcon.src = "images/go-down.svg"; collapseIcon.className = "filesTableCollapseIcon"; collapseIcon.addEventListener("click", (e) => { const id = collapseIcon.dataset.id; const node = that.getNode(id); if (node !== null) { if (that.isCollapsed(node.rowId)) that.expandNode(node.rowId); else that.collapseNode(node.rowId); if (that.useVirtualList) that.rerender(); } }); td.append(collapseIcon); } if (node.isFolder) { collapseIcon.style.marginLeft = `${node.depth * 20}px`; collapseIcon.style.display = "inline"; collapseIcon.dataset.id = id; collapseIcon.classList.toggle("rotate", that.isCollapsed(node.rowId)); } else { collapseIcon.style.display = "none"; } let dirImg = td.children[1]; if (dirImg === undefined) { dirImg = document.createElement("img"); dirImg.src = "images/directory.svg"; dirImg.style.height = "18px"; dirImg.style.width = "18px"; dirImg.style.marginRight = "5px"; dirImg.style.verticalAlign = "top"; td.append(dirImg); } dirImg.style.display = node.isFolder ? "inline" : "none"; let span = td.children[2]; if (span === undefined) { span = document.createElement("span"); td.append(span); } span.id = fileNameId; span.textContent = value; span.style.marginLeft = node.isFolder ? "0" : `${(node.depth + 1) * 20}px`; }; this.columns["name"].calculateBuffer = (rowId) => { const node = that.getNode(rowId); // folders add 20px for folder icon and 15px for collapse icon const folderBuffer = node.isFolder ? 35 : 0; return (node.depth * 20) + folderBuffer; }; // size this.columns["size"].updateTd = displaySize; // progress if (this.columns["progress"] !== undefined) { this.columns["progress"].updateTd = function(td, row) { const value = Number(this.getRowValue(row)); const progressBar = td.firstElementChild; if (progressBar === null) td.append(new window.qBittorrent.ProgressBar.ProgressBar(value)); else progressBar.setValue(value); }; this.columns["progress"].staticWidth = 100; } // priority this.columns["priority"].updateTd = function(td, row) { const id = row.rowId; const value = this.getRowValue(row); const fileId = that.getRowFileId(id); const priorityCombo = td.firstElementChild; if (priorityCombo === null) td.append(window.qBittorrent.TorrentContent.createPriorityCombo(id, fileId, value)); else window.qBittorrent.TorrentContent.updatePriorityCombo(priorityCombo, id, fileId, value); }; this.columns["priority"].staticWidth = 140; // remaining, availability if (this.columns["remaining"] !== undefined) this.columns["remaining"].updateTd = displaySize; if (this.columns["availability"] !== undefined) this.columns["availability"].updateTd = displayPercentage; for (const column of this.columns) { column["getRowValue"] = function(row, pos = 0) { const node = that.getNode(row.rowId); return node[this.dataProperties[pos]]; }; } } #sortNodesByColumn(root, column) { const isColumnName = (column.name === this.fileNameColumn); const compareRows = this.rowComparator(); const stack = [root]; while (stack.length > 0) { const node = stack.pop(); node.children.sort((node1, node2) => { // list folders before files when sorting by name if (isColumnName) { if (node1.isFolder && !node2.isFolder) return -1; if (!node1.isFolder && node2.isFolder) return 1; } return compareRows(node1, node2); }); stack.push(...node.children); } } #filterNodes(root, filterTerms) { const ret = []; const stack = [root]; while (stack.length > 0) { const node = stack.at(-1); if (node.isFolder && (!this.useVirtualList || !this.isCollapsed(node.rowId))) { if (node._visited === undefined) { node._visited = true; stack.push(...node.children); continue; } // ready to check results from children at this point if (node._hasChildrenAdded || window.qBittorrent.Misc.containsAllTerms(node.name, filterTerms)) { ret.push(this.getRow(node)); delete node._hasChildrenAdded; // propagate up const parent = node.root; if (parent !== undefined) parent._hasChildrenAdded = true; } delete node._visited; } else { if (window.qBittorrent.Misc.containsAllTerms(node[this.fileNameColumn], filterTerms)) { ret.push(this.getRow(node)); const parent = node.root; if (parent !== undefined) parent._hasChildrenAdded = true; } } stack.pop(); } ret.reverse(); return ret; } setFilter(text) { const filterTerms = text.trim().toLowerCase().split(" "); if ((filterTerms.length === 1) && (filterTerms[0] === "")) this.filterTerms = []; else this.filterTerms = filterTerms; } generateRowsSignature() { const rowsData = []; for (const { rowId } of this.getRowValues()) { const node = this.getNode(rowId); rowsData.push({ ...node.serialize(), collapsed: this.isCollapsed(node.rowId) }); } return JSON.stringify(rowsData); } getFilteredAndSortedRows() { const root = this.getRoot(); if (root === null) return []; const hasRowsChanged = function(rowsString, prevRowsStringString) { const rowsChanged = (rowsString !== prevRowsStringString); const isFilterTermsChanged = this.filterTerms.reduce((acc, term, index) => { return (acc || (term !== this.prevFilterTerms[index])); }, false); const isFilterChanged = ((this.filterTerms.length !== this.prevFilterTerms.length) || ((this.filterTerms.length > 0) && isFilterTermsChanged)); const isSortedColumnChanged = (this.prevSortedColumn !== this.sortedColumn); const isReverseSortChanged = (this.prevReverseSort !== this.reverseSort); const isSecondarySortChanged = (this.prevSecondarySortedColumn !== this.secondarySortedColumn) || (this.prevSecondaryReverseSort !== this.secondaryReverseSort); return (rowsChanged || isFilterChanged || isSortedColumnChanged || isReverseSortChanged || isSecondarySortChanged); }.bind(this); const rowsString = this.generateRowsSignature(); if (!hasRowsChanged(rowsString, this.prevRowsString)) return this.prevFilteredRows; // sort, then filter this.#sortNodesByColumn(root, this.columns[this.sortedColumn]); const rows = (() => { if (!this.useVirtualList && (this.filterTerms.length === 0)) { const nodeArray = this.fileTree.toArray(); const filteredRows = nodeArray.map(node => this.getRow(node)); return filteredRows; } return root.children.flatMap(node => this.#filterNodes(node, this.filterTerms)); })(); this.prevFilterTerms = this.filterTerms; this.prevRowsString = rowsString; this.prevFilteredRows = rows; this.prevSortedColumn = this.sortedColumn; this.prevReverseSort = this.reverseSort; this.prevSecondarySortedColumn = this.secondarySortedColumn; this.prevSecondaryReverseSort = this.secondaryReverseSort; return rows; } setupCommonEvents() { super.setupCommonEvents(); this.dynamicTableDiv.addEventListener("keydown", (e) => { const tr = e.target.closest("tr"); if (!tr) return; switch (e.key) { case "ArrowLeft": e.preventDefault(); this.collapseFolder(this.getSelectedRowId()); break; case "ArrowRight": e.preventDefault(); this.expandFolder(this.getSelectedRowId()); break; } }); } } class BulkRenameTorrentFilesTable extends TorrentFilesTable { prevCheckboxNum = null; supportCollapsing = false; fileNameColumn = "original"; headerCheckboxId = "rootMultiRename_cb"; getSelectedRows() { const TriState = window.qBittorrent.FileTree.TriState; const nodes = this.fileTree.toArray(); window.qBittorrent.Misc.filterInPlace(nodes, (node => node.checked === TriState.Checked)); return nodes; } initColumns() { // Blocks saving header width (because window width isn't saved) localPreferences.remove(`column_checked_width_${this.dynamicTableDivId}`); localPreferences.remove(`column_original_width_${this.dynamicTableDivId}`); localPreferences.remove(`column_renamed_width_${this.dynamicTableDivId}`); this.newColumn("checked", "", "", 50, true); this.newColumn("original", "", "QBT_TR(Original)QBT_TR[CONTEXT=TrackerListWidget]", 270, true); this.newColumn("renamed", "", "QBT_TR(Renamed)QBT_TR[CONTEXT=TrackerListWidget]", 220, true); this.initColumnsFunctions(); } /** * Toggles the global checkbox and all checkboxes underneath */ toggleHeaderCheckbox() { const TriState = window.qBittorrent.FileTree.TriState; const checkbox = document.getElementById(this.headerCheckboxId); const isChecked = checkbox.checked || checkbox.indeterminate; for (const cb of document.querySelectorAll("input.RenamingCB")) { cb.indeterminate = false; if (isChecked) { cb.checked = true; cb.state = TriState.Checked; } else { cb.checked = false; cb.state = TriState.Unchecked; } } const nodes = this.fileTree.toArray(); for (const node of nodes) node.checked = isChecked ? TriState.Checked : TriState.Unchecked; this.updateHeaderCheckbox(); } toggleNodeTreeCheckbox(rowId, checkState) { const TriState = window.qBittorrent.FileTree.TriState; const node = this.getNode(rowId); node.checked = checkState; const checkbox = document.getElementById(`cbRename${rowId}`); checkbox.checked = node.checked === TriState.Checked; checkbox.state = checkbox.checked ? TriState.Checked : TriState.Unchecked; for (let i = 0; i < node.children.length; ++i) this.toggleNodeTreeCheckbox(node.children[i].rowId, checkState); } initColumnsFunctions() { const that = this; const TriState = window.qBittorrent.FileTree.TriState; // checked this.columns["checked"].updateTd = (td, row) => { const id = row.rowId; const node = that.getNode(id); if (td.firstElementChild === null) { const treeImg = document.createElement("img"); treeImg.src = "images/L.svg"; treeImg.style.verticalAlign = "top"; td.append(treeImg); } let checkbox = td.children[1]; if (checkbox === undefined) { checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.className = "RenamingCB"; checkbox.addEventListener("click", (e) => { e.stopPropagation(); const targetId = e.target.dataset.id; const ids = []; // when holding shift, set all files between the previously selected one and the clicked one if (e.shiftKey && (that.prevCheckboxNum !== null) && (targetId !== that.prevCheckboxNum)) { const targetState = that.tableBody.querySelector(`.RenamingCB[data-id="${that.prevCheckboxNum}"]`).checked; const checkboxes = that.tableBody.getElementsByClassName("RenamingCB"); let started = false; for (const cb of checkboxes) { const currId = cb.dataset.id; if ((currId === targetId) || (currId === that.prevCheckboxNum)) { if (started) { ids.push(currId); cb.checked = targetState; break; } started = true; } if (started) { ids.push(currId); cb.checked = targetState; } } } else { ids.push(targetId); } for (const id of ids) { const node = that.getNode(id); node.checked = e.target.checked ? TriState.Checked : TriState.Unchecked; } that.updateHeaderCheckbox(); that.onRowSelectionChange(that.getNode(targetId)); that.prevCheckboxNum = targetId; }); checkbox.indeterminate = false; td.append(checkbox); } checkbox.id = `cbRename${id}`; checkbox.dataset.id = id; checkbox.checked = (node.checked === TriState.Checked); checkbox.state = checkbox.checked ? TriState.Checked : TriState.Unchecked; }; this.columns["checked"].staticWidth = 50; // original this.columns["original"].updateTd = function(td, row) { const id = row.rowId; const node = that.getNode(id); const value = this.getRowValue(row); let dirImg = td.children[0]; if (dirImg === undefined) { dirImg = document.createElement("img"); dirImg.src = "images/directory.svg"; dirImg.style.height = "18px"; dirImg.style.width = "18px"; dirImg.style.marginRight = "5px"; dirImg.style.verticalAlign = "top"; td.append(dirImg); } if (node.isFolder) { dirImg.style.display = "inline"; dirImg.style.marginLeft = `${node.depth * 20}px`; } else { dirImg.style.display = "none"; } let span = td.children[1]; if (span === undefined) { span = document.createElement("span"); td.append(span); } span.textContent = value; span.style.marginLeft = node.isFolder ? "0" : `${(node.depth + 1) * 20}px`; }; // renamed this.columns["renamed"].updateTd = (td, row) => { const id = row.rowId; const fileNameRenamedId = `filesTablefileRenamed${id}`; const node = that.getNode(id); let span = td.firstElementChild; if (span === null) { span = document.createElement("span"); td.append(span); } span.id = fileNameRenamedId; span.textContent = node.renamed; }; for (const column of this.columns) { column["getRowValue"] = function(row, pos = 0) { const node = that.getNode(row.rowId); return node[this.dataProperties[pos]]; }; } } onRowSelectionChange(row) {} selectRow() {} reselectRows(rowIds) { const TriState = window.qBittorrent.FileTree.TriState; this.deselectAll(); for (const tr of this.getTrs()) { if (rowIds.includes(tr.rowId)) { const node = this.getNode(tr.rowId); node.checked = TriState.Checked; const checkbox = tr.querySelector(".RenamingCB"); checkbox.state = TriState.Checked; checkbox.indeterminate = false; checkbox.checked = true; } } this.updateHeaderCheckbox(); } generateRowsSignature() { const rowsData = []; for (const { full_data } of this.getRowValues()) rowsData.push(full_data); return JSON.stringify(rowsData); } setupCommonEvents() { this.dynamicTableDiv.addEventListener("scroll", (e) => { // rerender on vertical scroll if (this.useVirtualList) { const scrollTop = this.dynamicTableDiv.scrollTop; if (scrollTop !== this.renderedOffset) { this.renderedOffset = scrollTop; this.rerender(); } } }); } } class AddTorrentFilesTable extends TorrentFilesTable { initColumns() { this.newColumn("checked", "", "", 50, true); this.newColumn("name", "", "QBT_TR(Name)QBT_TR[CONTEXT=TrackerListWidget]", 190, true); this.newColumn("size", "", "QBT_TR(Total Size)QBT_TR[CONTEXT=TrackerListWidget]", 75, true); this.newColumn("priority", "", "QBT_TR(Download Priority)QBT_TR[CONTEXT=TrackerListWidget]", 140, true); this.initColumnsFunctions(); } } class RssFeedTable extends DynamicTable { initColumns() { this.newColumn("name", "", "QBT_TR(RSS feeds)QBT_TR[CONTEXT=FeedListWidget]", -1, true); // map name row to "[name] ([unread])" this.columns["name"].dataProperties.push("unread", "status"); this.columns["name"].updateTd = (td, row) => { td.style.display = "block"; const data = row.full_data; const name = data.name; const indentation = data.indentation; const value = `${name} (${data.unread})`; let statusImg = "images/application-rss.svg"; switch (data.status) { case "hasError": statusImg = "images/task-reject.svg"; break; case "isLoading": statusImg = "images/spinner.svg"; break; case "unread": statusImg = "images/mail-inbox.svg"; break; case "isFolder": statusImg = "images/folder-documents.svg"; break; } let img = td.children[0]; if (img === undefined) { img = document.createElement("img"); img.className = "stateIcon"; img.style.width = "20px"; img.style.paddingRight = "5px"; img.style.marginBottom = "-3px"; td.append(img); } img.title = value; img.src = statusImg; img.style.marginLeft = `${indentation * 20}px`; let span = td.children[1]; if (span === undefined) { span = document.createElement("span"); td.append(span); } span.textContent = value; }; } setupHeaderMenu() {} setupHeaderEvents() {} getFilteredAndSortedRows() { return [...this.getRowValues()]; } selectRow(rowId) { super.selectRow(rowId); const row = this.getRow(rowId); const path = row.full_data.dataPath; window.qBittorrent.Rss.showRssFeed(path); } setupCommonEvents() { super.setupCommonEvents(); this.dynamicTableDiv.addEventListener("dblclick", (e) => { const tr = e.target.closest("tr"); if (!tr || (tr.rowId === "0")) return; window.qBittorrent.Rss.moveItem(this.getRow(tr.rowId).full_data.dataPath); }); } updateRowElement(tr, rowId, top) { super.updateRowElement(tr, rowId, top); if (this.useVirtualList) tr.style.width = "100%"; } } class RssArticleTable extends DynamicTable { initColumns() { this.newColumn("name", "", "QBT_TR(Torrents: (double-click to download))QBT_TR[CONTEXT=RSSWidget]", -1, true); this.columns["name"].updateTd = (td, row) => { td.style.display = "block"; const data = row.full_data; td.textContent = data.name; td.title = data.name; const tr = td.parentElement; tr.classList.toggle("unreadArticle", !data.isRead); }; } setupHeaderMenu() {} setupHeaderEvents() {} getFilteredAndSortedRows() { return [...this.getRowValues()]; } selectRow(rowId) { super.selectRow(rowId); const tr = this.getTrByRowId(rowId); tr.classList.remove("unreadArticle"); const row = this.getRow(rowId); const data = row.full_data; data.isRead = true; window.qBittorrent.Rss.showDetails(data.feedUid, data.dataId); } setupCommonEvents() { super.setupCommonEvents(); this.dynamicTableDiv.addEventListener("dblclick", (e) => { const tr = e.target.closest("tr"); if (!tr) return; const { name, torrentURL } = this.getRow(tr.rowId).full_data; qBittorrent.Client.createAddTorrentWindow(name, torrentURL); }); } updateRowElement(tr, rowId, top) { super.updateRowElement(tr, rowId, top); if (this.useVirtualList) tr.style.width = "100%"; } } class RssDownloaderRulesTable extends DynamicTable { initColumns() { this.newColumn("checked", "", "", 30, true); this.newColumn("name", "", "", -1, true); this.columns["checked"].updateTd = (td, row) => { let checkbox = td.firstElementChild; if (checkbox === null) { checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.dataset.id = row.rowId; checkbox.addEventListener("click", (e) => { const targetId = e.target.dataset.id; const row = window.qBittorrent.RssDownloader.rssDownloaderRulesTable.getRow(targetId); window.qBittorrent.RssDownloader.rssDownloaderRulesTable.updateRowData({ rowId: row.rowId, checked: checkbox.checked }); window.qBittorrent.RssDownloader.modifyRuleState(row.full_data.name, "enabled", checkbox.checked); e.stopPropagation(); }); td.append(checkbox); } checkbox.checked = row.full_data.checked; }; this.columns["checked"].staticWidth = 50; } setupHeaderMenu() {} setupHeaderEvents() {} getFilteredAndSortedRows() { return [...this.getRowValues()]; } setupCommonEvents() { super.setupCommonEvents(); this.dynamicTableDiv.addEventListener("dblclick", (e) => { const tr = e.target.closest("tr"); if (!tr) return; window.qBittorrent.RssDownloader.renameRule(this.getRow(tr.rowId).full_data.name); }); } selectRow(rowId) { super.selectRow(rowId); const row = this.getRow(rowId); const name = row.full_data.name; window.qBittorrent.RssDownloader.showRule(name); } updateRowElement(tr, rowId, top) { super.updateRowElement(tr, rowId, top); if (this.useVirtualList) tr.style.width = "100%"; } } class RssDownloaderFeedSelectionTable extends DynamicTable { initColumns() { this.newColumn("checked", "", "", 30, true); this.newColumn("name", "", "", -1, true); this.columns["checked"].updateTd = (td, row) => { let checkbox = td.firstElementChild; if (checkbox === null) { checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.dataset.id = row.rowId; checkbox.addEventListener("click", (e) => { const targetId = e.target.dataset.id; const row = window.qBittorrent.RssDownloader.rssDownloaderFeedSelectionTable.getRow(targetId); window.qBittorrent.RssDownloader.rssDownloaderFeedSelectionTable.updateRowData({ rowId: row.rowId, checked: checkbox.checked }); e.stopPropagation(); }); td.append(checkbox); } checkbox.checked = row.full_data.checked; }; this.columns["checked"].staticWidth = 50; } setupHeaderMenu() {} setupHeaderEvents() {} getFilteredAndSortedRows() { return [...this.getRowValues()]; } selectRow() {} updateRowElement(tr, rowId, top) { super.updateRowElement(tr, rowId, top); if (this.useVirtualList) tr.style.width = "100%"; } } class RssDownloaderArticlesTable extends DynamicTable { initColumns() { this.newColumn("name", "", "", -1, true); this.columns["name"].updateTd = (td, row) => { td.style.display = "block"; const data = row.full_data; td.textContent = data.name; td.title = data.name; const tr = td.parentElement; tr.classList.toggle("articleTableFeed", data.isFeed); tr.classList.toggle("articleTableArticle", !data.isFeed); }; } setupHeaderMenu() {} setupHeaderEvents() {} getFilteredAndSortedRows() { return [...this.getRowValues()]; } selectRow() {} updateRowElement(tr, rowId, top) { super.updateRowElement(tr, rowId, top); if (this.useVirtualList) tr.style.width = "100%"; } } class LogMessageTable extends DynamicTable { filterText = ""; initColumns() { this.newColumn("rowId", "", "QBT_TR(ID)QBT_TR[CONTEXT=ExecutionLogWidget]", 50, true); this.newColumn("message", "", "QBT_TR(Message)QBT_TR[CONTEXT=ExecutionLogWidget]", 350, true); this.newColumn("timestamp", "", "QBT_TR(Timestamp)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true); this.newColumn("type", "", "QBT_TR(Log Type)QBT_TR[CONTEXT=ExecutionLogWidget]", 100, true); this.initColumnsFunctions(); } initColumnsFunctions() { this.columns["timestamp"].updateTd = function(td, row) { const date = toDateString(this.getRowValue(row)); td.textContent = date; td.title = date; }; this.columns["type"].updateTd = function(td, row) { // Type of the message: Log::NORMAL: 1, Log::INFO: 2, Log::WARNING: 4, Log::CRITICAL: 8 let logLevel, addClass; switch (Number(this.getRowValue(row))) { case 1: logLevel = "QBT_TR(Normal)QBT_TR[CONTEXT=ExecutionLogWidget]"; addClass = "logNormal"; break; case 2: logLevel = "QBT_TR(Info)QBT_TR[CONTEXT=ExecutionLogWidget]"; addClass = "logInfo"; break; case 4: logLevel = "QBT_TR(Warning)QBT_TR[CONTEXT=ExecutionLogWidget]"; addClass = "logWarning"; break; case 8: logLevel = "QBT_TR(Critical)QBT_TR[CONTEXT=ExecutionLogWidget]"; addClass = "logCritical"; break; default: logLevel = "QBT_TR(Unknown)QBT_TR[CONTEXT=ExecutionLogWidget]"; addClass = "logUnknown"; break; } td.textContent = logLevel; td.title = logLevel; td.closest("tr").classList.add(`logTableRow${addClass}`); }; } getFilteredAndSortedRows() { let filteredRows = []; this.filterText = window.qBittorrent.Log.getFilterText(); const filterTerms = (this.filterText.length > 0) ? this.filterText.toLowerCase().split(" ") : []; const logLevels = window.qBittorrent.Log.getSelectedLevels(); if ((filterTerms.length > 0) || (logLevels.length < 4)) { for (const row of this.getRowValues()) { if (!logLevels.includes(row.full_data.type.toString())) continue; if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(row.full_data.message, filterTerms)) continue; filteredRows.push(row); } } else { filteredRows = [...this.getRowValues()]; } filteredRows.sort(this.rowComparator()); this.filteredLength = filteredRows.length; return filteredRows; } } class LogPeerTable extends LogMessageTable { initColumns() { this.newColumn("rowId", "", "QBT_TR(ID)QBT_TR[CONTEXT=ExecutionLogWidget]", 50, true); this.newColumn("ip", "", "QBT_TR(IP)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true); this.newColumn("timestamp", "", "QBT_TR(Timestamp)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true); this.newColumn("blocked", "", "QBT_TR(Status)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true); this.newColumn("reason", "", "QBT_TR(Reason)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true); this.columns["timestamp"].updateTd = function(td, row) { const date = toDateString(this.getRowValue(row)); td.textContent = date; td.title = date; }; this.columns["blocked"].updateTd = function(td, row) { let status, addClass; if (this.getRowValue(row)) { status = "QBT_TR(Blocked)QBT_TR[CONTEXT=ExecutionLogWidget]"; addClass = "peerBlocked"; } else { status = "QBT_TR(Banned)QBT_TR[CONTEXT=ExecutionLogWidget]"; addClass = "peerBanned"; } td.textContent = status; td.title = status; td.closest("tr").classList.add(`logTableRow${addClass}`); }; } getFilteredAndSortedRows() { let filteredRows = []; this.filterText = window.qBittorrent.Log.getFilterText(); const filterTerms = (this.filterText.length > 0) ? this.filterText.toLowerCase().split(" ") : []; if (filterTerms.length > 0) { for (const row of this.getRowValues()) { if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(row.full_data.ip, filterTerms)) continue; filteredRows.push(row); } } else { filteredRows = [...this.getRowValues()]; } filteredRows.sort(this.rowComparator()); return filteredRows; } } class TorrentWebseedsTable extends DynamicTable { initColumns() { this.newColumn("url", "", "QBT_TR(URL)QBT_TR[CONTEXT=HttpServer]", 500, true); } } class TorrentCreationTasksTable extends DynamicTable { initColumns() { this.newColumn("state_icon", "", "QBT_TR(Status Icon)QBT_TR[CONTEXT=TorrentCreator]", 30, false); this.newColumn("source_path", "", "QBT_TR(Source Path)QBT_TR[CONTEXT=TorrentCreator]", 200, true); this.newColumn("progress", "", "QBT_TR(Progress)QBT_TR[CONTEXT=TorrentCreator]", 85, true); this.newColumn("status", "", "QBT_TR(Status)QBT_TR[CONTEXT=TorrentCreator]", 100, true); this.newColumn("torrent_format", "", "QBT_TR(Format)QBT_TR[CONTEXT=TorrentCreator]", 100, true); this.newColumn("piece_size", "", "QBT_TR(Piece Size)QBT_TR[CONTEXT=TorrentCreator]", 100, true); this.newColumn("ignore_dotfiles", "", "QBT_TR(Ignore Dotfiles)QBT_TR[CONTEXT=TorrentCreator]", 30, true); this.newColumn("private", "", "QBT_TR(Private)QBT_TR[CONTEXT=TorrentCreator]", 30, true); this.newColumn("added_on", "", "QBT_TR(Added On)QBT_TR[CONTEXT=TorrentCreator]", 100, true); this.newColumn("start_on", "", "QBT_TR(Started On)QBT_TR[CONTEXT=TorrentCreator]", 100, true); this.newColumn("completion_on", "", "QBT_TR(Completed On)QBT_TR[CONTEXT=TorrentCreator]", 100, true); this.newColumn("trackers", "", "QBT_TR(Trackers)QBT_TR[CONTEXT=TorrentCreator]", 100, true); this.newColumn("web_seeds", "", "QBT_TR(Web Seeds)QBT_TR[CONTEXT=TorrentCreator]", 100, false); this.newColumn("comment", "", "QBT_TR(Comment)QBT_TR[CONTEXT=TorrentCreator]", 100, true); this.newColumn("source", "", "QBT_TR(Source)QBT_TR[CONTEXT=TorrentCreator]", 100, false); this.newColumn("error_message", "", "QBT_TR(Error Message)QBT_TR[CONTEXT=TorrentCreator]", 100, true); this.columns["state_icon"].dataProperties[0] = "status"; this.columns["source_path"].dataProperties.push("status"); this.initColumnsFunctions(); } initColumnsFunctions() { const getStateIconClasses = (state) => { let stateClass = "stateUnknown"; // normalize states switch (state) { case "Running": stateClass = "stateRunning"; break; case "Queued": stateClass = "stateQueued"; break; case "Finished": stateClass = "stateStoppedUP"; break; case "Failed": stateClass = "stateError"; break; } return `stateIcon ${stateClass}`; }; // state_icon this.columns["state_icon"].updateTd = function(td, row) { const state = this.getRowValue(row); let div = td.firstElementChild; if (div === null) { div = document.createElement("div"); td.append(div); } div.className = `${getStateIconClasses(state)} stateIconColumn`; }; this.columns["state_icon"].onVisibilityChange = (columnName) => { // show state icon in name column only when standalone // state icon column is hidden this.updateColumn("name", true); }; // source_path this.columns["source_path"].updateTd = function(td, row) { const name = this.getRowValue(row, 0); const state = this.getRowValue(row, 1); let span = td.firstElementChild; if (span === null) { span = document.createElement("span"); td.append(span); } span.className = this.isStateIconShown() ? getStateIconClasses(state) : ""; span.textContent = name; td.title = name; }; this.columns["source_path"].isStateIconShown = () => !this.columns["state_icon"].isVisible(); // status this.columns["status"].updateTd = function(td, row) { const state = this.getRowValue(row); if (!state) return; let status = "QBT_TR(Unknown)QBT_TR[CONTEXT=HttpServer]"; switch (state) { case "Queued": status = "QBT_TR(Queued)QBT_TR[CONTEXT=TorrentCreator]"; break; case "Running": status = "QBT_TR(Running)QBT_TR[CONTEXT=TorrentCreator]"; break; case "Finished": status = "QBT_TR(Finished)QBT_TR[CONTEXT=TorrentCreator]"; break; case "Failed": status = "QBT_TR(Failed)QBT_TR[CONTEXT=TorrentCreator]"; break; } td.textContent = status; td.title = status; }; // torrent_format this.columns["torrent_format"].updateTd = function(td, row) { const torrentFormat = this.getRowValue(row); if (!torrentFormat) return; let format = "QBT_TR(Unknown)QBT_TR[CONTEXT=HttpServer]"; switch (torrentFormat) { case "v1": format = "V1"; break; case "v2": format = "V2"; break; case "hybrid": format = "QBT_TR(Hybrid)QBT_TR[CONTEXT=TorrentCreator]"; break; } td.textContent = format; td.title = format; }; // progress this.columns["progress"].updateTd = function(td, row) { const progress = this.getRowValue(row); const div = td.firstElementChild; if (div !== null) { if (div.getValue() !== progress) div.setValue(progress); } else { td.append(new window.qBittorrent.ProgressBar.ProgressBar(progress)); } }; this.columns["progress"].staticWidth = 100; // piece_size this.columns["piece_size"].updateTd = function(td, row) { const pieceSize = this.getRowValue(row); const size = (pieceSize === 0) ? "QBT_TR(N/A)QBT_TR[CONTEXT=TorrentCreator]" : window.qBittorrent.Misc.friendlyUnit(pieceSize, false); td.textContent = size; td.title = size; }; // ignore dotfiles this.columns["ignore_dotfiles"].updateTd = function(td, row) { const text = this.getRowValue(row) ? "QBT_TR(Yes)QBT_TR[CONTEXT=TorrentCreator]" : "QBT_TR(No)QBT_TR[CONTEXT=TorrentCreator]"; td.textContent = text; td.title = text; }; // private this.columns["private"].updateTd = function(td, row) { const text = this.getRowValue(row) ? "QBT_TR(Yes)QBT_TR[CONTEXT=TorrentCreator]" : "QBT_TR(No)QBT_TR[CONTEXT=TorrentCreator]"; td.textContent = text; td.title = text; }; const displayDate = function(td, row) { const date = toDateString(this.getRowValue(row)); td.textContent = date; td.title = date; }; // added_on, start_on, completion_on this.columns["added_on"].updateTd = displayDate; this.columns["start_on"].updateTd = displayDate; this.columns["completion_on"].updateTd = displayDate; } setupCommonEvents() { super.setupCommonEvents(); this.dynamicTableDiv.addEventListener("dblclick", (e) => { const tr = e.target.closest("tr"); if (!tr) return; this.deselectAll(); this.selectRow(tr.rowId); window.qBittorrent.TorrentCreator.exportTorrents(); }); } } return exports(); })(); Object.freeze(window.qBittorrent.DynamicTable); /*************************************************************/