WebUI: Add multi-file renaming

PR #18287.
Closes #16239.
This commit is contained in:
loligans 2023-02-19 03:07:55 -08:00 committed by GitHub
parent d75fd3fcde
commit 466314675c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 1340 additions and 28 deletions

View file

@ -57,7 +57,8 @@ h2 {
padding: 6px 0;
}
#error_div {
#error_div,
#rename_error {
color: #f00;
float: left;
font-size: 14px;

View file

@ -30,6 +30,7 @@
<script src="scripts/piecesbar.js?v=${CACHEID}"></script>
<script src="scripts/file-tree.js?v=${CACHEID}"></script>
<script src="scripts/dynamicTable.js?locale=${LANG}&v=${CACHEID}"></script>
<script src="scripts/rename-files.js?v=${CACHEID}"></script>
<script src="scripts/client.js?locale=${LANG}&v=${CACHEID}"></script>
<script src="scripts/contextmenu.js?locale=${LANG}&v=${CACHEID}"></script>
</head>
@ -136,8 +137,13 @@
<li class="separator"><a href="#delete"><img src="images/list-remove.svg" alt="QBT_TR(Remove)QBT_TR[CONTEXT=TransferListWidget]" /> QBT_TR(Remove)QBT_TR[CONTEXT=TransferListWidget]</a></li>
<li class="separator">
<a href="#setLocation"><img src="images/set-location.svg" alt="QBT_TR(Set location...)QBT_TR[CONTEXT=TransferListWidget]" /> QBT_TR(Set location...)QBT_TR[CONTEXT=TransferListWidget]</a>
</li>
<li>
<a href="#rename"><img src="images/edit-rename.svg" alt="QBT_TR(Rename...)QBT_TR[CONTEXT=TransferListWidget]" /> QBT_TR(Rename...)QBT_TR[CONTEXT=TransferListWidget]</a>
</li>
<li>
<a href="#renameFiles"><img src="images/edit-rename.svg" alt="QBT_TR(Rename Files...)QBT_TR[CONTEXT=TransferListWidget]" /> QBT_TR(Rename Files...)QBT_TR[CONTEXT=TransferListWidget]</a>
</li>
<li>
<a href="#Category" class="arrow-right"><img src="images/view-categories.svg" alt="QBT_TR(Category)QBT_TR[CONTEXT=TransferListWidget]" /> QBT_TR(Category)QBT_TR[CONTEXT=TransferListWidget]</a>
<ul id="contextCategoryList" class="scrollableMenu"></ul>
@ -225,6 +231,9 @@
</ul>
</li>
</ul>
<ul id="multiRenameFilesMenu" class="contextMenu">
<li><a href="#ToggleSelection"><img src="images/edit-rename.svg" alt="QBT_TR(Toggle Selection)QBT_TR[CONTEXT=PropertiesWidget]" /> QBT_TR(Toggle Selection)QBT_TR[CONTEXT=PropertiesWidget]</a></li>
</ul>
<div id="desktopFooterWrapper">
<div id="desktopFooter">
<span id="error_div"></span>

View file

@ -0,0 +1,489 @@
<!DOCTYPE html>
<html lang="${LANG}">
<head>
<meta charset="UTF-8" />
<title>QBT_TR(Renaming))QBT_TR[CONTEXT=TorrentContentTreeView]</title>
<script src="scripts/lib/MooTools-Core-1.6.0-compat-compressed.js"></script>
<script src="scripts/lib/MooTools-More-1.6.0-compat-compressed.js"></script>
<script src="scripts/filesystem.js?v=${CACHEID}"></script>
<script src="scripts/misc.js?locale=${LANG}&v=${CACHEID}"></script>
<script src="scripts/file-tree.js?v=${CACHEID}"></script>
<script src="scripts/dynamicTable.js?locale=${LANG}&v=${CACHEID}"></script>
<script src="scripts/rename-files.js?v=${CACHEID}"></script>
<script>
'use strict';
if (window.parent.qBittorrent !== undefined) {
window.qBittorrent = window.parent.qBittorrent;
}
window.qBittorrent = window.parent.qBittorrent;
var TriState = window.qBittorrent.FileTree.TriState;
var data = window.MUI.Windows.instances['multiRenamePage'].options.data;
var bulkRenameFilesContextMenu;
if (!bulkRenameFilesContextMenu) {
bulkRenameFilesContextMenu = new window.qBittorrent.ContextMenu.ContextMenu({
targets: '#bulkRenameFilesTableDiv tr',
menu: 'multiRenameFilesMenu',
actions: {
ToggleSelection: function(element, ref) {
const rowId = parseInt(element.get('data-row-id'));
const row = bulkRenameFilesTable.getNode(rowId);
const checkState = row.checked == 1 ? 0 : 1;
bulkRenameFilesTable.toggleNodeTreeCheckbox(rowId, checkState);
bulkRenameFilesTable.updateGlobalCheckbox();
bulkRenameFilesTable.onRowSelectionChange(bulkRenameFilesTable.getSelectedRows());
}
},
offsets: {
x: -15,
y: 2
}
});
}
// Setup the dynamic table for bulk renaming
var bulkRenameFilesTable = new window.qBittorrent.DynamicTable.BulkRenameTorrentFilesTable();
bulkRenameFilesTable.setup('bulkRenameFilesTableDiv', 'bulkRenameFilesTableFixedHeaderDiv', bulkRenameFilesContextMenu);
// Inject checkbox into the first column of the table header
var tableHeaders = $$('#bulkRenameFilesTableFixedHeaderDiv .dynamicTableHeader th');
var checkboxHeader;
if (tableHeaders.length > 0) {
if (checkboxHeader) {
checkboxHeader.remove();
}
checkboxHeader = new Element('input');
checkboxHeader.set('type', 'checkbox');
checkboxHeader.set('id', 'rootMultiRename_cb');
checkboxHeader.addEvent('click', function(e) {
bulkRenameFilesTable.toggleGlobalCheckbox();
fileRenamer.selectedFiles = bulkRenameFilesTable.getSelectedRows();
fileRenamer.update();
});
const checkboxTH = tableHeaders[0];
checkboxHeader.injectInside(checkboxTH);
}
// Register keyboard events to modal window
if (!keyboard) {
var keyboard = new Keyboard({
defaultEventType: 'keydown',
events: {
'Escape': function(event) {
window.parent.closeWindows();
event.preventDefault();
},
'Esc': function(event) {
window.parent.closeWindows();
event.preventDefault();
}
}
});
keyboard.activate();
}
var fileRenamer = new window.qBittorrent.MultiRename.RenameFiles();
fileRenamer.hash = data.hash;
// Load Multi Rename Preferences
var multiRenamePrefChecked = LocalPreferences.get('multirename_rememberPreferences', "true") === "true";
$('multirename_rememberprefs_checkbox').setProperty('checked', multiRenamePrefChecked);
if (multiRenamePrefChecked) {
var multirename_search = LocalPreferences.get('multirename_search', '');
fileRenamer.setSearch(multirename_search);
$('multiRenameSearch').set('value', multirename_search);
var multirename_useRegex = LocalPreferences.get('multirename_useRegex', false);
fileRenamer.useRegex = multirename_useRegex === 'true';
$('use_regex_search').checked = fileRenamer.useRegex;
var multirename_matchAllOccurences = LocalPreferences.get('multirename_matchAllOccurences', false);
fileRenamer.matchAllOccurences = multirename_matchAllOccurences === 'true';
$('match_all_occurences').checked = fileRenamer.matchAllOccurences;
var multirename_caseSensitive = LocalPreferences.get('multirename_caseSensitive', false);
fileRenamer.caseSensitive = multirename_caseSensitive === 'true';
$('case_sensitive').checked = fileRenamer.caseSensitive;
var multirename_replace = LocalPreferences.get('multirename_replace', '');
fileRenamer.setReplacement(multirename_replace);
$('multiRenameReplace').set('value', multirename_replace);
var multirename_appliesTo = LocalPreferences.get('multirename_appliesTo', window.qBittorrent.MultiRename.AppliesTo.FilenameExtension);
fileRenamer.appliesTo = window.qBittorrent.MultiRename.AppliesTo[multirename_appliesTo];
$('applies_to_option').set('value', fileRenamer.appliesTo);
var multirename_includeFiles = LocalPreferences.get('multirename_includeFiles', true);
fileRenamer.includeFiles = multirename_includeFiles === 'true';
$('include_files').checked = fileRenamer.includeFiles;
var multirename_includeFolders = LocalPreferences.get('multirename_includeFolders', false);
fileRenamer.includeFolders = multirename_includeFolders === 'true';
$('include_folders').checked = fileRenamer.includeFolders;
var multirename_fileEnumerationStart = LocalPreferences.get('multirename_fileEnumerationStart', 0);
fileRenamer.fileEnumerationStart = parseInt(multirename_fileEnumerationStart);
$('file_counter').set('value', fileRenamer.fileEnumerationStart);
var multirename_replaceAll = LocalPreferences.get('multirename_replaceAll', false);
fileRenamer.replaceAll = multirename_replaceAll === 'true';
var renameButtonValue = fileRenamer.replaceAll ? 'Replace All' : 'Replace';
$('renameOptions').set('value', renameButtonValue);
$('renameButton').set('value', renameButtonValue);
}
// Fires everytime a row's selection changes
bulkRenameFilesTable.onRowSelectionChange = function(row) {
fileRenamer.selectedFiles = bulkRenameFilesTable.getSelectedRows();
fileRenamer.update();
};
// Setup Search Events that control renaming
$('multiRenameSearch').addEvent('input', function(e) {
let sanitized = e.target.value.replace(/\n/g, '');
$('multiRenameSearch').set('value', sanitized);
// Search input has changed
$('multiRenameSearch').style['border-color'] = '';
LocalPreferences.set('multirename_search', sanitized);
fileRenamer.setSearch(sanitized);
});
$('use_regex_search').addEvent('change', function(e) {
fileRenamer.useRegex = e.target.checked;
LocalPreferences.set('multirename_useRegex', e.target.checked);
fileRenamer.update();
});
$('match_all_occurences').addEvent('change', function(e) {
fileRenamer.matchAllOccurences = e.target.checked;
LocalPreferences.set('multirename_matchAllOccurences', e.target.checked);
fileRenamer.update();
});
$('case_sensitive').addEvent('change', function(e) {
fileRenamer.caseSensitive = e.target.checked;
LocalPreferences.set('multirename_caseSensitive', e.target.checked);
fileRenamer.update();
});
/**
* Fires every time the filerenamer gets changed, it will update all the rows in the table
*/
fileRenamer.onChanged = function(matchedRows) {
// Clear renamed column
document
.querySelectorAll("span[id^='filesTablefileRenamed']")
.forEach(function(span) {
span.set('text', "");
});
// Update renamed column for matched rows
for (let i = 0; i < matchedRows.length; ++i) {
const row = matchedRows[i];
$('filesTablefileRenamed' + row.rowId).set('text', row.renamed);
}
};
fileRenamer.onInvalidRegex = function(err) {
$('multiRenameSearch').style['border-color'] = '#CC0033';
};
// Setup Replace Events that control renaming
$('multiRenameReplace').addEvent('input', function(e) {
let sanitized = e.target.value.replace(/\n/g, '');
$('multiRenameReplace').set('value', sanitized);
// Replace input has changed
$('multiRenameReplace').style['border-color'] = '';
LocalPreferences.set('multirename_replace', sanitized);
fileRenamer.setReplacement(sanitized);
});
$('applies_to_option').addEvent('change', function(e) {
fileRenamer.appliesTo = e.target.value;
LocalPreferences.set('multirename_appliesTo', e.target.value);
fileRenamer.update();
});
$('include_files').addEvent('change', function(e) {
fileRenamer.includeFiles = e.target.checked;
LocalPreferences.set('multirename_includeFiles', e.target.checked);
fileRenamer.update();
});
$('include_folders').addEvent('change', function(e) {
fileRenamer.includeFolders = e.target.checked;
LocalPreferences.set('multirename_includeFolders', e.target.checked);
fileRenamer.update();
});
$('file_counter').addEvent('input', function(e) {
let value = e.target.valueAsNumber;
if (!value) { value = 0; }
if (value < 0) { value = 0; }
if (value > 99999999) { value = 99999999; }
fileRenamer.fileEnumerationStart = value;
$('file_counter').set('value', value);
LocalPreferences.set('multirename_fileEnumerationStart', value);
fileRenamer.update();
});
// Setup Rename Operation Events
$('renameButton').addEvent('click', function(e) {
// Disable Search Options
$('multiRenameSearch').disabled = true;
$('use_regex_search').disabled = true;
$('match_all_occurences').disabled = true;
$('case_sensitive').disabled = true;
// Disable Replace Options
$('multiRenameReplace').disabled = true;
$('applies_to_option').disabled = true;
$('include_files').disabled = true;
$('include_folders').disabled = true;
$('file_counter').disabled = true;
// Disable Rename Buttons
$('renameButton').disabled = true;
$('renameOptions').disabled = true;
// Clear error text
$('rename_error').set('text', '');
fileRenamer.rename();
});
fileRenamer.onRenamed = function(rows) {
// Disable Search Options
$('multiRenameSearch').disabled = false;
$('use_regex_search').disabled = false;
$('match_all_occurences').disabled = false;
$('case_sensitive').disabled = false;
// Disable Replace Options
$('multiRenameReplace').disabled = false;
$('applies_to_option').disabled = false;
$('include_files').disabled = false;
$('include_folders').disabled = false;
$('file_counter').disabled = false;
// Disable Rename Buttons
$('renameButton').disabled = false;
$('renameOptions').disabled = false;
// Recreate table
let selectedRows = bulkRenameFilesTable.getSelectedRows().map(row => row.rowId.toString());
for (let renamedRow of rows) {
selectedRows = selectedRows.filter(selectedRow => selectedRow !== renamedRow.rowId.toString());
}
bulkRenameFilesTable.clear();
// Adjust file enumeration count by 1 when replacing single files to prevent naming conflicts
if (!fileRenamer.replaceAll) {
fileRenamer.fileEnumerationStart++;
$('file_counter').set('value', fileRenamer.fileEnumerationStart);
}
setupTable(selectedRows);
};
fileRenamer.onRenameError = function(err, row) {
if (err.xhr.status === 409) {
$('rename_error').set('text', `QBT_TR(Rename failed: file or folder already exists)QBT_TR[CONTEXT=PropertiesWidget] \`${row.renamed}\``);
}
};
$('renameOptions').addEvent('change', function(e) {
const combobox = e.target;
const replaceOperation = combobox.value;
if (replaceOperation == "Replace") {
fileRenamer.replaceAll = false;
}
else if (replaceOperation == "Replace All") {
fileRenamer.replaceAll = true;
}
else {
fileRenamer.replaceAll = false;
}
LocalPreferences.set('multirename_replaceAll', fileRenamer.replaceAll);
$('renameButton').set('value', replaceOperation);
});
$('closeButton').addEvent('click', function() {
window.parent.closeWindows();
event.preventDefault();
});
// synchronize header scrolling to table body
$('bulkRenameFilesTableDiv').onscroll = function() {
const length = $(this).scrollLeft;
$('bulkRenameFilesTableFixedHeaderDiv').scrollLeft = length;
};
var handleTorrentFiles = function(files, selectedRows) {
const rows = files.map(function(file, index) {
const row = {
fileId: index,
checked: 1, // unchecked
path: file.name,
original: window.qBittorrent.Filesystem.fileName(file.name),
renamed: "",
size: file.size
};
return row;
});
addRowsToTable(rows, selectedRows);
};
var addRowsToTable = function(rows, selectedRows) {
let rowId = 0;
const rootNode = new window.qBittorrent.FileTree.FolderNode();
rootNode.autoCheckFolders = false;
rows.forEach(function(row) {
const pathItems = row.path.split(window.qBittorrent.Filesystem.PathSeparator);
pathItems.pop(); // remove last item (i.e. file name)
let parent = rootNode;
pathItems.forEach(function(folderName) {
if (folderName === '.unwanted') {
return;
}
let folderNode = null;
if (parent.children !== null) {
for (let i = 0; i < parent.children.length; ++i) {
const childFolder = parent.children[i];
if (childFolder.original === folderName) {
folderNode = childFolder;
break;
}
}
}
if (folderNode === null) {
folderNode = new window.qBittorrent.FileTree.FolderNode();
folderNode.autoCheckFolders = false;
folderNode.rowId = rowId;
folderNode.path = (parent.path === "")
? folderName
: [parent.path, folderName].join(window.qBittorrent.Filesystem.PathSeparator);
folderNode.checked = selectedRows.includes(rowId.toString()) ? 0 : 1;
folderNode.original = folderName;
folderNode.renamed = "";
folderNode.root = parent;
parent.addChild(folderNode);
++rowId;
}
parent = folderNode;
});
const childNode = new window.qBittorrent.FileTree.FileNode();
childNode.rowId = rowId;
childNode.path = row.path;
childNode.checked = selectedRows.includes(rowId.toString()) ? 0 : 1;
childNode.original = row.original;
childNode.renamed = "";
childNode.root = parent;
childNode.data = row;
parent.addChild(childNode);
++rowId;
});
bulkRenameFilesTable.populateTable(rootNode);
bulkRenameFilesTable.updateTable(false);
bulkRenameFilesTable.altRow();
if (selectedRows !== undefined) {
bulkRenameFilesTable.reselectRows(selectedRows);
}
fileRenamer.selectedFiles = bulkRenameFilesTable.getSelectedRows();
fileRenamer.update();
};
var setupTable = function(selectedRows) {
new Request.JSON({
url: new URI('api/v2/torrents/files?hash=' + data.hash),
noCache: true,
method: 'get',
onSuccess: function(files) {
if (files.length === 0) {
bulkRenameFilesTable.clear();
}
else {
handleTorrentFiles(files, selectedRows);
}
}
}).send();
};
setupTable(data.selectedRows);
</script>
</head>
<body style="min-width: 400px; min-height: 300px;">
<div style="padding: 0px 10px 0px 0px;">
<div style="float: left; height: 100%; width: 228px;">
<div class="formRow">
<input type="checkbox" id="multirename_rememberprefs_checkbox" onchange="LocalPreferences.set('multirename_rememberPreferences', this.checked);" />
<label for="multirename_rememberprefs_checkbox">QBT_TR(Remember Multi-Rename settings)QBT_TR[CONTEXT=OptionsDialog]</label>
</div>
<hr>
<textarea id="multiRenameSearch" placeholder="QBT_TR(Search Files)QBT_TR[CONTEXT=PropertiesWidget]" style="width: calc(100% - 8px); resize: vertical; min-height: 30px;"></textarea>
<div class="formRow">
<input type="checkbox" id="use_regex_search" />
<label for="use_regex_search">QBT_TR(Use regular expressions)QBT_TR[CONTEXT=PropertiesWidget]</label>
</div>
<div class="formRow">
<input type="checkbox" id="match_all_occurences" />
<label for="match_all_occurences">QBT_TR(Match all occurences)QBT_TR[CONTEXT=PropertiesWidget]</label>
</div>
<div class="formRow">
<input type="checkbox" id="case_sensitive" />
<label for="case_sensitive">QBT_TR(Case sensitive)QBT_TR[CONTEXT=PropertiesWidget]</label>
</div>
<hr>
<textarea id="multiRenameReplace" placeholder="QBT_TR(Replacement Input)QBT_TR[CONTEXT=PropertiesWidget]" style="width: calc(100% - 8px); resize: vertical; min-height: 30px;"></textarea>
<select id="applies_to_option" name="applies_to_option" style="width: 100%; margin-bottom: 5px;">
<option selected value="FilenameExtension">QBT_TR(Filename + Extension)QBT_TR[CONTEXT=PropertiesWidget]</option>
<option value="Filename">QBT_TR(Filename)QBT_TR[CONTEXT=PropertiesWidget]</option>
<option value="Extension">QBT_TR(Extension)QBT_TR[CONTEXT=PropertiesWidget]</option>
</select>
<div class="formRow">
<input type="checkbox" id="include_files" checked />
<label for="include_files">QBT_TR(Include files)QBT_TR[CONTEXT=PropertiesWidget]</label>
</div>
<div class="formRow">
<input type="checkbox" id="include_folders" />
<label for="include_folders">QBT_TR(Include folders)QBT_TR[CONTEXT=PropertiesWidget]</label>
</div>
<div class="formRow">
<input type="number" min="0" max="99999999" value="0" id="file_counter" style="width: 80px;" />
<label for="file_counter">QBT_TR(Enumerate Files)QBT_TR[CONTEXT=PropertiesWidget]</label>
</div>
</div>
<div id="operation_btns" style="position: absolute; left: 0; bottom: 0; margin: 0px 12px 36px 12px; width: 228px;background: #ffffff;padding: 0px 5px 10px 0px;">
<div style="overflow: auto;">
<span id="rename_error" style="float: unset; font-size: unset;"></span>
</div>
<hr>
<div style="width: 60%; float: left;">
<input id="renameButton" type="button" value="Replace" style="float: left; width: 86px;">
<select id="renameOptions" name="renameOptions" style="width: 22px;">
<option selected value="Replace">QBT_TR(Replace)QBT_TR[CONTEXT=PropertiesWidget]</option>
<option value="Replace All">QBT_TR(Replace All)QBT_TR[CONTEXT=PropertiesWidget]</option>
</select>
</div>
<input id="closeButton" type="button" value="Close" style="float: right; width: 30%;">
</div>
<div id="torrentFiles" class="panel" style="position: absolute; top: 0; right: 0; bottom: 0; left: 228px; margin: 35px 10px 45px 20px; border-bottom: 0">
<div id="bulkRenameFilesTableFixedHeaderDiv" class="dynamicTableFixedHeaderDiv">
<table class="dynamicTable">
<thead>
<tr class="dynamicTableHeader"></tr>
</thead>
</table>
</div>
<div id="bulkRenameFilesTableDiv" class="dynamicTableDiv">
<table class="dynamicTable">
<thead>
<tr class="dynamicTableHeader"></tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</body>
</html>

View file

@ -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;

View file

@ -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,

View file

@ -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);

View file

@ -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();

View file

@ -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);
}
}
});

View file

@ -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) {

View file

@ -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);

View file

@ -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);

View file

@ -372,6 +372,7 @@
<file>private/rename.html</file>
<file>private/rename_feed.html</file>
<file>private/rename_file.html</file>
<file>private/rename_files.html</file>
<file>private/rename_rule.html</file>
<file>private/scripts/client.js</file>
<file>private/scripts/contextmenu.js</file>
@ -393,6 +394,7 @@
<file>private/scripts/prop-peers.js</file>
<file>private/scripts/prop-trackers.js</file>
<file>private/scripts/prop-webseeds.js</file>
<file>private/scripts/rename-files.js</file>
<file>private/scripts/speedslider.js</file>
<file>private/scripts/lib/vanillaSelectBox.js</file>
<file>private/setlocation.html</file>