Display External IP Address in status bar
[qBittorrent.git] / src / webui / www / private / scripts / rename-files.js
blob029370a05056d48f0ad2ee40c26dc704560086d8
1 "use strict";
3 window.qBittorrent ??= {};
4 window.qBittorrent.MultiRename ??= (() => {
5 const exports = () => {
6 return {
7 AppliesTo: AppliesTo,
8 RenameFiles: RenameFiles
9 };
12 const AppliesTo = {
13 "FilenameExtension": "FilenameExtension",
14 "Filename": "Filename",
15 "Extension": "Extension"
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);
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);
41 appliesTo: AppliesTo.FilenameExtension,
42 includeFiles: true,
43 includeFolders: false,
44 replaceAll: false,
45 fileEnumerationStart: 0,
47 onChanged: (rows) => {},
48 onInvalidRegex: (err) => {},
49 onRenamed: (rows) => {},
50 onRenameError: (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;
80 const replaceBetween = (input, start, end, replacement) => {
81 return input.substring(0, start) + replacement + input.substring(end);
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;
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;
100 else {
101 result += input.substring(i, i + escape.length + search.length);
102 i += escape.length + search.length;
104 // Check if the current index contains the search string
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
111 else {
112 result += input[i];
113 i++;
116 return result;
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);
139 catch (err) {
140 if (this.useRegex) {
141 this.onInvalidRegex(err);
142 return;
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;
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);
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);
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);
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);
214 row.renamed = renamed;
215 ++fileEnumeration;
216 this.matchedFiles.push(row);
220 rename: async function() {
221 if (!this.matchedFiles || (this.matchedFiles.length === 0) || !this.hash) {
222 this.onRenamed([]);
223 return;
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;
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
252 try {
253 await renameRequest.send();
254 replaced.push(match);
256 catch (err) {
257 this.onRenameError(err, match);
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);
268 else {
269 // single replacements go linearly top-down because the
270 // file tree gets recreated after every rename
271 await _inner_rename(0);
273 this.onRenamed(replaced);
275 update: function() {
276 this._inner_update();
277 this.onChanged(this.matchedFiles);
281 return exports();
282 })();
283 Object.freeze(window.qBittorrent.MultiRename);