Rename files and folders recursively based on regex

1.7k views Asked by At

I have this file structure:

FolderName/
    [NAME]/
        [NAME].controller.js
        [NAME].html

Using Node.js I want to replace [NAME] with a variable.

Here is what I've tried:

const shell = require("shelljs");

shell.ls('-Rl', '.').forEach(entry => {
  if (entry.name.includes(`[NAME]`)) {
    let newName = entry.name.replace(/\[NAME\]/, "Test");
    shell.mv(entry.name, newName);
  }
});

This will only rename the folder [NAME] to Test, and leave the files untouched. And output this:

mv: no such file or directory: FolderName/[NAME]/[NAME].controller.js
mv: no such file or directory: FolderName/[NAME]/[NAME].html
1

There are 1 answers

0
RobC On BEST ANSWER

The issue

When running shell.mv(entry.name, newName); within the context of your example its trying to move/change a path that no longer exists as it has been changed in a previous turn of the loop. This results in the error:

mv: no such file or directory: FolderName/[NAME]/[NAME].controller.js

mv: no such file or directory: FolderName/[NAME]/[NAME].html


Solution A

To avoid the error try the following approach:

  1. Utilize the shelljs find command to obtain the paths instead of ls. This will ensure the resulting paths include the base directories.
  2. Iterate each path and filter out any paths whose asset does not contain the string to find (e.g [NAME]. Also exclude any hidden assets (i.e. those names starting with a dot .)
  3. Sort the array of paths by its depth in descending order.
  4. Replace only the last instance of the string to find (e.g [NAME]) with the replacement string (e.g TEST) within each path. Then finally apply the new path using the shelljs mv command.

Note: As a safeguard, (at step 4), the asset/path is only renamed if the new resultant path is not already taken. If the new path DOES already exist then those paths that should not be renamed are reported. For example, To better understand this, lets assume we have an initial directory structured as follows:

Initial directory structure.

.
└── [NAME]
    ├── [NAME].controller.js
    ├── [NAME].html
    └── TEST.html

...If we run the script searching for the string [NAME] to be replaced with the string TEST - then we have a potential problem. If we were to rename [NAME].html to be TEST.html we would override the existing TEST.html. Our resultant directory would be structured as follows:

Potential resultant directory structure.

.
└── TEST
    ├── TEST.controller.js
    └── TEST.html

By only renaming an asset if the new resultant path IS NOT already taken we avoid this potentially harmful scenario of loosing data.

Actual resultant directory structure.

.
└── TEST
    ├── TEST.controller.js
    ├── [NAME].html
    └── TEST.html

When an asset should not be renamed, (as it would result in loss of data), the script currently reports those instances. Given the initial directory structure (above) the following will be logged to your console:

1 path(s) not renamed. Name is already taken:

+ FolderName/TEST/[NAME].js --> FolderName/TEST/TEST.js

The following gist uses the approach described above. The solution is authored in ES5 so it works with older versions of nodejs, however it can be simply revised to use the ES6 syntax.

example node script 1

var shell = require('shelljs');

var ROOT_DIR = './FolderName/'; // <-- Directory to search in relative to cwd.
var FIND_STR = '[NAME]';        // <-- String to find
var REPLACE_STR = 'TEST';       // <-- Replacement string

var issues = [];

// 1. Obtain all paths in the root directory.
shell.find(ROOT_DIR)

  // 2. Exclude:
  //    - hidden files/folders (i.e. assets names starting with a dot)
  //    - Assets (i.e. at the end of the path) that do not contain `FIND_STR`
  .filter(function(_path) {
    var isVisible = _path.split('/').pop().indexOf('.', 0) !== 0,
      assetHasFindStr = _path.split('/').pop().indexOf(FIND_STR) > -1;
    return (assetHasFindStr && isVisible);
  })

  // 3. Sort paths by its depth in descending order.
  .sort(function(a, b) {
    return (b.split('/') || []).length - (a.split('/') || []).length;
  })

  // 4. Replace last instance of string to find with replace string and rename.
  .forEach(function (_path) {
    var firstPart = _path.substring(0, _path.lastIndexOf(FIND_STR)),
      lastPart = _path.substring(_path.lastIndexOf(FIND_STR, _path.length)),
      newPath = firstPart + lastPart.replace(FIND_STR, REPLACE_STR);

    // Only rename if `newPath` is not already taken otherwise log them.
    if (!shell.test('-e', newPath)) {
      shell.mv(_path, newPath);
    } else {
      issues.push(_path + ' --> ' + newPath);
    }
  });

// 5. Log any paths that were not renamed because its name is already taken.
if (issues.length) {
  shell.echo(issues.length + ' path(s) not renamed. Name is already taken:');
  issues.forEach(function(issue) {
    shell.echo('+ ' + issue);
  });
}

Solution B

Your requirement can also be achieved by installing and utilizing renamer.

$ npm i -D renamer

Then use shelljs to invoke the renamer command.


example node script 2

const shell = require("shelljs");
shell.exec('node_modules/.bin/renamer --find \"[NAME]\" --replace \"TEST\" \"FolderName/**\"', { silent:true });

example node script 3

If you need something a little more terse, (although it incurs an additional dependency), you could utilize shelljs-nodecli:

$ npm i -D shelljs-nodecli

Then invoke the renamercommand as shown below:

const nodeCLI = require("shelljs-nodecli");
nodeCLI.exec('renamer', '--find \"[NAME]\" --replace \"TEST\" \"FolderName/**\"', { silent:true });

Note, using shelljs-nodecli you avoid manually looking into the node_modules directory to find the binary renamer file. I.e.

shell.exec('node_modules/.bin/renamer ...

becomes...

nodeCLI.exec('renamer' ...