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