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%; } 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/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index aacdb5f5d..e3ec5d413 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, @@ -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) { @@ -2332,441 +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.each((node) => { - this.#addNodeToTable(node, 0, root); - }); - } - - #addNodeToTable(node, depth, parent) { - 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); - } - - node.children.each((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); - } - - 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; - node.full_data.checked = node.checked; - } - - this.updateGlobalCheckbox(); - } - - 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"; - - 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; - node.full_data.checked = node.checked; - } - 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; - }; - } - - 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; - node.full_data.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((row1, row2) => { - // 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); - 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; - } - - 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) => { - headerDiv.scrollLeft = this.dynamicTableDiv.scrollLeft; - // rerender on scroll - if (this.useVirtualList) { - this.renderedOffset = this.dynamicTableDiv.scrollTop; - this.rerender(); - } - }); - } - } - class TorrentFilesTable extends DynamicTable { filterTerms = []; prevFilterTerms = []; @@ -2775,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; @@ -2789,6 +2365,9 @@ window.qBittorrent.DynamicTable ??= (() => { } collapseNode(id) { + if (!this.supportCollapsing) + return; + const state = this.collapseState.get(id); if (state !== undefined) state.collapsed = true; @@ -2796,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) @@ -2864,28 +2449,34 @@ window.qBittorrent.DynamicTable ??= (() => { } expandFolder(id) { + if (!this.supportCollapsing) + return; + const node = this.getNode(id); if (node.isFolder) - this.expandNode(node); + this.expandNode(node.rowId); } collapseFolder(id) { + if (!this.supportCollapsing) + return; + const node = this.getNode(id); if (node.isFolder) - this.collapseNode(node); + this.collapseNode(node.rowId); } 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) { this.fileTree.setRoot(root); - root.children.each((node) => { + root.children.forEach((node) => { this.#addNodeToTable(node, 0, root); }); } @@ -2894,32 +2485,14 @@ 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.supportCollapsing && !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) => { + node.children.forEach((child) => { this.#addNodeToTable(child, depth + 1, node); }); } @@ -2938,8 +2511,16 @@ 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); + } + + calculateRemaining() { + this.fileTree.getRoot().calculateRemaining(); } initColumns() { @@ -2971,6 +2552,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 +2563,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 +2654,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,28 +2669,33 @@ 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) { - const isColumnName = (column.name === "name"); + const isColumnName = (column.name === this.fileNameColumn); const isReverseSort = (this.reverseSort === "0"); const stack = [root]; 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; }); @@ -3146,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; @@ -3170,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 { full_data } of this.getRowValues()) - rowsData.push({ ...full_data, collapsed: this.isCollapsed(full_data.rowId) }); - return JSON.stringify(rowsData); - }; - const hasRowsChanged = function(rowsString, prevRowsStringString) { const rowsChanged = (rowsString !== prevRowsStringString); const isFilterTermsChanged = this.filterTerms.reduce((acc, term, index) => { @@ -3195,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; @@ -3211,14 +2807,6 @@ window.qBittorrent.DynamicTable ??= (() => { return rows; } - setIgnored(rowId, ignore) { - const row = this.rows.get(rowId.toString()); - if (ignore) - row.full_data.remaining = 0; - else - row.full_data.remaining = (row.full_data.size * (1.0 - (row.full_data.progress / 100))); - } - setupCommonEvents() { super.setupCommonEvents(); this.dynamicTableDiv.addEventListener("keydown", (e) => { @@ -3240,6 +2828,253 @@ window.qBittorrent.DynamicTable ??= (() => { } } + class BulkRenameTorrentFilesTable extends TorrentFilesTable { + prevCheckboxNum = null; + supportCollapsing = false; + fileNameColumn = "original"; + + setupVirtualList() { + super.setupVirtualList(); + this.rowHeight = 29; + } + + 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 isChecked = checkbox.checked || checkbox.indeterminate; + + for (const cb of document.querySelectorAll("input.RenamingCB")) { + cb.indeterminate = false; + if (isChecked) { + cb.checked = true; + cb.state = "checked"; + } + else { + cb.checked = false; + cb.state = "unchecked"; + } + } + + const nodes = this.fileTree.toArray(); + for (const node of nodes) + node.checked = isChecked ? 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(); + } + + generateRowsSignature() { + const rowsData = []; + for (const { full_data } of this.getRowValues()) + rowsData.push(full_data); + return JSON.stringify(rowsData); + } + + 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); @@ -3299,18 +3134,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 @@ -3435,15 +3266,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 = {}; @@ -3700,23 +3525,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); } } diff --git a/src/webui/www/private/scripts/file-tree.js b/src/webui/www/private/scripts/file-tree.js index c56ddeaef..e225cb297 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,19 +123,42 @@ window.qBittorrent.FileTree ??= (() => { availability = 0; depth = 0; root = null; - data = null; 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, + 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 { /** - * 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; addChild(node) { + node.calculateRemaining(); this.children.push(node); } @@ -181,22 +205,32 @@ 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); } } - root.checked = root.autoCheckFolders ? root.checked : TriState.Checked; - root.progress /= root.size; - root.availability /= root.size; + root.checked = root.autoCalculateCheckedState ? root.checked : TriState.Checked; + root.progress = (root.size > 0) ? (root.progress / root.size) : 0; + root.availability = (root.size > 0) ? (root.availability / root.size) : 0; } 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 8e3431579..37a216b96 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,26 +287,22 @@ 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); if (onFilePriorityChanged) onFilePriorityChanged(fileIds, priority); - const ignore = (priority === FilePriority.Ignored); - ids.forEach((id) => { - 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 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) => { @@ -318,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 }; @@ -372,19 +369,17 @@ 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; childNode.rowId = rowId; + 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; childNode.root = parent; - childNode.data = row; parent.addChild(childNode); ++rowId; @@ -430,6 +425,7 @@ window.qBittorrent.TorrentContent ??= (() => { const updateComplete = () => { // we've finished recursing updateGlobalCheckbox(); + torrentFilesTable.calculateRemaining(); torrentFilesTable.updateTable(true); }; @@ -447,7 +443,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 +461,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 +470,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); }