3
\$\begingroup\$

From all the files in a directory, the method shiftFilenamesOffset is to increment the numeric part (int) of the alphanumeric filename by offset each time, and rename the files. A negative offset is also allowed.

Example:

offset: -2
Old filename:                  New filename:
Textdokument (neu) 3.txt    -> Textdokument (neu) 1.txt
Textdokument (neu) 5.4.1txt -> Textdokument (neu) 5.2.1txt
Textdokument (neu) 5.txt    -> Textdokument (neu) 3.txt
Textdokument (neu) 6.txt    -> Textdokument (neu) 4.txt
import java.io.File;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class NFiles {
    record Filename(File file, String prefix, int number, String ending) {
    }

    private static final String regex = "(.*)(\\d+)\\.([^.]*)";
    private static final Pattern pattern = Pattern.compile(regex);

    public static void shiftFilenamesOffset(final File dir, final int offset) {
        Comparator<Filename> filenameComparator = offset <= 0 ? Comparator.comparingInt(Filename::number) : Comparator.comparingInt(Filename::number)
                .reversed();
        Arrays.stream(Objects.requireNonNull(dir.listFiles()))
                .filter(f -> pattern.matcher(f.getName()).find())
                .map(f -> {
                    Matcher mat = pattern.matcher(f.getName());
                    mat.find();
                    return new Filename(f, mat.group(1), Integer.parseInt(mat.group(2)), mat.group(3));
                })
                .sorted(filenameComparator)
                .forEach(fn -> {
                    File nf = new File(fn.file().getParent(), fn.prefix() + (fn.number() + offset) + "." + fn.ending());
                    System.out.println(fn + " -> " + nf + " -> " + fn.file().renameTo(nf));
                });
    }

    public static void main(final String[] args) {
        shiftFilenamesOffset(new File("test1"), -3);
    }
}
\$\endgroup\$

2 Answers 2

5
\$\begingroup\$

Reusing function

You can create a constant with Comparator function and reuse it in method.

private static final Comparator<Filename> comparator = Comparator.comparingInt(Filename::number);
    enter code here

public static void shiftFilenamesOffset(final File dir, final int offset) {
        Comparator<Filename> filenameComparator = offset <= 0 ? comparator : comparator.reversed();
    -----
    -----
        }

Duplicate computation

pattern.matcher(f.getName()).find() is done twice and can be done once. Something like this

Arrays.stream(Objects.requireNonNull(dir.listFiles()))
                .map(f -> {
                    Matcher mat = pattern.matcher(f.getName());
                    return mat.find() ? new Filename(f, mat.group(1), Integer.parseInt(mat.group(2)), mat.group(3)) : null;
                })
                .filter(Objects::nonNull)

Invariants Validation

You can add Invariants/Input validation at the beginning of method. Also you've put non null check on dir.listFiles() which is good. Objects.requireNonNull(dir.listFiles())

you can add further validation if dir or offset are valid or not in the beginning.

Minor Comment

  • Rename your constants to match the regular expression '^[A-Z][A-Z0-9](_[A-Z0-9]+)$' meaning that they should be uppercase separated by underscore generally.

Good Points In your code

  • Used latest Java Features like records.
  • Used fluent APIs and streams to iterate over directory structure and using comparator.
\$\endgroup\$
1
  • 1
    \$\begingroup\$ Many thanks for your review @SRJ. I think, the most important points are to check/validate the input parameters, and that there is a duplicate computation, which can be avoided using null return values... \$\endgroup\$ Commented Sep 25, 2023 at 21:27
1
\$\begingroup\$

Prefer regex definitions that have comments and named groups.

It's not very helpful to mark function parameters as final. File and int are already immutable, and even if they weren't, it doesn't particularly matter if those are marked final because that only prevents referential mutation, not value mutation.

Don't force a match, increment and rename all in one go. Split them out for the purposes of unit testing and dryrun support. Java-like code would represent this as a renaming tool object.

Let the caller decide whether to sort or not, for them to print or test. Only enforce sort (which breaks Stream laziness) on rename.

Prefer Path manipulation from nio.

Fail early. Don't wait until the directory list to check for nulls; instead check that the provided path is actually a directory so that no null check is needed. Also: using the Files.list method, the insane return-null behaviour has been replaced with an exception.

You've missed a character in your regex for the negative sign in an ID.

Since the ordering depends on offset, I propose a simpler natural ordering scheme using a nested class and Comparable<>.

Suggested

package com.stackexchange.filename_offset;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;


/**
 * A class to rename a collection of files in a given directory by adding a given offset.
 * The natural ordering of the nested Filename class depends on the offset's sign.
 */
public class RenameTool {
    public final Path dir;
    public final int offset;

    public RenameTool(Path dir, int offset) {
        if (!Files.isDirectory(dir))
            throw new IllegalArgumentException("Directory must exist");
        this.dir = dir;
        this.offset = offset;
    }

    // Only a class and not a record because this is non-static (to access offset).
    public class Filename implements Comparable<Filename> {
        public final Path file;
        private final Matcher matcher;
        public final boolean matches;
        public final Integer id;

        private static final Pattern pattern = Pattern.compile(
            """
            (?x)      # Verbose
            (?<stem>
                .*?   # Filename stem, lazy
            )
            (?<id>
               -?     # Optional negative
               \\d+   # Digits to be incremented, greedy
            )
            (?<ext>
                \\.     # Literal dot before extension
                [^.]*?  # Extension (non-dot), lazy
            )
            """
        );

        public Filename(Path file) {
            this.file = file;
            matcher = pattern.matcher(file.toString());
            matches = matcher.matches();
            id = matches? Integer.parseInt(matcher.group("id")): null;
        }

        public boolean isMatch() { return matches; }

        public Path increment() {
            String sub = "${stem}%d${ext}".formatted(id + offset);
            return Path.of(matcher.replaceFirst(sub));
        }

        public void rename() throws IOException {
            Files.move(file, increment());
        }

        @Override
        public int compareTo(Filename other) {
            int c = Integer.compare(id, other.id);
            return offset > 0? -c: c;
        }
    }

    /**
     * @return A stream of filenames that match the ID pattern; this cannot be used
     * for renaming because it stays lazy and does not include a deconflict sort.
     * @throws IOException If Files.list() fails.
     */
    public Stream<Filename> matchingFiles() throws IOException {
        return Files.list(dir)
            .map(Filename::new)
            .filter(Filename::isMatch);
    }

    /**
     * @return A list of Filenames sorted in rename order.
     * This materializes to a list because Stream::sorted materializes internally anyway.
     * @throws IOException If Files.list() fails.
     */
    public List<Filename> sortedFiles() throws IOException {
        List<Filename> names = matchingFiles().collect(Collectors.toList());
        Collections.sort(names);
        return names;
    }

    public void rename(int offset) throws IOException {
        for (Filename name: sortedFiles())
            name.rename();
    }
}
\$\endgroup\$
4
  • \$\begingroup\$ Is there a tool to create regex definitions that have comments and named groups. :) ? \$\endgroup\$
    – SRJ
    Commented Sep 27, 2023 at 9:33
  • \$\begingroup\$ "Don't force a match, increment and rename all in one go." ... You can't rename one file (for example "5.txt" to "1.txt") when there is already a file with the target name ("1.txt"), without sorting the files first. \$\endgroup\$ Commented Sep 27, 2023 at 10:25
  • 1
    \$\begingroup\$ @Tobias321If that was your reason for sorting, it isn't enough to prevent filename clashes. Think: you allow negative offsets but don't match on hyphens in the ID. \$\endgroup\$
    – Reinderien
    Commented Sep 27, 2023 at 12:35
  • \$\begingroup\$ @SRJ The tool is.. your fingers? Your keyboard? I don't understand the question. \$\endgroup\$
    – Reinderien
    Commented Sep 27, 2023 at 12:37

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.