AbstractImageRenamer.java

/*
 * Copyright 2011-2026, Aiki IT, FotoRenamer
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package de.aikiit.fotorenamer.image;

import de.aikiit.fotorenamer.exception.InvalidDirectoryException;
import de.aikiit.fotorenamer.exception.NoFilesFoundException;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CancellationException;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import java.util.stream.Stream;

/** Shared, UI-independent NIO image discovery and renaming. */
abstract class AbstractImageRenamer implements ImageRenamer {

    private static final Logger LOG = LogManager.getLogger(AbstractImageRenamer.class);
    private final List<Path> imageList;

    /**
     * Finds image candidates without starting a worker or creating GUI components.
     *
     * @param directory Directory containing the images.
     * @throws InvalidDirectoryException If the directory cannot be opened or enumerated.
     * @throws NoFilesFoundException If the directory contains no detected images.
     */
    AbstractImageRenamer(final String directory) throws InvalidDirectoryException, NoFilesFoundException {
        if (directory == null) {
            throw new InvalidDirectoryException("null is not a directory");
        }
        checkInterrupted();
        Path currentDirectory;
        try {
            currentDirectory = Path.of(directory).toAbsolutePath();
            try (Stream<Path> entries = Files.list(currentDirectory)) {
                this.imageList = entries.peek(file -> checkInterrupted())
                        .filter(new ImageContentFilter()).toList();
            }
        } catch (IOException | UncheckedIOException | IllegalArgumentException | SecurityException e) {
            throw new InvalidDirectoryException(directory, e);
        }
        if (this.imageList.isEmpty()) {
            throw new NoFilesFoundException(currentDirectory);
        }
    }

    /**
     * Processes a snapshot of detected candidates. Callbacks run on the calling thread.
     * Observer failures are logged separately and do not change file outcome counts.
     * Cancellation is cooperative: already completed moves are preserved.
     */
    @Override
    public final RenameResult renameFiles(final Consumer<RenameProgress> onProgress,
                                          final BooleanSupplier cancellationRequested) {
        Objects.requireNonNull(onProgress, "onProgress");
        Objects.requireNonNull(cancellationRequested, "cancellationRequested");
        int total = this.imageList.size();
        int processed = 0;
        int renamed = 0;
        int skipped = 0;
        int failed = 0;
        int withoutExifDate = 0;
        LOG.info("Starting to process {} image files.", total);
        for (Path source : this.imageList) {
            if (Thread.currentThread().isInterrupted() || cancellationRequested.getAsBoolean()) {
                return new RenameResult(total, processed, renamed, skipped, failed, withoutExifDate, true);
            }
            Path target = source;
            try {
                if (!Files.readAttributes(source, BasicFileAttributes.class).isRegularFile()) {
                    LOG.info("Skipping '{}': no longer a regular file", source);
                    skipped++;
                } else {
                    RenameDecision decision = determineRename(source);
                    String targetFilename = decision.filename();
                    if (targetFilename == null || targetFilename.isEmpty()
                            || targetFilename.equals(fileName(source))) {
                        skipped++;
                        if (decision.withoutExifDate()) {
                            withoutExifDate++;
                        }
                    } else {
                        target = source.resolveSibling(targetFilename);
                        // No REPLACE_EXISTING or ATOMIC_MOVE: preserve existing targets.
                        Files.move(source, target);
                        renamed++;
                    }
                }
            } catch (CancellationException e) {
                LOG.info("Processing cancelled at '{}'", source, e);
                return new RenameResult(total, processed, renamed, skipped, failed, withoutExifDate, true);
            } catch (IOException | RuntimeException e) {
                LOG.error("Unable to process '{}' with target '{}'", source, target, e);
                if (Thread.currentThread().isInterrupted()) {
                    return new RenameResult(total, processed, renamed, skipped, failed, withoutExifDate, true);
                }
                failed++;
            }
            processed++;
            RenameResult snapshot = new RenameResult(total, processed, renamed, skipped, failed, withoutExifDate, false);
            try {
                onProgress.accept(new RenameProgress(source, snapshot));
            } catch (RuntimeException e) {
                LOG.warn("Progress observer failed for '{}'", source, e);
            }
        }
        return new RenameResult(total, processed, renamed, skipped, failed, withoutExifDate, false);
    }

    /** Determines the filename and, where applicable, the EXIF skip reason. */
    protected RenameDecision determineRename(final Path imageFile) throws IOException {
        return new RenameDecision(renameImage(imageFile), false);
    }

    /** A per-file decision, avoiding mutable state between files or batch runs. */
    protected record RenameDecision(String filename, boolean withoutExifDate) { }

    /** Determines a filename; return the original name or an empty name to skip. */
    protected abstract String renameImage(Path imageFile) throws IOException;

    /** Returns the final path component, or the path itself for a filesystem root. */
    protected static String fileName(final Path path) {
        Path name = path.getFileName();
        return name == null ? path.toString() : name.toString();
    }

    private static void checkInterrupted() {
        if (Thread.currentThread().isInterrupted()) {
            throw new CancellationException("Image discovery interrupted");
        }
    }
}