WebUI: Improve accuracy of trackers list
[qBittorrent.git] / src / webui / www / private / scripts / rename-files.js
blobd9a2e2d6fcc274a5dab5fd33e5aa1ad020bc3210
1 'use strict';
3 if (window.qBittorrent === undefined) {
4 window.qBittorrent = {};
7 window.qBittorrent.MultiRename = (function() {
8 const exports = function() {
9 return {
10 AppliesTo: AppliesTo,
11 RenameFiles: RenameFiles
15 const AppliesTo = {
16 "FilenameExtension": "FilenameExtension",
17 "Filename": "Filename",
18 "Extension": "Extension"
21 const RenameFiles = new Class({
22 hash: '',
23 selectedFiles: [],
24 matchedFiles: [],
26 // Search Options
27 _inner_search: "",
28 setSearch(val) {
29 this._inner_search = val;
30 this._inner_update();
31 this.onChanged(this.matchedFiles);
33 useRegex: false,
34 matchAllOccurrences: false,
35 caseSensitive: false,
37 // Replacement Options
38 _inner_replacement: "",
39 setReplacement(val) {
40 this._inner_replacement = val;
41 this._inner_update();
42 this.onChanged(this.matchedFiles);
44 appliesTo: AppliesTo.FilenameExtension,
45 includeFiles: true,
46 includeFolders: false,
47 replaceAll: false,
48 fileEnumerationStart: 0,
50 onChanged: function(rows) {},
51 onInvalidRegex: function(err) {},
52 onRenamed: function(rows) {},
53 onRenameError: function(err) {},
55 _inner_update: function() {
56 const findMatches = (regex, str) => {
57 let result;
58 let count = 0;
59 let lastIndex = 0;
60 regex.lastIndex = 0;
61 let matches = [];
62 do {
63 result = regex.exec(str);
65 if (result == null) { break; }
66 matches.push(result);
68 // regex assertions don't modify lastIndex,
69 // so we need to explicitly break out to prevent infinite loop
70 if (lastIndex == regex.lastIndex) {
71 break;
73 else {
74 lastIndex = regex.lastIndex;
77 // Maximum of 250 matches per file
78 ++count;
79 } while (regex.global && count < 250);
81 return matches;
84 const replaceBetween = (input, start, end, replacement) => {
85 return input.substring(0, start) + replacement + input.substring(end);
87 const replaceGroup = (input, search, replacement, escape, stripEscape = true) => {
88 let result = '';
89 let i = 0;
90 while (i < input.length) {
91 // Check if the current index contains the escape string
92 if (input.substring(i, i + escape.length) === escape) {
93 // Don't replace escape chars when they don't precede the current search being performed
94 if (input.substring(i + escape.length, i + escape.length + search.length) !== search) {
95 result += input[i];
96 i++;
97 continue;
99 // Replace escape chars when they precede the current search being performed, unless explicitly told not to
100 if (stripEscape) {
101 result += input.substring(i + escape.length, i + escape.length + search.length);
102 i += escape.length + search.length;
104 else {
105 result += input.substring(i, i + escape.length + search.length);
106 i += escape.length + search.length;
108 // Check if the current index contains the search string
110 else if (input.substring(i, i + search.length) === search) {
111 result += replacement;
112 i += search.length;
113 // Append characters that didn't meet the previous criteria
115 else {
116 result += input[i];
117 i++;
120 return result;
123 this.matchedFiles = [];
125 // Ignore empty searches
126 if (!this._inner_search) {
127 return;
130 // Setup regex flags
131 let regexFlags = "";
132 if (this.matchAllOccurrences) { regexFlags += "g"; }
133 if (!this.caseSensitive) { regexFlags += "i"; }
135 // Setup regex search
136 const regexEscapeExp = new RegExp(/[/\-\\^$*+?.()|[\]{}]/g);
137 const standardSearch = new RegExp(this._inner_search.replace(regexEscapeExp, '\\$&'), regexFlags);
138 let regexSearch;
139 try {
140 regexSearch = new RegExp(this._inner_search, regexFlags);
142 catch (err) {
143 if (this.useRegex) {
144 this.onInvalidRegex(err);
145 return;
148 const search = this.useRegex ? regexSearch : standardSearch;
150 let fileEnumeration = this.fileEnumerationStart;
151 for (let i = 0; i < this.selectedFiles.length; ++i) {
152 const row = this.selectedFiles[i];
154 // Ignore files
155 if (!row.isFolder && !this.includeFiles) {
156 continue;
158 // Ignore folders
159 else if (row.isFolder && !this.includeFolders) {
160 continue;
163 // Get file extension and reappend the "." (only when the file has an extension)
164 let fileExtension = window.qBittorrent.Filesystem.fileExtension(row.original);
165 if (fileExtension) { fileExtension = "." + fileExtension; }
167 const fileNameWithoutExt = row.original.slice(0, row.original.lastIndexOf(fileExtension));
169 let matches = [];
170 let offset = 0;
171 switch (this.appliesTo) {
172 case "FilenameExtension":
173 matches = findMatches(search, `${fileNameWithoutExt}${fileExtension}`);
174 break;
175 case "Filename":
176 matches = findMatches(search, `${fileNameWithoutExt}`);
177 break;
178 case "Extension":
179 // Adjust the offset to ensure we perform the replacement at the extension location
180 offset = fileNameWithoutExt.length;
181 matches = findMatches(search, `${fileExtension}`);
182 break;
184 // Ignore rows without a match
185 if (!matches || matches.length == 0) {
186 continue;
189 let renamed = row.original;
190 for (let i = matches.length - 1; i >= 0; --i) {
191 const match = matches[i];
192 let replacement = this._inner_replacement;
193 // Replace numerical groups
194 for (let g = 0; g < match.length; ++g) {
195 let group = match[g];
196 if (!group) { continue; }
197 replacement = replaceGroup(replacement, `$${g}`, group, '\\', false);
199 // Replace named groups
200 for (let namedGroup in match.groups) {
201 replacement = replaceGroup(replacement, `$${namedGroup}`, match.groups[namedGroup], '\\', false);
203 // Replace auxiliary variables
204 for (let v = 'dddddddd'; v !== ''; v = v.substring(1)) {
205 let fileCount = fileEnumeration.toString().padStart(v.length, '0');
206 replacement = replaceGroup(replacement, `$${v}`, fileCount, '\\', false);
208 // Remove empty $ variable
209 replacement = replaceGroup(replacement, '$', '', '\\');
210 const wholeMatch = match[0];
211 const index = match['index'];
212 renamed = replaceBetween(renamed, index + offset, index + offset + wholeMatch.length, replacement);
215 row.renamed = renamed;
216 ++fileEnumeration;
217 this.matchedFiles.push(row);
221 rename: async function() {
222 if (!this.matchedFiles || this.matchedFiles.length === 0 || !this.hash) {
223 this.onRenamed([]);
224 return;
227 let replaced = [];
228 const _inner_rename = async function(i) {
229 const match = this.matchedFiles[i];
230 const newName = match.renamed;
231 if (newName === match.original) {
232 // Original file name is identical to Renamed
233 return;
236 const isFolder = match.isFolder;
237 const parentPath = window.qBittorrent.Filesystem.folderName(match.path);
238 const oldPath = parentPath
239 ? parentPath + window.qBittorrent.Filesystem.PathSeparator + match.original
240 : match.original;
241 const newPath = parentPath
242 ? parentPath + window.qBittorrent.Filesystem.PathSeparator + newName
243 : newName;
244 let renameRequest = new Request({
245 url: isFolder ? 'api/v2/torrents/renameFolder' : 'api/v2/torrents/renameFile',
246 method: 'post',
247 data: {
248 hash: this.hash,
249 oldPath: oldPath,
250 newPath: newPath
253 try {
254 await renameRequest.send();
255 replaced.push(match);
257 catch (err) {
258 this.onRenameError(err, match);
260 }.bind(this);
262 const replacements = this.matchedFiles.length;
263 if (this.replaceAll) {
264 // matchedFiles are in DFS order so we rename in reverse
265 // in order to prevent unwanted folder creation
266 for (let i = replacements - 1; i >= 0; --i) {
267 await _inner_rename(i);
270 else {
271 // single replacements go linearly top-down because the
272 // file tree gets recreated after every rename
273 await _inner_rename(0);
275 this.onRenamed(replaced);
277 update: function() {
278 this._inner_update();
279 this.onChanged(this.matchedFiles);
283 return exports();
284 })();
286 Object.freeze(window.qBittorrent.MultiRename);