Bulk file editing with ranger

Translations: Português (pt-BR)
Publication date: Oct 20, 2020
Tags: ranger, python

My file manager of choice is ranger. It's terminal-based, provides keybind mapping for everything making me more efficient in navigating my files, and it's incredibly extensible by enabling the creation of custom commands in python. If that wasn't enough, it also has a ton of other great features (extensible file preview, tabs, tags, ...). Go check out their github page, seriously.

One very useful feature of ranger is the bulkrename command. It allows you to open an editor with the filenames of all selected files and edit them. After saving, a shell script is generated to make the necessary renaming (and you're given the chance to review it), and just like magic *poof* you just renamed a bunch of files simultaneously from the convenience of your favorite text editor.

Here's an example:

Ranger's bulkrename command being used to rename three files in one go

At asciinema.org

Pretty convenient. But when you think about it, it is rather limited. Why only allow file renaming? This same workflow could be used to edit any file attribute. One just needs to provide a way to obtain the attribute for each selected file and to provide the shell script command that changes the attribute to be what the user changed it to.

The bulk command

I recently wanted to modify the Artist ID3 tag of multiple mp3 files at once which motivated me to write the generic version of ranger's bulkrename command: bulk.

For that I basically copied the code for bulkrename and generalized the file attribute retrieval and the attribute change command generation, by calling the get_attribute() and get_change_attribute_cmd(), respectively, from a bulk subcommand stored in the bulk dictionary.

The bulk command class is as follows:

class bulk(Command):
    """:bulk <attribute>

    This command opens a list with the attribute <attribute> for each of the
    selected files in an external editor.
    After you edit and save the file, it will generate a shell script
    which changes the attributes in bulk according to the changes you did in the
    file.

    This shell script is opened in an editor for you to review.
    After you close it, it will be executed.
    """


    bulk = {}

    def execute(self):  # pylint: disable=too-many-locals,too-many-statements
        import sys
        import tempfile
        from ranger.container.file import File
        from ranger.ext.shell_escape import shell_escape as esc
        py3 = sys.version_info[0] >= 3

        # get bulk command argument
        bkname = self.rest(1)

        # Create and edit the file list
        files = [f for f in self.fm.thistab.get_selection()]
        attributes = [self.bulk[bkname].get_attribute(f) for f in files]
        listfile = tempfile.NamedTemporaryFile(delete=False)
        listpath = listfile.name

        if py3:
            listfile.write("\n".join(attributes).encode("utf-8"))
        else:
            listfile.write("\n".join(attributes))
        listfile.close()
        self.fm.execute_file([File(listpath)], app='editor')
        listfile = open(listpath, 'r')
        new_attributes = listfile.read().split("\n")
        listfile.close()
        os.unlink(listpath)
        if all(a == b for a, b in zip(attributes, new_attributes)):
            self.fm.notify("Nothing to be done!")
            return
        print(new_attributes)

        # Generate script
        cmdfile = tempfile.NamedTemporaryFile()
        script_lines = []
        script_lines.append("# This file will be executed when you close the editor.\n")
        script_lines.append("# Please double-check everything, clear the file to abort.\n")
        script_lines.extend("%s\n" % self.bulk[bkname].get_change_attribute_cmd(file, old, new)
                            for old, new, file in
                            zip(attributes, new_attributes, files) if old != new)
        script_content = "".join(script_lines)
        if py3:
            cmdfile.write(script_content.encode("utf-8"))
        else:
            cmdfile.write(script_content)
        cmdfile.flush()

        # Open the script and let the user review it
        self.fm.execute_file([File(cmdfile.name)], app='editor')
        cmdfile.seek(0)

        # Do the attribute changing
        self.fm.run(['/bin/sh', cmdfile.name], flags='w')
        cmdfile.close()

Then, inside this class, I added a class for each of the bulk subcommands that I wanted to add: id3art, id3tit and id3alb, which change the ID3 tag for the Artist, Title and Album, respectively.

For each one of those, I implemented the get_attribute() and get_change_attribute_cmd() methods. get_attribute() receives a ranger's file object and should return a string with the attribute, in the case of id3art, the ID3 Artist tag, which I used the eyed3 python module to get. get_change_attribute_cmd() receives a ranger's file object, the old attribute (the one returned by get_attribute()) and the new one (the value edited by the user), and should return a string containing the shell script command to apply the change made by the user (in the case of id3art, eyeD3 -a NewArtist fileName.mp3).

Finally, I also added an entry for each of those subcommands to the bulk dictionary, which maps the subcommand name to its object.

Translating all of this to code, this is what I added inside the bulk class:

class id3art(object):

    def get_attribute(self, file):
        import eyed3
        artist = eyed3.load(file.relative_path).tag.artist
        return str(artist) if artist else ''


    def get_change_attribute_cmd(self, file, old, new):
        from ranger.ext.shell_escape import shell_escape as esc
        return "eyeD3 -a %s %s" % (esc(new), esc(file))

class id3tit(object):

    def get_attribute(self, file):
        import eyed3
        title = eyed3.load(file.relative_path).tag.title
        return str(title) if title else ''


    def get_change_attribute_cmd(self, file, old, new):
        from ranger.ext.shell_escape import shell_escape as esc
        return "eyeD3 -t %s %s" % (esc(new), esc(file))

class id3alb(object):

    def get_attribute(self, file):
        import eyed3
        album = eyed3.load(file.relative_path).tag.album
        return str(album) if album else ''


    def get_change_attribute_cmd(self, file, old, new):
        from ranger.ext.shell_escape import shell_escape as esc
        return "eyeD3 -A %s %s" % (esc(new), esc(file))

bulk = {'id3art': id3art(),
        'id3tit': id3tit(),
        'id3alb': id3alb(),
        }

Here it is in action:

The id3art bulk subcommand being used to change the ID3 artist tag of three mp3 files in one go

At asciinema.org

Since I thought this generic framework for the bulk command could be useful to everyone using ranger, with each user implementing their own bulk subcommand, I opened a pull request. It seems the idea was well received, and they even intend to make bulkrename simply an alias to a bulk subcommand, so maybe in the near future you will be able to make your own bulk subcommands using the built-in bulk command 🙂.