diff --git a/src/webui/www/private/css/style.css b/src/webui/www/private/css/style.css index 0b934b5b879a..14a598749c3e 100644 --- a/src/webui/www/private/css/style.css +++ b/src/webui/www/private/css/style.css @@ -57,7 +57,8 @@ h2 { padding: 6px 0; } -#error_div { +#error_div, +#rename_error { color: #f00; float: left; font-size: 14px; diff --git a/src/webui/www/private/index.html b/src/webui/www/private/index.html index bbdea7f61acb..94e83f062ff8 100644 --- a/src/webui/www/private/index.html +++ b/src/webui/www/private/index.html @@ -30,6 +30,7 @@ + @@ -136,8 +137,13 @@

qBittorrent Web User Interface QBT_TR(Remove)QBT_TR[CONTEXT=TransferListWidget] QBT_TR(Remove)QBT_TR[CONTEXT=TransferListWidget]
  • QBT_TR(Set location...)QBT_TR[CONTEXT=TransferListWidget] QBT_TR(Set location...)QBT_TR[CONTEXT=TransferListWidget] +
  • +
  • QBT_TR(Rename...)QBT_TR[CONTEXT=TransferListWidget] QBT_TR(Rename...)QBT_TR[CONTEXT=TransferListWidget]
  • +
  • + QBT_TR(Rename Files...)QBT_TR[CONTEXT=TransferListWidget] QBT_TR(Rename Files...)QBT_TR[CONTEXT=TransferListWidget] +
  • QBT_TR(Category)QBT_TR[CONTEXT=TransferListWidget] QBT_TR(Category)QBT_TR[CONTEXT=TransferListWidget]
      @@ -225,6 +231,9 @@

      qBittorrent Web User Interface +
    • QBT_TR(Toggle Selection)QBT_TR[CONTEXT=PropertiesWidget] QBT_TR(Toggle Selection)QBT_TR[CONTEXT=PropertiesWidget]
    • +
      diff --git a/src/webui/www/private/rename_files.html b/src/webui/www/private/rename_files.html new file mode 100644 index 000000000000..d5d4155cae4f --- /dev/null +++ b/src/webui/www/private/rename_files.html @@ -0,0 +1,489 @@ + + + + + + QBT_TR(Renaming))QBT_TR[CONTEXT=TorrentContentTreeView] + + + + + + + + + + + +
      +
      +
      + + +
      +
      + +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      + + +
      +
      + + +
      +
      + + +
      +
      +
      +
      + +
      +
      +
      + + +
      + +
      +
      +
      + + + + +
      +
      +
      + + + + + +
      +
      +
      +
      + + + diff --git a/src/webui/www/private/scripts/contextmenu.js b/src/webui/www/private/scripts/contextmenu.js index 018bf1f0a024..eca991330997 100644 --- a/src/webui/www/private/scripts/contextmenu.js +++ b/src/webui/www/private/scripts/contextmenu.js @@ -362,6 +362,19 @@ window.qBittorrent.ContextMenu = (function() { let show_seq_dl = true; + // hide renameFiles when more than 1 torrent is selected + if (h.length == 1) { + const data = torrentsTable.rows.get(h[0]).full_data; + let metadata_downloaded = !(data['state'] == 'metaDL' || data['state'] == 'forcedMetaDL' || data['total_size'] == -1); + + // hide renameFiles when metadata hasn't been downloaded yet + metadata_downloaded + ? this.showItem('renameFiles') + : this.hideItem('renameFiles'); + } + else + this.hideItem('renameFiles'); + if (!all_are_seq_dl && there_are_seq_dl) show_seq_dl = false; diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index 39cac136dd5a..21a0d5404fb1 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -45,6 +45,7 @@ window.qBittorrent.DynamicTable = (function() { SearchResultsTable: SearchResultsTable, SearchPluginsTable: SearchPluginsTable, TorrentTrackersTable: TorrentTrackersTable, + BulkRenameTorrentFilesTable: BulkRenameTorrentFilesTable, TorrentFilesTable: TorrentFilesTable, LogMessageTable: LogMessageTable, LogPeerTable: LogPeerTable, @@ -128,7 +129,14 @@ window.qBittorrent.DynamicTable = (function() { // Workaround. Resize event is called not always (for example it isn't called when browser window changes it's size) const checkResizeFn = function() { - const panel = $(this.dynamicTableDivId).getParent('.panel'); + const tableDiv = $(this.dynamicTableDivId); + + // dynamicTableDivId is not visible on the UI + if (!tableDiv) { + return; + } + + const panel = tableDiv.getParent('.panel'); if (this.lastPanelHeight != panel.getBoundingClientRect().height) { this.lastPanelHeight = panel.getBoundingClientRect().height; panel.fireEvent('resize'); @@ -333,7 +341,8 @@ window.qBittorrent.DynamicTable = (function() { const menuId = this.dynamicTableDivId + '_headerMenu'; - const ul = new Element('ul', { + // reuse menu if already exists + const ul = $(menuId) ?? new Element('ul', { id: menuId, class: 'contextMenu scrollableMenu' }); @@ -351,6 +360,13 @@ window.qBittorrent.DynamicTable = (function() { this.showColumn(action, this.columns[action].visible === '0'); }.bind(this); + // recreate child nodes when reusing (enables the context menu to work correctly) + if (ul.hasChildNodes()) { + while (ul.firstChild) { + ul.removeChild(ul.lastChild); + } + } + for (let i = 0; i < this.columns.length; ++i) { const text = this.columns[i].caption; if (text === '') @@ -1777,6 +1793,431 @@ window.qBittorrent.DynamicTable = (function() { }, }); + const BulkRenameTorrentFilesTable = new Class({ + Extends: DynamicTable, + + filterTerms: [], + prevFilterTerms: [], + prevRowsString: null, + prevFilteredRows: [], + prevSortedColumn: null, + prevReverseSort: null, + fileTree: new window.qBittorrent.FileTree.FileTree(), + + populateTable: function(root) { + this.fileTree.setRoot(root); + root.children.each(function(node) { + this._addNodeToTable(node, 0); + }.bind(this)); + }, + + _addNodeToTable: function(node, depth) { + node.depth = depth; + + 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(function(child) { + this._addNodeToTable(child, depth + 1); + }.bind(this)); + }, + + getRoot: function() { + return this.fileTree.getRoot(); + }, + + getNode: function(rowId) { + return this.fileTree.getNode(rowId); + }, + + getRow: function(node) { + const rowId = this.fileTree.getRowId(node); + return this.rows.get(rowId); + }, + + getSelectedRows: function() { + const nodes = this.fileTree.toArray(); + + return nodes.filter(x => x.checked == 0); + }, + + initColumns: function() { + // 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: function() { + const checkbox = $('rootMultiRename_cb'); + const checkboxes = $$('input.RenamingCB'); + + for (let i = 0; i < checkboxes.length; ++i) { + const node = this.getNode(i); + + if (checkbox.checked || checkbox.indeterminate) { + let cb = checkboxes[i]; + cb.checked = true; + cb.indeterminate = false; + cb.state = "checked"; + node.checked = 0; + node.full_data.checked = node.checked; + } + else { + let cb = checkboxes[i]; + cb.checked = false; + cb.indeterminate = false; + cb.state = "unchecked"; + node.checked = 1; + node.full_data.checked = node.checked; + } + } + + this.updateGlobalCheckbox(); + }, + + toggleNodeTreeCheckbox: function(rowId, checkState) { + const node = this.getNode(rowId); + node.checked = checkState; + node.full_data.checked = checkState; + const checkbox = $(`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: function() { + const checkbox = $('rootMultiRename_cb'); + const checkboxes = $$('input.RenamingCB'); + const isAllChecked = function() { + for (let i = 0; i < checkboxes.length; ++i) { + if (!checkboxes[i].checked) + return false; + } + return true; + }; + const isAllUnchecked = function() { + for (let i = 0; i < checkboxes.length; ++i) { + if (checkboxes[i].checked) + return false; + } + return true; + }; + 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: function() { + const that = this; + + // checked + this.columns['checked'].updateTd = function(td, row) { + const id = row.rowId; + const value = this.getRowValue(row); + + const treeImg = new Element('img', { + src: 'images/L.gif', + styles: { + 'margin-bottom': -2 + } + }); + const checkbox = new Element('input'); + checkbox.set('type', 'checkbox'); + checkbox.set('id', 'cbRename' + id); + checkbox.set('data-id', id); + checkbox.set('class', 'RenamingCB'); + checkbox.addEvent('click', function(e) { + const node = that.getNode(id); + node.checked = e.target.checked ? 0 : 1; + node.full_data.checked = node.checked; + that.updateGlobalCheckbox(); + that.onRowSelectionChange(node); + e.stopPropagation(); + }); + checkbox.checked = value == 0; + checkbox.state = checkbox.checked ? "checked" : "unchecked"; + checkbox.indeterminate = false; + td.adopt(treeImg, checkbox); + }; + + // original + this.columns['original'].updateTd = function(td, row) { + const id = row.rowId; + const fileNameId = 'filesTablefileName' + id; + const node = that.getNode(id); + + if (node.isFolder) { + const value = this.getRowValue(row); + const dirImgId = 'renameTableDirImg' + id; + if ($(dirImgId)) { + // just update file name + $(fileNameId).set('text', value); + } + else { + const span = new Element('span', { + text: value, + id: fileNameId + }); + const dirImg = new Element('img', { + src: 'images/directory.svg', + styles: { + 'width': 15, + 'padding-right': 5, + 'margin-bottom': -3, + 'margin-left': (node.depth * 20) + }, + id: dirImgId + }); + const html = dirImg.outerHTML + span.outerHTML; + td.set('html', html); + } + } + else { // is file + const value = this.getRowValue(row); + const span = new Element('span', { + text: value, + id: fileNameId, + styles: { + 'margin-left': ((node.depth + 1) * 20) + } + }); + td.set('html', span.outerHTML); + } + }; + + // renamed + this.columns['renamed'].updateTd = function(td, row) { + const id = row.rowId; + const fileNameRenamedId = 'filesTablefileRenamed' + id; + const value = this.getRowValue(row); + + const span = new Element('span', { + text: value, + id: fileNameRenamedId, + }); + td.set('html', span.outerHTML); + }; + }, + + onRowSelectionChange: function(row) {}, + + selectRow: function() { + return; + }, + + reselectRows: function(rowIds) { + const that = this; + this.deselectAll(); + this.tableBody.getElements('tr').each(function(tr) { + if (rowIds.indexOf(tr.rowId) > -1) { + const node = that.getNode(tr.rowId); + node.checked = 0; + node.full_data.checked = 0; + + const checkbox = tr.children[0].getElement('input'); + checkbox.state = "checked"; + checkbox.indeterminate = false; + checkbox.checked = true; + } + }); + + this.updateGlobalCheckbox(); + }, + + altRow: function() { + let addClass = false; + const trs = this.tableBody.getElements('tr'); + trs.each(function(tr) { + if (tr.hasClass("invisible")) + return; + + if (addClass) { + tr.addClass("alt"); + tr.removeClass("nonAlt"); + } + else { + tr.removeClass("alt"); + tr.addClass("nonAlt"); + } + addClass = !addClass; + }.bind(this)); + }, + + _sortNodesByColumn: function(nodes, column) { + nodes.sort(function(row1, row2) { + // list folders before files when sorting by name + if (column.name === "original") { + const node1 = this.getNode(row1.data.rowId); + const node2 = this.getNode(row2.data.rowId); + if (node1.isFolder && !node2.isFolder) + return -1; + if (node2.isFolder && !node1.isFolder) + return 1; + } + + const res = column.compareRows(row1, row2); + return (this.reverseSort === '0') ? res : -res; + }.bind(this)); + + nodes.each(function(node) { + if (node.children.length > 0) + this._sortNodesByColumn(node.children, column); + }.bind(this)); + }, + + _filterNodes: function(node, filterTerms, filteredRows) { + if (node.isFolder) { + const childAdded = node.children.reduce(function(acc, child) { + // we must execute the function before ORing w/ acc or we'll stop checking child nodes after the first successful match + return (this._filterNodes(child, filterTerms, filteredRows) || acc); + }.bind(this), false); + + if (childAdded) { + const row = this.getRow(node); + filteredRows.push(row); + return true; + } + } + + if (window.qBittorrent.Misc.containsAllTerms(node.original, filterTerms)) { + const row = this.getRow(node); + filteredRows.push(row); + return true; + } + + return false; + }, + + setFilter: function(text) { + const filterTerms = text.trim().toLowerCase().split(' '); + if ((filterTerms.length === 1) && (filterTerms[0] === '')) + this.filterTerms = []; + else + this.filterTerms = filterTerms; + }, + + getFilteredAndSortedRows: function() { + if (this.getRoot() === null) + return []; + + const generateRowsSignature = function(rows) { + const rowsData = rows.map(function(row) { + return row.full_data; + }); + return JSON.stringify(rowsData); + }; + + const getFilteredRows = function() { + if (this.filterTerms.length === 0) { + const nodeArray = this.fileTree.toArray(); + const filteredRows = nodeArray.map(function(node) { + return this.getRow(node); + }.bind(this)); + return filteredRows; + } + + const filteredRows = []; + this.getRoot().children.each(function(child) { + this._filterNodes(child, this.filterTerms, filteredRows); + }.bind(this)); + filteredRows.reverse(); + return filteredRows; + }.bind(this); + + const hasRowsChanged = function(rowsString, prevRowsStringString) { + const rowsChanged = (rowsString !== prevRowsStringString); + const isFilterTermsChanged = this.filterTerms.reduce(function(acc, term, index) { + return (acc || (term !== this.prevFilterTerms[index])); + }.bind(this), 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(this.rows); + if (!hasRowsChanged(rowsString, this.prevRowsString)) { + return this.prevFilteredRows; + } + + // sort, then filter + const column = this.columns[this.sortedColumn]; + this._sortNodesByColumn(this.getRoot().children, column); + const filteredRows = getFilteredRows(); + + this.prevFilterTerms = this.filterTerms; + this.prevRowsString = rowsString; + this.prevFilteredRows = filteredRows; + this.prevSortedColumn = this.sortedColumn; + this.prevReverseSort = this.reverseSort; + return filteredRows; + }, + + setIgnored: function(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))); + }, + + setupTr: function(tr) { + tr.addEvent('keydown', function(event) { + switch (event.key) { + case "left": + qBittorrent.PropFiles.collapseFolder(this._this.getSelectedRowId()); + return false; + case "right": + qBittorrent.PropFiles.expandFolder(this._this.getSelectedRowId()); + return false; + } + }); + } + }); + const TorrentFilesTable = new Class({ Extends: DynamicTable, diff --git a/src/webui/www/private/scripts/file-tree.js b/src/webui/www/private/scripts/file-tree.js index e83817d47c7e..3457dd0f966e 100644 --- a/src/webui/www/private/scripts/file-tree.js +++ b/src/webui/www/private/scripts/file-tree.js @@ -135,6 +135,11 @@ window.qBittorrent.FileTree = (function() { const FolderNode = new Class({ Extends: FileNode, + /** + * Will automatically tick the checkbox for a folder if all subfolders and files are also ticked + */ + autoCheckFolders: true, + initialize: function() { this.isFolder = true; }, @@ -184,7 +189,7 @@ window.qBittorrent.FileTree = (function() { this.size = size; this.remaining = remaining; - this.checked = checked; + this.checked = this.autoCheckFolders ? checked : TriState.Checked; this.progress = (progress / size); this.priority = priority; this.availability = (availability / size); diff --git a/src/webui/www/private/scripts/mocha-init.js b/src/webui/www/private/scripts/mocha-init.js index 87748bf851af..c1906a60c42e 100644 --- a/src/webui/www/private/scripts/mocha-init.js +++ b/src/webui/www/private/scripts/mocha-init.js @@ -62,6 +62,7 @@ let recheckFN = function() {}; let reannounceFN = function() {}; let setLocationFN = function() {}; let renameFN = function() {}; +let renameFilesFN = function() {}; let torrentNewCategoryFN = function() {}; let torrentSetCategoryFN = function() {}; let createCategoryFN = function() {}; @@ -523,6 +524,31 @@ const initializeWindows = function() { } }; + renameFilesFN = function() { + const hashes = torrentsTable.selectedRowsIds(); + if (hashes.length == 1) { + const hash = hashes[0]; + const row = torrentsTable.rows[hash]; + if (row) { + new MochaUI.Window({ + id: 'multiRenamePage', + title: "QBT_TR(Renaming)QBT_TR[CONTEXT=TransferListWidget]", + data: { hash: hash, selectedRows: [] }, + loadMethod: 'xhr', + contentURL: 'rename_files.html', + scrollbars: false, + resizable: true, + maximizable: false, + paddingVertical: 0, + paddingHorizontal: 0, + width: 800, + height: 420, + resizeLimit: { 'x': [800], 'y': [420] } + }); + } + } + }; + torrentNewCategoryFN = function() { const action = "set"; const hashes = torrentsTable.selectedRowsIds(); diff --git a/src/webui/www/private/scripts/preferences.js b/src/webui/www/private/scripts/preferences.js index 37f7389419a5..c704b9f89900 100644 --- a/src/webui/www/private/scripts/preferences.js +++ b/src/webui/www/private/scripts/preferences.js @@ -54,6 +54,15 @@ window.qBittorrent.LocalPreferences = (function() { catch (err) { console.error(err); } + }, + + remove: function(key) { + try { + localStorage.removeItem(key); + } + catch (err) { + console.error(err); + } } }); diff --git a/src/webui/www/private/scripts/prop-files.js b/src/webui/www/private/scripts/prop-files.js index 2242fd81fed3..6b3a4bf327f7 100644 --- a/src/webui/www/private/scripts/prop-files.js +++ b/src/webui/www/private/scripts/prop-files.js @@ -536,6 +536,51 @@ window.qBittorrent.PropFiles = (function() { setFilePriority(Object.keys(uniqueRowIds), Object.keys(uniqueFileIds), priority); }; + const singleFileRename = function(hash) { + const rowId = torrentFilesTable.selectedRowsIds()[0]; + if (rowId === undefined) + return; + const row = torrentFilesTable.rows[rowId]; + if (!row) + return; + + const node = torrentFilesTable.getNode(rowId); + const path = node.path; + + new MochaUI.Window({ + id: 'renamePage', + title: "QBT_TR(Renaming)QBT_TR[CONTEXT=TorrentContentTreeView]", + loadMethod: 'iframe', + contentURL: 'rename_file.html?hash=' + hash + '&isFolder=' + node.isFolder + + '&path=' + encodeURIComponent(path), + scrollbars: false, + resizable: true, + maximizable: false, + paddingVertical: 0, + paddingHorizontal: 0, + width: 400, + height: 100 + }); + }; + + const multiFileRename = function(hash) { + const win = new MochaUI.Window({ + id: 'multiRenamePage', + title: "QBT_TR(Renaming)QBT_TR[CONTEXT=TorrentContentTreeView]", + data: { hash: hash, selectedRows: torrentFilesTable.selectedRows }, + loadMethod: 'xhr', + contentURL: 'rename_files.html', + scrollbars: false, + resizable: true, + maximizable: false, + paddingVertical: 0, + paddingHorizontal: 0, + width: 800, + height: 420, + resizeLimit: { 'x': [800], 'y': [420] } + }); + }; + const torrentFilesContextMenu = new window.qBittorrent.ContextMenu.ContextMenu({ targets: '#torrentFilesTableDiv tr', menu: 'torrentFilesMenu', @@ -544,30 +589,13 @@ window.qBittorrent.PropFiles = (function() { const hash = torrentsTable.getCurrentTorrentID(); if (!hash) return; - const rowId = torrentFilesTable.selectedRowsIds()[0]; - if (rowId === undefined) - return; - const row = torrentFilesTable.rows[rowId]; - if (!row) - return; - const node = torrentFilesTable.getNode(rowId); - const path = node.path; - - new MochaUI.Window({ - id: 'renamePage', - title: "QBT_TR(Renaming)QBT_TR[CONTEXT=TorrentContentTreeView]", - loadMethod: 'iframe', - contentURL: 'rename_file.html?hash=' + hash + '&isFolder=' + node.isFolder - + '&path=' + encodeURIComponent(path), - scrollbars: false, - resizable: true, - maximizable: false, - paddingVertical: 0, - paddingHorizontal: 0, - width: 400, - height: 100 - }); + if (torrentFilesTable.selectedRowsIds().length > 1) { + multiFileRename(hash); + } + else { + singleFileRename(hash); + } }, FilePrioIgnore: function(element, ref) { diff --git a/src/webui/www/private/scripts/rename-files.js b/src/webui/www/private/scripts/rename-files.js new file mode 100644 index 000000000000..687bd1f07f3b --- /dev/null +++ b/src/webui/www/private/scripts/rename-files.js @@ -0,0 +1,286 @@ +'use strict'; + +if (window.qBittorrent === undefined) { + window.qBittorrent = {}; +} + +window.qBittorrent.MultiRename = (function() { + const exports = function() { + return { + AppliesTo: AppliesTo, + RenameFiles: RenameFiles + }; + }; + + const AppliesTo = { + "FilenameExtension": "FilenameExtension", + "Filename": "Filename", + "Extension": "Extension" + }; + + const RenameFiles = new Class({ + hash: '', + selectedFiles: [], + matchedFiles: [], + + // Search Options + _inner_search: "", + setSearch(val) { + this._inner_search = val; + this._inner_update(); + this.onChanged(this.matchedFiles); + }, + useRegex: false, + matchAllOccurences: false, + caseSensitive: false, + + // Replacement Options + _inner_replacement: "", + setReplacement(val) { + this._inner_replacement = val; + this._inner_update(); + this.onChanged(this.matchedFiles); + }, + appliesTo: AppliesTo.FilenameExtension, + includeFiles: true, + includeFolders: false, + replaceAll: false, + fileEnumerationStart: 0, + + onChanged: function(rows) {}, + onInvalidRegex: function(err) {}, + onRenamed: function(rows) {}, + onRenameError: function(err) {}, + + _inner_update: function() { + const findMatches = (regex, str) => { + let result; + let count = 0; + let lastIndex = 0; + regex.lastIndex = 0; + let matches = []; + do { + result = regex.exec(str); + + if (result == null) { break; } + matches.push(result); + + // regex assertions don't modify lastIndex, + // so we need to explicitly break out to prevent infinite loop + if (lastIndex == regex.lastIndex) { + break; + } + else { + lastIndex = regex.lastIndex; + } + + // Maximum of 250 matches per file + ++count; + } while (regex.global && count < 250); + + return matches; + }; + + const replaceBetween = (input, start, end, replacement) => { + return input.substring(0, start) + replacement + input.substring(end); + }; + const replaceGroup = (input, search, replacement, escape, stripEscape = true) => { + let result = ''; + let i = 0; + while (i < input.length) { + // Check if the current index contains the escape string + if (input.substring(i, i + escape.length) === escape) { + // Don't replace escape chars when they don't precede the current search being performed + if (input.substring(i + escape.length, i + escape.length + search.length) !== search) { + result += input[i]; + i++; + continue; + } + // Replace escape chars when they precede the current search being performed, unless explicitly told not to + if (stripEscape) { + result += input.substring(i + escape.length, i + escape.length + search.length); + i += escape.length + search.length; + } + else { + result += input.substring(i, i + escape.length + search.length); + i += escape.length + search.length; + } + // Check if the current index contains the search string + } + else if (input.substring(i, i + search.length) === search) { + result += replacement; + i += search.length; + // Append characters that didn't meet the previous critera + } + else { + result += input[i]; + i++; + } + } + return result; + }; + + this.matchedFiles = []; + + // Ignore empty searches + if (!this._inner_search) { + return; + } + + // Setup regex flags + let regexFlags = ""; + if (this.matchAllOccurences) { regexFlags += "g"; } + if (!this.caseSensitive) { regexFlags += "i"; } + + // Setup regex search + const regexEscapeExp = new RegExp(/[/\-\\^$*+?.()|[\]{}]/g); + const standardSearch = new RegExp(this._inner_search.replace(regexEscapeExp, '\\$&'), regexFlags); + let regexSearch; + try { + regexSearch = new RegExp(this._inner_search, regexFlags); + } + catch (err) { + if (this.useRegex) { + this.onInvalidRegex(err); + return; + } + } + const search = this.useRegex ? regexSearch : standardSearch; + + let fileEnumeration = this.fileEnumerationStart; + for (let i = 0; i < this.selectedFiles.length; ++i) { + const row = this.selectedFiles[i]; + + // Ignore files + if (!row.isFolder && !this.includeFiles) { + continue; + } + // Ignore folders + else if (row.isFolder && !this.includeFolders) { + continue; + } + + // Get file extension and reappend the "." (only when the file has an extension) + let fileExtension = window.qBittorrent.Filesystem.fileExtension(row.original); + if (fileExtension) { fileExtension = "." + fileExtension; } + + const fileNameWithoutExt = row.original.slice(0, row.original.lastIndexOf(fileExtension)); + + let matches = []; + let offset = 0; + switch (this.appliesTo) { + case "FilenameExtension": + matches = findMatches(search, `${fileNameWithoutExt}${fileExtension}`); + break; + case "Filename": + matches = findMatches(search, `${fileNameWithoutExt}`); + break; + case "Extension": + // Adjust the offset to ensure we perform the replacement at the extension location + offset = fileNameWithoutExt.length; + matches = findMatches(search, `${fileExtension}`); + break; + } + // Ignore rows without a match + if (!matches || matches.length == 0) { + continue; + } + + let renamed = row.original; + for (let i = matches.length - 1; i >= 0; --i) { + const match = matches[i]; + let replacement = this._inner_replacement; + // Replace numerical groups + for (let g = 0; g < match.length; ++g) { + let group = match[g]; + if (!group) { continue; } + replacement = replaceGroup(replacement, `$${g}`, group, '\\', false); + } + // Replace named groups + for (let namedGroup in match.groups) { + replacement = replaceGroup(replacement, `$${namedGroup}`, match.groups[namedGroup], '\\', false); + } + // Replace auxillary variables + for (let v = 'dddddddd'; v !== ''; v = v.substring(1)) { + let fileCount = fileEnumeration.toString().padStart(v.length, '0'); + replacement = replaceGroup(replacement, `$${v}`, fileCount, '\\', false); + } + // Remove empty $ variable + replacement = replaceGroup(replacement, '$', '', '\\'); + const wholeMatch = match[0]; + const index = match['index']; + renamed = replaceBetween(renamed, index + offset, index + offset + wholeMatch.length, replacement); + } + + row.renamed = renamed; + ++fileEnumeration; + this.matchedFiles.push(row); + } + }, + + rename: async function() { + if (!this.matchedFiles || this.matchedFiles.length === 0 || !this.hash) { + this.onRenamed([]); + return; + } + + let replaced = []; + const _inner_rename = async function(i) { + const match = this.matchedFiles[i]; + const newName = match.renamed; + if (newName === match.original) { + // Original file name is identical to Renamed + return; + } + + const isFolder = match.isFolder; + const parentPath = window.qBittorrent.Filesystem.folderName(match.path); + const oldPath = parentPath + ? parentPath + window.qBittorrent.Filesystem.PathSeparator + match.original + : match.original; + const newPath = parentPath + ? parentPath + window.qBittorrent.Filesystem.PathSeparator + newName + : newName; + let renameRequest = new Request({ + url: isFolder ? 'api/v2/torrents/renameFolder' : 'api/v2/torrents/renameFile', + method: 'post', + data: { + hash: this.hash, + oldPath: oldPath, + newPath: newPath + } + }); + try { + await renameRequest.send(); + replaced.push(match); + } + catch (err) { + this.onRenameError(err, match); + } + }.bind(this); + + const replacements = this.matchedFiles.length; + if (this.replaceAll) { + // matchedFiles are in DFS order so we rename in reverse + // in order to prevent unwanted folder creation + for (let i = replacements - 1; i >= 0; --i) { + await _inner_rename(i); + } + } + else { + // single replacements go linearly top-down because the + // file tree gets recreated after every rename + await _inner_rename(0); + } + this.onRenamed(replaced); + }, + update: function() { + this._inner_update(); + this.onChanged(this.matchedFiles); + } + }); + + return exports(); +})(); + +Object.freeze(window.qBittorrent.MultiRename); diff --git a/src/webui/www/private/views/transferlist.html b/src/webui/www/private/views/transferlist.html index 6bbad810a3f0..e6978c659079 100644 --- a/src/webui/www/private/views/transferlist.html +++ b/src/webui/www/private/views/transferlist.html @@ -55,6 +55,9 @@ rename: function(element, ref) { renameFN(); }, + renameFiles: function(element, ref) { + renameFilesFN(); + }, queueTop: function(element, ref) { setQueuePositionFN('topPrio'); }, @@ -106,7 +109,7 @@ offsets: { x: -15, y: 2 - } + }, }); torrentsTable.setup('torrentsTableDiv', 'torrentsTableFixedHeaderDiv', contextMenu); diff --git a/src/webui/www/webui.qrc b/src/webui/www/webui.qrc index e26ba2e93e33..407b9039d3dc 100644 --- a/src/webui/www/webui.qrc +++ b/src/webui/www/webui.qrc @@ -372,6 +372,7 @@ private/rename.html private/rename_feed.html private/rename_file.html + private/rename_files.html private/rename_rule.html private/scripts/client.js private/scripts/contextmenu.js @@ -393,6 +394,7 @@ private/scripts/prop-peers.js private/scripts/prop-trackers.js private/scripts/prop-webseeds.js + private/scripts/rename-files.js private/scripts/speedslider.js private/scripts/lib/vanillaSelectBox.js private/setlocation.html