From 74a3d5bf76563b7bb9c85a9c3b755d3c08b894d9 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Mon, 7 Oct 2024 16:12:32 -0700 Subject: [PATCH 01/11] Avoid duplicating torrent content data in table All content data is already stored in the FileTree, so we now read from that data directly. We no longer store a second copy of this data in the TorrentFilesTable. --- src/webui/www/private/scripts/dynamicTable.js | 135 ++++++++---------- src/webui/www/private/scripts/file-tree.js | 17 ++- .../www/private/scripts/torrent-content.js | 45 +++--- 3 files changed, 93 insertions(+), 104 deletions(-) diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index aacdb5f5d..3fb778c22 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -993,9 +993,13 @@ window.qBittorrent.DynamicTable ??= (() => { } } + getRowData(row, fullUpdate) { + return row[fullUpdate ? "full_data" : "data"]; + } + updateRow(tr, fullUpdate) { const row = this.rows.get(tr.rowId); - const data = row[fullUpdate ? "full_data" : "data"]; + const data = this.getRowData(row, fullUpdate); const tds = this.getRowCells(tr); for (let i = 0; i < this.columns.length; ++i) { @@ -1007,7 +1011,7 @@ window.qBittorrent.DynamicTable ??= (() => { if (this.columns[i].dataProperties.some(prop => Object.hasOwn(data, prop))) this.columns[i].updateTd(tds[i], row); } - row["data"] = {}; + row.data = {}; } removeRow(rowId) { @@ -2358,25 +2362,9 @@ window.qBittorrent.DynamicTable ??= (() => { node.depth = depth; node.parent = parent; - if (node.isFolder) { - const data = { - rowId: node.rowId, - fileId: -1, - checked: node.checked, - path: node.path, - original: node.original, - renamed: node.renamed - }; - - node.data = data; - node.full_data = data; - this.updateRowData(data); - } - else { - node.data.rowId = node.rowId; - node.full_data = node.data; - this.updateRowData(node.data); - } + this.updateRowData({ + rowId: node.rowId + }); node.children.each((child) => { this.#addNodeToTable(child, depth + 1, node); @@ -2396,6 +2384,10 @@ window.qBittorrent.DynamicTable ??= (() => { return this.rows.get(rowId); } + getRowData(row, fullUpdate) { + return this.getNode(row.rowId); + } + getSelectedRows() { const nodes = this.fileTree.toArray(); @@ -2437,10 +2429,8 @@ window.qBittorrent.DynamicTable ??= (() => { } const nodes = this.fileTree.toArray(); - for (const node of nodes) { + for (const node of nodes) node.checked = (checkbox.checked || checkbox.indeterminate) ? 0 : 1; - node.full_data.checked = node.checked; - } this.updateGlobalCheckbox(); } @@ -2448,7 +2438,6 @@ window.qBittorrent.DynamicTable ??= (() => { toggleNodeTreeCheckbox(rowId, checkState) { const node = this.getNode(rowId); node.checked = checkState; - node.full_data.checked = checkState; const checkbox = document.getElementById(`cbRename${rowId}`); checkbox.checked = node.checked === 0; checkbox.state = checkbox.checked ? "checked" : "unchecked"; @@ -2530,7 +2519,6 @@ window.qBittorrent.DynamicTable ??= (() => { for (const id of ids) { const node = that.getNode(id); node.checked = e.target.checked ? 0 : 1; - node.full_data.checked = node.checked; } that.updateGlobalCheckbox(); that.onRowSelectionChange(that.getNode(targetId)); @@ -2592,6 +2580,13 @@ window.qBittorrent.DynamicTable ??= (() => { 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) {} @@ -2604,7 +2599,6 @@ window.qBittorrent.DynamicTable ??= (() => { if (rowIds.includes(tr.rowId)) { const node = this.getNode(tr.rowId); node.checked = 0; - node.full_data.checked = 0; const checkbox = tr.querySelector(".RenamingCB"); checkbox.state = "checked"; @@ -2623,18 +2617,16 @@ window.qBittorrent.DynamicTable ??= (() => { while (stack.length > 0) { const node = stack.pop(); - node.children.sort((row1, row2) => { + node.children.sort((node1, node2) => { // list folders before files when sorting by name if (isColumnOriginal) { - const node1 = this.getNode(row1.data.rowId); - const node2 = this.getNode(row2.data.rowId); if (node1.isFolder && !node2.isFolder) return -1; if (!node1.isFolder && node2.isFolder) return 1; } - const result = column.compareRows(row1, row2); + const result = column.compareRows(node1, node2); return isReverseSort ? result : -result; }); @@ -2746,14 +2738,6 @@ window.qBittorrent.DynamicTable ??= (() => { return rows; } - setIgnored(rowId, ignore) { - const row = this.rows.get(rowId); - if (ignore) - row.full_data.remaining = 0; - else - row.full_data.remaining = (row.full_data.size * (1.0 - (row.full_data.progress / 100))); - } - setupCommonEvents() { const headerDiv = document.getElementById("bulkRenameFilesTableFixedHeaderDiv"); this.dynamicTableDiv.addEventListener("scroll", (e) => { @@ -2876,11 +2860,11 @@ window.qBittorrent.DynamicTable ??= (() => { } isAllCheckboxesChecked() { - return this.fileTree.toArray().every((node) => node.checked === 1); + return this.fileTree.toArray().every((node) => node.checked === window.qBittorrent.FileTree.TriState.Checked); } isAllCheckboxesUnchecked() { - return this.fileTree.toArray().every((node) => node.checked !== 1); + return this.fileTree.toArray().every((node) => node.checked !== window.qBittorrent.FileTree.TriState.Checked); } populateTable(root) { @@ -2894,30 +2878,12 @@ window.qBittorrent.DynamicTable ??= (() => { node.depth = depth; node.parent = parent; - if (node.isFolder) { - if (!this.collapseState.has(node.rowId)) - this.collapseState.set(node.rowId, { depth: depth, collapsed: false }); - const data = { - rowId: node.rowId, - size: node.size, - checked: node.checked, - remaining: node.remaining, - progress: node.progress, - priority: window.qBittorrent.TorrentContent.normalizePriority(node.priority), - availability: node.availability, - fileId: -1, - name: node.name - }; + if (node.isFolder && !this.collapseState.has(node.rowId)) + this.collapseState.set(node.rowId, { depth: depth, collapsed: false }); - node.data = data; - node.full_data = data; - this.updateRowData(data, depth); - } - else { - node.data.rowId = node.rowId; - node.full_data = node.data; - this.updateRowData(node.data); - } + this.updateRowData({ + rowId: node.rowId, + }); node.children.each((child) => { this.#addNodeToTable(child, depth + 1, node); @@ -2938,8 +2904,12 @@ window.qBittorrent.DynamicTable ??= (() => { } getRowFileId(rowId) { - const row = this.rows.get(rowId); - return row?.full_data.fileId; + const node = this.getNode(rowId); + return node.fileId; + } + + getRowData(row, fullUpdate) { + return this.getNode(row.rowId); } initColumns() { @@ -2971,6 +2941,7 @@ window.qBittorrent.DynamicTable ??= (() => { 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"); @@ -2981,9 +2952,9 @@ window.qBittorrent.DynamicTable ??= (() => { const downloadCheckbox = td.children[1]; if (downloadCheckbox === undefined) - td.append(window.qBittorrent.TorrentContent.createDownloadCheckbox(id, row.full_data.fileId, value)); + td.append(window.qBittorrent.TorrentContent.createDownloadCheckbox(id, fileId, value)); else - window.qBittorrent.TorrentContent.updateDownloadCheckbox(downloadCheckbox, id, row.full_data.fileId, value); + window.qBittorrent.TorrentContent.updateDownloadCheckbox(downloadCheckbox, id, fileId, value); }; this.columns["checked"].staticWidth = 50; @@ -3072,12 +3043,13 @@ window.qBittorrent.DynamicTable ??= (() => { 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, row.full_data.fileId, value)); + td.append(window.qBittorrent.TorrentContent.createPriorityCombo(id, fileId, value)); else - window.qBittorrent.TorrentContent.updatePriorityCombo(priorityCombo, id, row.full_data.fileId, value); + window.qBittorrent.TorrentContent.updatePriorityCombo(priorityCombo, id, fileId, value); }; this.columns["priority"].staticWidth = 140; @@ -3086,6 +3058,13 @@ window.qBittorrent.DynamicTable ??= (() => { 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) { @@ -3096,18 +3075,16 @@ window.qBittorrent.DynamicTable ??= (() => { while (stack.length > 0) { const node = stack.pop(); - node.children.sort((row1, row2) => { + node.children.sort((node1, node2) => { // list folders before files when sorting by name if (isColumnName) { - const node1 = this.getNode(row1.data.rowId); - const node2 = this.getNode(row2.data.rowId); if (node1.isFolder && !node2.isFolder) return -1; if (!node1.isFolder && node2.isFolder) return 1; } - const result = column.compareRows(row1, row2); + const result = column.compareRows(node1, node2); return isReverseSort ? result : -result; }); @@ -3177,8 +3154,8 @@ window.qBittorrent.DynamicTable ??= (() => { const generateRowsSignature = () => { const rowsData = []; - for (const { full_data } of this.getRowValues()) - rowsData.push({ ...full_data, collapsed: this.isCollapsed(full_data.rowId) }); + for (const { rowId } of this.getRowValues()) + rowsData.push({ ...this.getNode(rowId).serialize(), collapsed: this.isCollapsed(rowId) }); return JSON.stringify(rowsData); }; @@ -3212,11 +3189,11 @@ window.qBittorrent.DynamicTable ??= (() => { } setIgnored(rowId, ignore) { - const row = this.rows.get(rowId.toString()); + const node = this.getNode(rowId.toString()); if (ignore) - row.full_data.remaining = 0; + node.remaining = 0; else - row.full_data.remaining = (row.full_data.size * (1.0 - (row.full_data.progress / 100))); + node.remaining = (node.size * (1.0 - (node.progress / 100))); } setupCommonEvents() { diff --git a/src/webui/www/private/scripts/file-tree.js b/src/webui/www/private/scripts/file-tree.js index c56ddeaef..13bbf0488 100644 --- a/src/webui/www/private/scripts/file-tree.js +++ b/src/webui/www/private/scripts/file-tree.js @@ -114,6 +114,7 @@ window.qBittorrent.FileTree ??= (() => { name = ""; path = ""; rowId = null; + fileId = null; size = 0; checked = TriState.Unchecked; remaining = 0; @@ -122,9 +123,22 @@ window.qBittorrent.FileTree ??= (() => { availability = 0; depth = 0; root = null; - data = null; isFolder = false; children = []; + + serialize() { + return { + name: this.name, + path: this.path, + fileId: this.fileId, + size: this.size, + checked: this.checked, + remaining: this.remaining, + progress: this.progress, + priority: this.priority, + availability: this.availability + }; + } } class FolderNode extends FileNode { @@ -133,6 +147,7 @@ window.qBittorrent.FileTree ??= (() => { */ autoCheckFolders = true; isFolder = true; + fileId = -1; addChild(node) { this.children.push(node); diff --git a/src/webui/www/private/scripts/torrent-content.js b/src/webui/www/private/scripts/torrent-content.js index 8e3431579..932b4ab94 100644 --- a/src/webui/www/private/scripts/torrent-content.js +++ b/src/webui/www/private/scripts/torrent-content.js @@ -84,12 +84,12 @@ window.qBittorrent.TorrentContent ??= (() => { const getAllChildren = (id, fileId) => { const node = torrentFilesTable.getNode(id); - const rowIds = [node.data.rowId]; - const fileIds = [node.data.fileId]; + const rowIds = [node.rowId]; + const fileIds = [node.fileId]; const getChildFiles = (node) => { - rowIds.push(node.data.rowId); - fileIds.push(node.data.fileId); + rowIds.push(node.rowId); + fileIds.push(node.fileId); if (node.isFolder) { node.children.forEach((child) => { @@ -214,8 +214,8 @@ window.qBittorrent.TorrentContent ??= (() => { }; const getComboboxPriority = (id) => { - const row = torrentFilesTable.rows.get(id.toString()); - return normalizePriority(row.full_data.priority, 10); + const node = torrentFilesTable.getNode(id.toString()); + return normalizePriority(node.priority, 10); }; const switchGlobalCheckboxState = (e) => { @@ -230,8 +230,9 @@ window.qBittorrent.TorrentContent ??= (() => { setCheckboxUnchecked(checkbox); torrentFilesTable.rows.forEach((row) => { const rowId = row.rowId; - const fileId = row.full_data.fileId; - const isChecked = (getCheckboxState(rowId) === TriState.Checked); + const node = torrentFilesTable.getNode(rowId); + const fileId = node.fileId; + const isChecked = (node.checked === TriState.Checked); if (isChecked) { rowIds.push(rowId); fileIds.push(fileId); @@ -242,8 +243,9 @@ window.qBittorrent.TorrentContent ??= (() => { setCheckboxChecked(checkbox); torrentFilesTable.rows.forEach((row) => { const rowId = row.rowId; - const fileId = row.full_data.fileId; - const isUnchecked = (getCheckboxState(rowId) === TriState.Unchecked); + const node = torrentFilesTable.getNode(rowId); + const fileId = node.fileId; + const isUnchecked = (node.checked === TriState.Unchecked); if (isUnchecked) { rowIds.push(rowId); fileIds.push(fileId); @@ -285,11 +287,6 @@ window.qBittorrent.TorrentContent ??= (() => { checkbox.indeterminate = true; }; - const getCheckboxState = (id) => { - const row = torrentFilesTable.rows.get(id.toString()); - return Number(row.full_data.checked); - }; - const setFilePriority = (ids, fileIds, priority) => { priority = normalizePriority(priority); @@ -301,9 +298,9 @@ window.qBittorrent.TorrentContent ??= (() => { id = id.toString(); torrentFilesTable.setIgnored(id, ignore); - const row = torrentFilesTable.rows.get(id); - row.full_data.priority = priority; - row.full_data.checked = triStateFromPriority(priority); + const node = torrentFilesTable.getNode(id); + node.priority = priority; + node.checked = triStateFromPriority(priority); }); }; @@ -377,6 +374,7 @@ window.qBittorrent.TorrentContent ??= (() => { childNode.name = row.name; childNode.path = row.fileName; childNode.rowId = rowId; + childNode.fileId = row.fileId; childNode.size = row.size; childNode.checked = isChecked; childNode.remaining = remaining; @@ -384,7 +382,6 @@ window.qBittorrent.TorrentContent ??= (() => { childNode.priority = row.priority; childNode.availability = row.availability; childNode.root = parent; - childNode.data = row; parent.addChild(childNode); ++rowId; @@ -447,7 +444,7 @@ window.qBittorrent.TorrentContent ??= (() => { let indeterminateCount = 0; let desiredComboboxPriority = null; for (const sibling of siblings) { - switch (getCheckboxState(sibling.rowId)) { + switch (sibling.checked) { case TriState.Checked: ++checkedCount; break; @@ -465,7 +462,7 @@ window.qBittorrent.TorrentContent ??= (() => { desiredComboboxPriority = FilePriority.Mixed; } - const currentCheckboxState = getCheckboxState(parent.rowId); + const currentCheckboxState = parent.checked; let desiredCheckboxState = TriState.Unchecked; if ((indeterminateCount > 0) || ((checkedCount > 0) && (uncheckedCount > 0))) desiredCheckboxState = TriState.Partial; @@ -474,9 +471,9 @@ window.qBittorrent.TorrentContent ??= (() => { const currentComboboxPriority = getComboboxPriority(parent.rowId); if ((currentCheckboxState !== desiredCheckboxState) || (currentComboboxPriority !== desiredComboboxPriority)) { - const row = torrentFilesTable.rows.get(parent.rowId.toString()); - row.full_data.priority = desiredComboboxPriority; - row.full_data.checked = desiredCheckboxState; + const node = torrentFilesTable.getNode(parent.rowId.toString()); + node.priority = desiredComboboxPriority; + node.checked = desiredCheckboxState; updateParentFolder(parent.rowId); } From a7d10b4a916dc60bffe0273ed04a5ed72d5eeefd Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Sun, 8 Jun 2025 22:22:28 -0700 Subject: [PATCH 02/11] Recalculate remaining size when priority changes Previously, we would have to wait for the API to respond with updated values before updating the 'remaining' size of folders. This also centralizes all math related to `remaining` size in FileTree. --- src/webui/www/private/scripts/dynamicTable.js | 12 ++++------ src/webui/www/private/scripts/file-tree.js | 23 +++++++++++++++++-- .../www/private/scripts/torrent-content.js | 17 +++++++------- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index 3fb778c22..b49f35a2f 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -2912,6 +2912,10 @@ window.qBittorrent.DynamicTable ??= (() => { 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); @@ -3188,14 +3192,6 @@ window.qBittorrent.DynamicTable ??= (() => { return rows; } - setIgnored(rowId, ignore) { - const node = this.getNode(rowId.toString()); - if (ignore) - node.remaining = 0; - else - node.remaining = (node.size * (1.0 - (node.progress / 100))); - } - setupCommonEvents() { super.setupCommonEvents(); this.dynamicTableDiv.addEventListener("keydown", (e) => { diff --git a/src/webui/www/private/scripts/file-tree.js b/src/webui/www/private/scripts/file-tree.js index 13bbf0488..4c4c32ed0 100644 --- a/src/webui/www/private/scripts/file-tree.js +++ b/src/webui/www/private/scripts/file-tree.js @@ -126,6 +126,14 @@ window.qBittorrent.FileTree ??= (() => { isFolder = false; children = []; + isIgnored() { + return this.priority === FilePriority.Ignored; + } + + calculateRemaining() { + this.remaining = this.isIgnored() ? 0 : (this.size * (1.0 - (this.progress / 100))); + } + serialize() { return { name: this.name, @@ -150,6 +158,7 @@ window.qBittorrent.FileTree ??= (() => { fileId = -1; addChild(node) { + node.calculateRemaining(); this.children.push(node); } @@ -196,8 +205,7 @@ window.qBittorrent.FileTree ??= (() => { root.checked = TriState.Partial; } - const isIgnored = (child.priority === FilePriority.Ignored); - if (!isIgnored) { + if (!child.isIgnored()) { root.remaining += child.remaining; root.progress += (child.progress * child.size); root.availability += (child.availability * child.size); @@ -212,6 +220,17 @@ window.qBittorrent.FileTree ??= (() => { stack.pop(); } } + + /** + * Recursively recalculate the amount of data remaining to be downloaded. + * This is useful for updating a folder's "remaining" size as files are unchecked/ignored. + */ + calculateRemaining() { + this.remaining = this.children.reduce((sum, node) => { + node.calculateRemaining(); + return sum + node.remaining; + }, 0); + } } return exports(); diff --git a/src/webui/www/private/scripts/torrent-content.js b/src/webui/www/private/scripts/torrent-content.js index 932b4ab94..37a216b96 100644 --- a/src/webui/www/private/scripts/torrent-content.js +++ b/src/webui/www/private/scripts/torrent-content.js @@ -293,15 +293,16 @@ window.qBittorrent.TorrentContent ??= (() => { if (onFilePriorityChanged) onFilePriorityChanged(fileIds, priority); - const ignore = (priority === FilePriority.Ignored); - ids.forEach((id) => { - id = id.toString(); - torrentFilesTable.setIgnored(id, ignore); - - const node = torrentFilesTable.getNode(id); + const nodes = ids.map((id) => { + const node = torrentFilesTable.getNode(id.toString()); node.priority = priority; node.checked = triStateFromPriority(priority); + return node; }); + + // must update all nodes above before recalculating + for (const node of nodes) + node.calculateRemaining(); }; const updateData = (files) => { @@ -315,7 +316,6 @@ window.qBittorrent.TorrentContent ??= (() => { size: file.size, progress: window.qBittorrent.Misc.toFixedPointString((file.progress * 100), 1), priority: normalizePriority(file.priority), - remaining: (ignore ? 0 : (file.size * (1 - file.progress))), availability: file.availability }; @@ -369,7 +369,6 @@ window.qBittorrent.TorrentContent ??= (() => { }); const isChecked = row.checked ? TriState.Checked : TriState.Unchecked; - const remaining = (row.priority === FilePriority.Ignored) ? 0 : row.remaining; const childNode = new window.qBittorrent.FileTree.FileNode(); childNode.name = row.name; childNode.path = row.fileName; @@ -377,7 +376,6 @@ window.qBittorrent.TorrentContent ??= (() => { childNode.fileId = row.fileId; childNode.size = row.size; childNode.checked = isChecked; - childNode.remaining = remaining; childNode.progress = row.progress; childNode.priority = row.priority; childNode.availability = row.availability; @@ -427,6 +425,7 @@ window.qBittorrent.TorrentContent ??= (() => { const updateComplete = () => { // we've finished recursing updateGlobalCheckbox(); + torrentFilesTable.calculateRemaining(); torrentFilesTable.updateTable(true); }; From 280aa15a9042c2b0e91b358079f9a8eec4e8be7a Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Sun, 8 Jun 2025 22:40:25 -0700 Subject: [PATCH 03/11] Rename property for clarity --- src/webui/www/private/rename_files.html | 4 ++-- src/webui/www/private/scripts/file-tree.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/webui/www/private/rename_files.html b/src/webui/www/private/rename_files.html index bef7ce84c..6e6385d6e 100644 --- a/src/webui/www/private/rename_files.html +++ b/src/webui/www/private/rename_files.html @@ -310,7 +310,7 @@ const addRowsToTable = (rows, selectedRows) => { let rowId = 0; const rootNode = new window.qBittorrent.FileTree.FolderNode(); - rootNode.autoCheckFolders = false; + rootNode.autoCalculateCheckedState = false; rows.forEach((row) => { const pathItems = row.path.split(window.qBittorrent.Filesystem.PathSeparator); @@ -334,7 +334,7 @@ if (folderNode === null) { folderNode = new window.qBittorrent.FileTree.FolderNode(); - folderNode.autoCheckFolders = false; + folderNode.autoCalculateCheckedState = false; folderNode.rowId = rowId; folderNode.path = (parent.path === "") ? folderName diff --git a/src/webui/www/private/scripts/file-tree.js b/src/webui/www/private/scripts/file-tree.js index 4c4c32ed0..c426279cc 100644 --- a/src/webui/www/private/scripts/file-tree.js +++ b/src/webui/www/private/scripts/file-tree.js @@ -151,9 +151,9 @@ window.qBittorrent.FileTree ??= (() => { class FolderNode extends FileNode { /** - * Will automatically tick the checkbox for a folder if all subfolders and files are also ticked + * When true, the folder's `checked` state will be calculately automatically based on its children */ - autoCheckFolders = true; + autoCalculateCheckedState = true; isFolder = true; fileId = -1; @@ -212,7 +212,7 @@ window.qBittorrent.FileTree ??= (() => { } } - root.checked = root.autoCheckFolders ? root.checked : TriState.Checked; + root.checked = root.autoCalculateCheckedState ? root.checked : TriState.Checked; root.progress /= root.size; root.availability /= root.size; } From 352221ebae08833d22dbf6726830e5c4a5b09da4 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Sun, 8 Jun 2025 22:43:54 -0700 Subject: [PATCH 04/11] Fix metadata calculation when folder size is 0 --- src/webui/www/private/scripts/file-tree.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/webui/www/private/scripts/file-tree.js b/src/webui/www/private/scripts/file-tree.js index c426279cc..e225cb297 100644 --- a/src/webui/www/private/scripts/file-tree.js +++ b/src/webui/www/private/scripts/file-tree.js @@ -213,8 +213,8 @@ window.qBittorrent.FileTree ??= (() => { } root.checked = root.autoCalculateCheckedState ? root.checked : TriState.Checked; - root.progress /= root.size; - root.availability /= root.size; + root.progress = (root.size > 0) ? (root.progress / root.size) : 0; + root.availability = (root.size > 0) ? (root.availability / root.size) : 0; } stack.pop(); From dbd19b314ffaf78843e40d57f27505da8e8f403f Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Sun, 8 Jun 2025 22:57:57 -0700 Subject: [PATCH 05/11] Switch from MooTools each to JS forEach --- src/webui/www/private/scripts/dynamicTable.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index b49f35a2f..a7617d702 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -2353,7 +2353,7 @@ window.qBittorrent.DynamicTable ??= (() => { populateTable(root) { this.fileTree.setRoot(root); - root.children.each((node) => { + root.children.forEach((node) => { this.#addNodeToTable(node, 0, root); }); } @@ -2366,7 +2366,7 @@ window.qBittorrent.DynamicTable ??= (() => { rowId: node.rowId }); - node.children.each((child) => { + node.children.forEach((child) => { this.#addNodeToTable(child, depth + 1, node); }); } @@ -2869,7 +2869,7 @@ window.qBittorrent.DynamicTable ??= (() => { populateTable(root) { this.fileTree.setRoot(root); - root.children.each((node) => { + root.children.forEach((node) => { this.#addNodeToTable(node, 0, root); }); } @@ -2885,7 +2885,7 @@ window.qBittorrent.DynamicTable ??= (() => { rowId: node.rowId, }); - node.children.each((child) => { + node.children.forEach((child) => { this.#addNodeToTable(child, depth + 1, node); }); } From 81bbd1c5bd11676aa8f394ad78dbe023808916f3 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Mon, 9 Jun 2025 14:44:22 -0700 Subject: [PATCH 06/11] Defer to base class for redundant logic This ensures these tables only contain their unique logic. --- src/webui/www/private/scripts/dynamicTable.js | 37 ++++--------------- 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index a7617d702..20333268b 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -3272,18 +3272,14 @@ window.qBittorrent.DynamicTable ??= (() => { } updateRow(tr, fullUpdate) { const row = this.rows.get(tr.rowId); - const data = row[fullUpdate ? "full_data" : "data"]; - const tds = this.getRowCells(tr); - for (let i = 0; i < this.columns.length; ++i) { - if (Object.hasOwn(data, this.columns[i].dataProperties[0])) - this.columns[i].updateTd(tds[i], row); - } - row["data"] = {}; + tds[0].style.overflow = "visible"; const indentation = row.full_data.indentation; tds[0].style.paddingLeft = `${indentation * 32 + 4}px`; tds[1].style.paddingLeft = `${indentation * 32 + 4}px`; + + return super.updateRow(tr, fullUpdate); } updateIcons() { // state_icon @@ -3408,15 +3404,9 @@ window.qBittorrent.DynamicTable ??= (() => { } updateRow(tr, fullUpdate) { const row = this.rows.get(tr.rowId); - const data = row[fullUpdate ? "full_data" : "data"]; tr.classList.toggle("unreadArticle", !row.full_data.isRead); - const tds = this.getRowCells(tr); - for (let i = 0; i < this.columns.length; ++i) { - if (Object.hasOwn(data, this.columns[i].dataProperties[0])) - this.columns[i].updateTd(tds[i], row); - } - row["data"] = {}; + return super.updateRow(tr, fullUpdate); } newColumn(name, style, caption, defaultWidth, defaultVisible) { const column = {}; @@ -3673,23 +3663,10 @@ window.qBittorrent.DynamicTable ??= (() => { selectRow() {} updateRow(tr, fullUpdate) { const row = this.rows.get(tr.rowId); - const data = row[fullUpdate ? "full_data" : "data"]; + tr.classList.toggle("articleTableFeed", row.full_data.isFeed); + tr.classList.toggle("articleTableArticle", !row.full_data.isFeed); - if (row.full_data.isFeed) { - tr.classList.add("articleTableFeed"); - tr.classList.remove("articleTableArticle"); - } - else { - tr.classList.remove("articleTableFeed"); - tr.classList.add("articleTableArticle"); - } - - const tds = this.getRowCells(tr); - for (let i = 0; i < this.columns.length; ++i) { - if (Object.hasOwn(data, this.columns[i].dataProperties[0])) - this.columns[i].updateTd(tds[i], row); - } - row["data"] = {}; + return super.updateRow(tr, fullUpdate); } } From ce757e866b6e6b6a3e031e085ff84338c00d692e Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Mon, 9 Jun 2025 16:06:09 -0700 Subject: [PATCH 07/11] Fix keyboard navigation in files table --- src/webui/www/private/scripts/dynamicTable.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index 20333268b..afe2d29ba 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -2850,13 +2850,13 @@ window.qBittorrent.DynamicTable ??= (() => { expandFolder(id) { const node = this.getNode(id); if (node.isFolder) - this.expandNode(node); + this.expandNode(node.rowId); } collapseFolder(id) { const node = this.getNode(id); if (node.isFolder) - this.collapseNode(node); + this.collapseNode(node.rowId); } isAllCheckboxesChecked() { From 73ab5dfeefd5f38af98163c62751c8a5e84e28b7 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Sat, 9 Aug 2025 18:32:15 -0700 Subject: [PATCH 08/11] Define BulkRenameTorrentFilesTable after TorrentFilesTable This change is a no-op that enables the next commit. --- src/webui/www/private/scripts/dynamicTable.js | 832 +++++++++--------- 1 file changed, 416 insertions(+), 416 deletions(-) diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index afe2d29ba..a1006108f 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -42,8 +42,8 @@ window.qBittorrent.DynamicTable ??= (() => { SearchResultsTable: SearchResultsTable, SearchPluginsTable: SearchPluginsTable, TorrentTrackersTable: TorrentTrackersTable, - BulkRenameTorrentFilesTable: BulkRenameTorrentFilesTable, TorrentFilesTable: TorrentFilesTable, + BulkRenameTorrentFilesTable: BulkRenameTorrentFilesTable, AddTorrentFilesTable: AddTorrentFilesTable, LogMessageTable: LogMessageTable, LogPeerTable: LogPeerTable, @@ -2336,421 +2336,6 @@ window.qBittorrent.DynamicTable ??= (() => { } } - class BulkRenameTorrentFilesTable extends DynamicTable { - filterTerms = []; - prevFilterTerms = []; - prevRowsString = null; - prevFilteredRows = []; - prevSortedColumn = null; - prevReverseSort = null; - prevCheckboxNum = null; - fileTree = new window.qBittorrent.FileTree.FileTree(); - - setupVirtualList() { - super.setupVirtualList(); - this.rowHeight = 29; - } - - populateTable(root) { - this.fileTree.setRoot(root); - root.children.forEach((node) => { - this.#addNodeToTable(node, 0, root); - }); - } - - #addNodeToTable(node, depth, parent) { - node.depth = depth; - node.parent = parent; - - this.updateRowData({ - rowId: node.rowId - }); - - node.children.forEach((child) => { - this.#addNodeToTable(child, depth + 1, node); - }); - } - - 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); - } - - getRowData(row, fullUpdate) { - return this.getNode(row.rowId); - } - - getSelectedRows() { - const nodes = this.fileTree.toArray(); - - return nodes.filter(x => x.checked === 0); - } - - 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 - */ - toggleGlobalCheckbox() { - const checkbox = document.getElementById("rootMultiRename_cb"); - const checkboxes = document.querySelectorAll("input.RenamingCB"); - - for (let i = 0; i < checkboxes.length; ++i) { - if (checkbox.checked || checkbox.indeterminate) { - const cb = checkboxes[i]; - cb.checked = true; - cb.indeterminate = false; - cb.state = "checked"; - } - else { - const cb = checkboxes[i]; - cb.checked = false; - cb.indeterminate = false; - cb.state = "unchecked"; - } - } - - const nodes = this.fileTree.toArray(); - for (const node of nodes) - node.checked = (checkbox.checked || checkbox.indeterminate) ? 0 : 1; - - this.updateGlobalCheckbox(); - } - - toggleNodeTreeCheckbox(rowId, checkState) { - const node = this.getNode(rowId); - node.checked = checkState; - const checkbox = document.getElementById(`cbRename${rowId}`); - checkbox.checked = node.checked === 0; - checkbox.state = checkbox.checked ? "checked" : "unchecked"; - - for (let i = 0; i < node.children.length; ++i) - this.toggleNodeTreeCheckbox(node.children[i].rowId, checkState); - } - - updateGlobalCheckbox() { - const checkbox = document.getElementById("rootMultiRename_cb"); - const nodes = this.fileTree.toArray(); - const isAllChecked = nodes.every((node) => node.checked === 0); - const isAllUnchecked = (() => nodes.every((node) => node.checked !== 0)); - if (isAllChecked) { - checkbox.state = "checked"; - checkbox.indeterminate = false; - checkbox.checked = true; - } - else if (isAllUnchecked()) { - checkbox.state = "unchecked"; - checkbox.indeterminate = false; - checkbox.checked = false; - } - else { - checkbox.state = "partial"; - checkbox.indeterminate = true; - checkbox.checked = false; - } - } - - initColumnsFunctions() { - const that = this; - - // 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.marginBottom = "-2px"; - 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 ? 0 : 1; - } - that.updateGlobalCheckbox(); - 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 === 0); - checkbox.state = checkbox.checked ? "checked" : "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.width = "20px"; - dirImg.style.paddingRight = "5px"; - dirImg.style.marginBottom = "-3px"; - 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) { - this.deselectAll(); - for (const tr of this.getTrs()) { - if (rowIds.includes(tr.rowId)) { - const node = this.getNode(tr.rowId); - node.checked = 0; - - const checkbox = tr.querySelector(".RenamingCB"); - checkbox.state = "checked"; - checkbox.indeterminate = false; - checkbox.checked = true; - } - } - this.updateGlobalCheckbox(); - } - - #sortNodesByColumn(root, column) { - const isColumnOriginal = (column.name === "original"); - const isReverseSort = (this.reverseSort === "0"); - - 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 (isColumnOriginal) { - if (node1.isFolder && !node2.isFolder) - return -1; - if (!node1.isFolder && node2.isFolder) - return 1; - } - - const result = column.compareRows(node1, node2); - return isReverseSort ? result : -result; - }); - - stack.push(...node.children); - } - } - - #filterNodes(root, filterTerms) { - const ret = []; - const stack = [root]; - const visited = []; - - while (stack.length > 0) { - const node = stack.at(-1); - - if (node.isFolder) { - const lastVisited = visited.at(-1); - - if ((visited.length <= 0) || (lastVisited !== node)) { - visited.push(node); - stack.push(...node.children); - continue; - } - - // has children added or itself matches - if (lastVisited.has_children_added || window.qBittorrent.Misc.containsAllTerms(node.name, filterTerms)) { - ret.push(this.getRow(node)); - delete node.has_children_added; - - // propagate up - const parent = node.root; - if (parent !== undefined) - parent.has_children_added = true; - } - - visited.pop(); - } - else { - if (window.qBittorrent.Misc.containsAllTerms(node.original, filterTerms)) { - ret.push(this.getRow(node)); - - const parent = node.root; - if (parent !== undefined) - parent.has_children_added = 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; - } - - getFilteredAndSortedRows() { - const root = this.getRoot(); - if (root === null) - return []; - - const generateRowsSignature = () => { - const rowsData = []; - for (const { full_data } of this.getRowValues()) - rowsData.push(full_data); - return JSON.stringify(rowsData); - }; - - 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); - - return (rowsChanged || isFilterChanged || isSortedColumnChanged || isReverseSortChanged); - }.bind(this); - - const rowsString = generateRowsSignature(); - if (!hasRowsChanged(rowsString, this.prevRowsString)) - return this.prevFilteredRows; - - // sort, then filter - this.#sortNodesByColumn(root, this.columns[this.sortedColumn]); - const rows = (() => { - if (this.filterTerms.length === 0) { - const nodeArray = this.fileTree.toArray(); - const filteredRows = nodeArray.map(node => this.getRow(node)); - return filteredRows; - } - - return this.#filterNodes(root.children[0], this.filterTerms); - })(); - - this.prevFilterTerms = this.filterTerms; - this.prevRowsString = rowsString; - this.prevFilteredRows = rows; - this.prevSortedColumn = this.sortedColumn; - this.prevReverseSort = this.reverseSort; - return rows; - } - - setupCommonEvents() { - const headerDiv = document.getElementById("bulkRenameFilesTableFixedHeaderDiv"); - this.dynamicTableDiv.addEventListener("scroll", (e) => { - headerDiv.scrollLeft = this.dynamicTableDiv.scrollLeft; - // rerender on scroll - if (this.useVirtualList) { - this.renderedOffset = this.dynamicTableDiv.scrollTop; - this.rerender(); - } - }); - } - } - class TorrentFilesTable extends DynamicTable { filterTerms = []; prevFilterTerms = []; @@ -3213,6 +2798,421 @@ window.qBittorrent.DynamicTable ??= (() => { } } + class BulkRenameTorrentFilesTable extends DynamicTable { + filterTerms = []; + prevFilterTerms = []; + prevRowsString = null; + prevFilteredRows = []; + prevSortedColumn = null; + prevReverseSort = null; + prevCheckboxNum = null; + fileTree = new window.qBittorrent.FileTree.FileTree(); + + setupVirtualList() { + super.setupVirtualList(); + this.rowHeight = 29; + } + + populateTable(root) { + this.fileTree.setRoot(root); + root.children.forEach((node) => { + this.#addNodeToTable(node, 0, root); + }); + } + + #addNodeToTable(node, depth, parent) { + node.depth = depth; + node.parent = parent; + + this.updateRowData({ + rowId: node.rowId + }); + + node.children.forEach((child) => { + this.#addNodeToTable(child, depth + 1, node); + }); + } + + 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); + } + + getRowData(row, fullUpdate) { + return this.getNode(row.rowId); + } + + getSelectedRows() { + const nodes = this.fileTree.toArray(); + + return nodes.filter(x => x.checked === 0); + } + + 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 + */ + toggleGlobalCheckbox() { + const checkbox = document.getElementById("rootMultiRename_cb"); + const checkboxes = document.querySelectorAll("input.RenamingCB"); + + for (let i = 0; i < checkboxes.length; ++i) { + if (checkbox.checked || checkbox.indeterminate) { + const cb = checkboxes[i]; + cb.checked = true; + cb.indeterminate = false; + cb.state = "checked"; + } + else { + const cb = checkboxes[i]; + cb.checked = false; + cb.indeterminate = false; + cb.state = "unchecked"; + } + } + + const nodes = this.fileTree.toArray(); + for (const node of nodes) + node.checked = (checkbox.checked || checkbox.indeterminate) ? 0 : 1; + + this.updateGlobalCheckbox(); + } + + toggleNodeTreeCheckbox(rowId, checkState) { + const node = this.getNode(rowId); + node.checked = checkState; + const checkbox = document.getElementById(`cbRename${rowId}`); + checkbox.checked = node.checked === 0; + checkbox.state = checkbox.checked ? "checked" : "unchecked"; + + for (let i = 0; i < node.children.length; ++i) + this.toggleNodeTreeCheckbox(node.children[i].rowId, checkState); + } + + updateGlobalCheckbox() { + const checkbox = document.getElementById("rootMultiRename_cb"); + const nodes = this.fileTree.toArray(); + const isAllChecked = nodes.every((node) => node.checked === 0); + const isAllUnchecked = (() => nodes.every((node) => node.checked !== 0)); + if (isAllChecked) { + checkbox.state = "checked"; + checkbox.indeterminate = false; + checkbox.checked = true; + } + else if (isAllUnchecked()) { + checkbox.state = "unchecked"; + checkbox.indeterminate = false; + checkbox.checked = false; + } + else { + checkbox.state = "partial"; + checkbox.indeterminate = true; + checkbox.checked = false; + } + } + + initColumnsFunctions() { + const that = this; + + // 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.marginBottom = "-2px"; + 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 ? 0 : 1; + } + that.updateGlobalCheckbox(); + 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 === 0); + checkbox.state = checkbox.checked ? "checked" : "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.width = "20px"; + dirImg.style.paddingRight = "5px"; + dirImg.style.marginBottom = "-3px"; + 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) { + this.deselectAll(); + for (const tr of this.getTrs()) { + if (rowIds.includes(tr.rowId)) { + const node = this.getNode(tr.rowId); + node.checked = 0; + + const checkbox = tr.querySelector(".RenamingCB"); + checkbox.state = "checked"; + checkbox.indeterminate = false; + checkbox.checked = true; + } + } + this.updateGlobalCheckbox(); + } + + #sortNodesByColumn(root, column) { + const isColumnOriginal = (column.name === "original"); + const isReverseSort = (this.reverseSort === "0"); + + 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 (isColumnOriginal) { + if (node1.isFolder && !node2.isFolder) + return -1; + if (!node1.isFolder && node2.isFolder) + return 1; + } + + const result = column.compareRows(node1, node2); + return isReverseSort ? result : -result; + }); + + stack.push(...node.children); + } + } + + #filterNodes(root, filterTerms) { + const ret = []; + const stack = [root]; + const visited = []; + + while (stack.length > 0) { + const node = stack.at(-1); + + if (node.isFolder) { + const lastVisited = visited.at(-1); + + if ((visited.length <= 0) || (lastVisited !== node)) { + visited.push(node); + stack.push(...node.children); + continue; + } + + // has children added or itself matches + if (lastVisited.has_children_added || window.qBittorrent.Misc.containsAllTerms(node.name, filterTerms)) { + ret.push(this.getRow(node)); + delete node.has_children_added; + + // propagate up + const parent = node.root; + if (parent !== undefined) + parent.has_children_added = true; + } + + visited.pop(); + } + else { + if (window.qBittorrent.Misc.containsAllTerms(node.original, filterTerms)) { + ret.push(this.getRow(node)); + + const parent = node.root; + if (parent !== undefined) + parent.has_children_added = 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; + } + + getFilteredAndSortedRows() { + const root = this.getRoot(); + if (root === null) + return []; + + const generateRowsSignature = () => { + const rowsData = []; + for (const { full_data } of this.getRowValues()) + rowsData.push(full_data); + return JSON.stringify(rowsData); + }; + + 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); + + return (rowsChanged || isFilterChanged || isSortedColumnChanged || isReverseSortChanged); + }.bind(this); + + const rowsString = generateRowsSignature(); + if (!hasRowsChanged(rowsString, this.prevRowsString)) + return this.prevFilteredRows; + + // sort, then filter + this.#sortNodesByColumn(root, this.columns[this.sortedColumn]); + const rows = (() => { + if (this.filterTerms.length === 0) { + const nodeArray = this.fileTree.toArray(); + const filteredRows = nodeArray.map(node => this.getRow(node)); + return filteredRows; + } + + return this.#filterNodes(root.children[0], this.filterTerms); + })(); + + this.prevFilterTerms = this.filterTerms; + this.prevRowsString = rowsString; + this.prevFilteredRows = rows; + this.prevSortedColumn = this.sortedColumn; + this.prevReverseSort = this.reverseSort; + return rows; + } + + setupCommonEvents() { + const headerDiv = document.getElementById("bulkRenameFilesTableFixedHeaderDiv"); + this.dynamicTableDiv.addEventListener("scroll", (e) => { + headerDiv.scrollLeft = this.dynamicTableDiv.scrollLeft; + // rerender on scroll + if (this.useVirtualList) { + this.renderedOffset = this.dynamicTableDiv.scrollTop; + this.rerender(); + } + }); + } + } + class AddTorrentFilesTable extends TorrentFilesTable { initColumns() { this.newColumn("checked", "", "", 50, true); From afa92d865f307a54c768ffd424866bd2f70a1805 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Thu, 12 Jun 2025 19:47:24 -0700 Subject: [PATCH 09/11] Reduce repeated logic between files tables BulkRenameTorrentFilesTable now extends TorrentFilesTable, allowing for the removal of a lot of repeated logic. --- src/webui/www/private/scripts/dynamicTable.js | 235 ++++-------------- 1 file changed, 50 insertions(+), 185 deletions(-) diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index a1006108f..cf6e23642 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -2344,13 +2344,20 @@ window.qBittorrent.DynamicTable ??= (() => { prevSortedColumn = null; prevReverseSort = null; fileTree = new window.qBittorrent.FileTree.FileTree(); + supportCollapsing = true; collapseState = new Map(); + fileNameColumn = "name"; 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; @@ -2358,6 +2365,9 @@ window.qBittorrent.DynamicTable ??= (() => { } collapseNode(id) { + if (!this.supportCollapsing) + return; + const state = this.collapseState.get(id); if (state !== undefined) state.collapsed = true; @@ -2365,11 +2375,17 @@ window.qBittorrent.DynamicTable ??= (() => { } expandAllNodes() { + if (!this.supportCollapsing) + return; + for (const [key, _] of this.collapseState) this.expandNode(key); } collapseAllNodes() { + if (!this.supportCollapsing) + return; + for (const [key, state] of this.collapseState) { // collapse all nodes except root if (state.depth >= 1) @@ -2433,12 +2449,18 @@ window.qBittorrent.DynamicTable ??= (() => { } expandFolder(id) { + if (!this.supportCollapsing) + return; + const node = this.getNode(id); if (node.isFolder) this.expandNode(node.rowId); } collapseFolder(id) { + if (!this.supportCollapsing) + return; + const node = this.getNode(id); if (node.isFolder) this.collapseNode(node.rowId); @@ -2463,7 +2485,7 @@ window.qBittorrent.DynamicTable ??= (() => { node.depth = depth; node.parent = parent; - if (node.isFolder && !this.collapseState.has(node.rowId)) + if (node.isFolder && this.supportCollapsing && !this.collapseState.has(node.rowId)) this.collapseState.set(node.rowId, { depth: depth, collapsed: false }); this.updateRowData({ @@ -2657,7 +2679,7 @@ window.qBittorrent.DynamicTable ??= (() => { } #sortNodesByColumn(root, column) { - const isColumnName = (column.name === "name"); + const isColumnName = (column.name === this.fileNameColumn); const isReverseSort = (this.reverseSort === "0"); const stack = [root]; @@ -2712,7 +2734,7 @@ window.qBittorrent.DynamicTable ??= (() => { visited.pop(); } else { - if (window.qBittorrent.Misc.containsAllTerms(node.name, filterTerms)) { + if (window.qBittorrent.Misc.containsAllTerms(node[this.fileNameColumn], filterTerms)) { ret.push(this.getRow(node)); const parent = node.root; @@ -2736,18 +2758,18 @@ window.qBittorrent.DynamicTable ??= (() => { this.filterTerms = filterTerms; } + generateRowsSignature() { + const rowsData = []; + for (const { rowId } of this.getRowValues()) + rowsData.push({ ...this.getNode(rowId).serialize(), collapsed: this.isCollapsed(rowId) }); + return JSON.stringify(rowsData); + } + getFilteredAndSortedRows() { const root = this.getRoot(); if (root === null) return []; - const generateRowsSignature = () => { - const rowsData = []; - for (const { rowId } of this.getRowValues()) - rowsData.push({ ...this.getNode(rowId).serialize(), collapsed: this.isCollapsed(rowId) }); - return JSON.stringify(rowsData); - }; - const hasRowsChanged = function(rowsString, prevRowsStringString) { const rowsChanged = (rowsString !== prevRowsStringString); const isFilterTermsChanged = this.filterTerms.reduce((acc, term, index) => { @@ -2761,13 +2783,21 @@ window.qBittorrent.DynamicTable ??= (() => { return (rowsChanged || isFilterChanged || isSortedColumnChanged || isReverseSortChanged); }.bind(this); - const rowsString = generateRowsSignature(); + const rowsString = this.generateRowsSignature(); if (!hasRowsChanged(rowsString, this.prevRowsString)) return this.prevFilteredRows; // sort, then filter this.#sortNodesByColumn(root, this.columns[this.sortedColumn]); - const rows = this.#filterNodes(root.children[0], this.filterTerms); + const rows = (() => { + if (this.filterTerms.length === 0) { + const nodeArray = this.fileTree.toArray(); + const filteredRows = nodeArray.map(node => this.getRow(node)); + return filteredRows; + } + + return this.#filterNodes(root.children[0], this.filterTerms); + })(); this.prevFilterTerms = this.filterTerms; this.prevRowsString = rowsString; @@ -2798,61 +2828,18 @@ window.qBittorrent.DynamicTable ??= (() => { } } - class BulkRenameTorrentFilesTable extends DynamicTable { - filterTerms = []; - prevFilterTerms = []; - prevRowsString = null; - prevFilteredRows = []; - prevSortedColumn = null; - prevReverseSort = null; + class BulkRenameTorrentFilesTable extends TorrentFilesTable { prevCheckboxNum = null; - fileTree = new window.qBittorrent.FileTree.FileTree(); + supportCollapsing = false; + fileNameColumn = "original"; setupVirtualList() { super.setupVirtualList(); this.rowHeight = 29; } - populateTable(root) { - this.fileTree.setRoot(root); - root.children.forEach((node) => { - this.#addNodeToTable(node, 0, root); - }); - } - - #addNodeToTable(node, depth, parent) { - node.depth = depth; - node.parent = parent; - - this.updateRowData({ - rowId: node.rowId - }); - - node.children.forEach((child) => { - this.#addNodeToTable(child, depth + 1, node); - }); - } - - 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); - } - - getRowData(row, fullUpdate) { - return this.getNode(row.rowId); - } - getSelectedRows() { const nodes = this.fileTree.toArray(); - return nodes.filter(x => x.checked === 0); } @@ -3071,133 +3058,11 @@ window.qBittorrent.DynamicTable ??= (() => { this.updateGlobalCheckbox(); } - #sortNodesByColumn(root, column) { - const isColumnOriginal = (column.name === "original"); - const isReverseSort = (this.reverseSort === "0"); - - 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 (isColumnOriginal) { - if (node1.isFolder && !node2.isFolder) - return -1; - if (!node1.isFolder && node2.isFolder) - return 1; - } - - const result = column.compareRows(node1, node2); - return isReverseSort ? result : -result; - }); - - stack.push(...node.children); - } - } - - #filterNodes(root, filterTerms) { - const ret = []; - const stack = [root]; - const visited = []; - - while (stack.length > 0) { - const node = stack.at(-1); - - if (node.isFolder) { - const lastVisited = visited.at(-1); - - if ((visited.length <= 0) || (lastVisited !== node)) { - visited.push(node); - stack.push(...node.children); - continue; - } - - // has children added or itself matches - if (lastVisited.has_children_added || window.qBittorrent.Misc.containsAllTerms(node.name, filterTerms)) { - ret.push(this.getRow(node)); - delete node.has_children_added; - - // propagate up - const parent = node.root; - if (parent !== undefined) - parent.has_children_added = true; - } - - visited.pop(); - } - else { - if (window.qBittorrent.Misc.containsAllTerms(node.original, filterTerms)) { - ret.push(this.getRow(node)); - - const parent = node.root; - if (parent !== undefined) - parent.has_children_added = 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; - } - - getFilteredAndSortedRows() { - const root = this.getRoot(); - if (root === null) - return []; - - const generateRowsSignature = () => { - const rowsData = []; - for (const { full_data } of this.getRowValues()) - rowsData.push(full_data); - return JSON.stringify(rowsData); - }; - - 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); - - return (rowsChanged || isFilterChanged || isSortedColumnChanged || isReverseSortChanged); - }.bind(this); - - const rowsString = generateRowsSignature(); - if (!hasRowsChanged(rowsString, this.prevRowsString)) - return this.prevFilteredRows; - - // sort, then filter - this.#sortNodesByColumn(root, this.columns[this.sortedColumn]); - const rows = (() => { - if (this.filterTerms.length === 0) { - const nodeArray = this.fileTree.toArray(); - const filteredRows = nodeArray.map(node => this.getRow(node)); - return filteredRows; - } - - return this.#filterNodes(root.children[0], this.filterTerms); - })(); - - this.prevFilterTerms = this.filterTerms; - this.prevRowsString = rowsString; - this.prevFilteredRows = rows; - this.prevSortedColumn = this.sortedColumn; - this.prevReverseSort = this.reverseSort; - return rows; + generateRowsSignature() { + const rowsData = []; + for (const { full_data } of this.getRowValues()) + rowsData.push(full_data); + return JSON.stringify(rowsData); } setupCommonEvents() { From 9339bbb26d7f99ad64e5807e92227bf955bd0508 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Sun, 8 Jun 2025 22:45:49 -0700 Subject: [PATCH 10/11] Remove unused css This should have been removed earlier with the switch to using flex. --- src/webui/www/private/css/style.css | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/webui/www/private/css/style.css b/src/webui/www/private/css/style.css index 119912380..149dca88e 100644 --- a/src/webui/www/private/css/style.css +++ b/src/webui/www/private/css/style.css @@ -826,10 +826,6 @@ td.statusBarSeparator { overflow: auto; } -#searchResultsTableDiv { - height: calc(100% - 26px) !important; -} - #searchResults .dynamicTable { width: 100%; } From f65b2619626f3f34551b768c2fd7bf3040c56788 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Sun, 17 Aug 2025 14:38:21 -0700 Subject: [PATCH 11/11] Refactor logic to remove duplicate code --- src/webui/www/private/scripts/dynamicTable.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index cf6e23642..e3ec5d413 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -2860,26 +2860,23 @@ window.qBittorrent.DynamicTable ??= (() => { */ toggleGlobalCheckbox() { const checkbox = document.getElementById("rootMultiRename_cb"); - const checkboxes = document.querySelectorAll("input.RenamingCB"); + const isChecked = checkbox.checked || checkbox.indeterminate; - for (let i = 0; i < checkboxes.length; ++i) { - if (checkbox.checked || checkbox.indeterminate) { - const cb = checkboxes[i]; + for (const cb of document.querySelectorAll("input.RenamingCB")) { + cb.indeterminate = false; + if (isChecked) { cb.checked = true; - cb.indeterminate = false; cb.state = "checked"; } else { - const cb = checkboxes[i]; cb.checked = false; - cb.indeterminate = false; cb.state = "unchecked"; } } const nodes = this.fileTree.toArray(); for (const node of nodes) - node.checked = (checkbox.checked || checkbox.indeterminate) ? 0 : 1; + node.checked = isChecked ? 0 : 1; this.updateGlobalCheckbox(); }