de.phillme.PhotoSorter.java Source code

Java tutorial

Introduction

Here is the source code for de.phillme.PhotoSorter.java

Source

/*
 *     This file is part of photosorter.
 *
 *     photosorter is free software: you can redistribute it and/or modify
 *     it under the terms of the GNU General Public License as published by
 *     the Free Software Foundation, either version 3 of the License, or
 *     (at your option) any later version.
 *
 *     photosorter is distributed in the hope that it will be useful,
 *     but WITHOUT ANY WARRANTY; without even the implied warranty of
 *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *     GNU General Public License for more details.
 *
 *     You should have received a copy of the GNU General Public License
 *     along with Foobar.  If not, see <http://www.gnu.org/licenses/>.
 *
 *     Diese Datei ist Teil von photosorter.
 *
 *     photosorter ist Freie Software: Sie knnen es unter den Bedingungen
 *     der GNU General Public License, wie von der Free Software Foundation,
 *     Version 3 der Lizenz oder (nach Ihrer Wahl) jeder spteren
 *     verffentlichten Version, weiterverbreiten und/oder modifizieren.
 *
 *     photosorter wird in der Hoffnung, dass es ntzlich sein wird, aber
 *     OHNE JEDE GEWHRLEISTUNG, bereitgestellt; sogar ohne die implizite
 *     Gewhrleistung der MARKTFHIGKEIT oder EIGNUNG FR EINEN BESTIMMTEN ZWECK.
 *     Siehe die GNU General Public License fr weitere Details.
 *
 *     Sie sollten eine Kopie der GNU General Public License zusammen mit diesem
 *     Programm erhalten haben. Wenn nicht, siehe <http://www.gnu.org/licenses/>.
 */

/*
 *     This file is part of photosorter.
 *
 *     photosorter is free software: you can redistribute it and/or modify
 *     it under the terms of the GNU General Public License as published by
 *     the Free Software Foundation, either version 3 of the License, or
 *     (at your option) any later version.
 *
 *     photosorter is distributed in the hope that it will be useful,
 *     but WITHOUT ANY WARRANTY; without even the implied warranty of
 *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *     GNU General Public License for more details.
 *
 *     You should have received a copy of the GNU General Public License
 *     along with Foobar.  If not, see <http://www.gnu.org/licenses/>.
 *
 *     Diese Datei ist Teil von photosorter.
 *
 *     photosorter ist Freie Software: Sie knnen es unter den Bedingungen
 *     der GNU General Public License, wie von der Free Software Foundation,
 *     Version 3 der Lizenz oder (nach Ihrer Wahl) jeder spteren
 *     verffentlichten Version, weiterverbreiten und/oder modifizieren.
 *
 *     photosorter wird in der Hoffnung, dass es ntzlich sein wird, aber
 *     OHNE JEDE GEWHRLEISTUNG, bereitgestellt; sogar ohne die implizite
 *     Gewhrleistung der MARKTFHIGKEIT oder EIGNUNG FR EINEN BESTIMMTEN ZWECK.
 *     Siehe die GNU General Public License fr weitere Details.
 *
 *     Sie sollten eine Kopie der GNU General Public License zusammen mit diesem
 *     Programm erhalten haben. Wenn nicht, siehe <http://www.gnu.org/licenses/>.
 */

/*
 *     This file is part of photosorter.
 *
 *     photosorter is free software: you can redistribute it and/or modify
 *     it under the terms of the GNU General Public License as published by
 *     the Free Software Foundation, either version 3 of the License, or
 *     (at your option) any later version.
 *
 *     photosorter is distributed in the hope that it will be useful,
 *     but WITHOUT ANY WARRANTY; without even the implied warranty of
 *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *     GNU General Public License for more details.
 *
 *     You should have received a copy of the GNU General Public License
 *     along with Foobar.  If not, see <http://www.gnu.org/licenses/>.
 *
 *     Diese Datei ist Teil von photosorter.
 *
 *     photosorter ist Freie Software: Sie knnen es unter den Bedingungen
 *     der GNU General Public License, wie von der Free Software Foundation,
 *     Version 3 der Lizenz oder (nach Ihrer Wahl) jeder spteren
 *     verffentlichten Version, weiterverbreiten und/oder modifizieren.
 *
 *     photosorter wird in der Hoffnung, dass es ntzlich sein wird, aber
 *     OHNE JEDE GEWHRLEISTUNG, bereitgestellt; sogar ohne die implizite
 *     Gewhrleistung der MARKTFHIGKEIT oder EIGNUNG FR EINEN BESTIMMTEN ZWECK.
 *     Siehe die GNU General Public License fr weitere Details.
 *
 *     Sie sollten eine Kopie der GNU General Public License zusammen mit diesem
 *     Programm erhalten haben. Wenn nicht, siehe <http://www.gnu.org/licenses/>.
 */

package de.phillme;

import com.drew.imaging.ImageMetadataReader;
import com.drew.imaging.ImageProcessingException;
import com.drew.metadata.Metadata;
import com.drew.metadata.exif.ExifSubIFDDirectory;
import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.CommandLineParser;
import org.apache.commons.cli.DefaultParser;
import org.apache.commons.cli.HelpFormatter;
import org.apache.tika.Tika;

import java.io.File;
import java.io.IOException;
import java.nio.file.*;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.logging.ConsoleHandler;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.logging.SimpleFormatter;

class PhotoSorter {

    private boolean moveInsteadCopy = false;
    private String actionName = "copy";
    private boolean noRename = false;
    private TimeZone timeZone;
    private Date dateOld;
    private Date eventStartDate;
    private Date eventEndDate;

    private int hoursBetweenEvents = 36;

    private String eventFileSuffix = "";

    private String dateFormatPhotos = "";
    private String dateFormatFolders = "";
    private int photoNumberInEvent = 1;

    //private String splitBetweenDateAndRest = "";

    private Path photosPath = null;

    private boolean write = false;

    private List<PhotoEvent> eventList = new ArrayList<>();

    //TODO clean up and refactor make this more understandeable and mainteneable at some time
    private final static Logger LOGGER = Logger.getLogger(PhotoSorter.class.getName());
    private final Tika tika = new Tika();

    private PhotoSorter(CommandLine commandLine) {
        initLogging();

        this.hoursBetweenEvents = Integer.parseInt(commandLine.getOptionValue("minhours", "36"));
        this.dateFormatPhotos = commandLine.getOptionValue("dfp", "yyyy-MM-dd'T'HHmm");
        this.dateFormatFolders = commandLine.getOptionValue("dfe", "yyyy-MM-dd");

        //this.splitBetweenDateAndRest = commandLine.getOptionValue("dsplitchar", "_");
        this.photosPath = Paths.get(commandLine.getOptionValue("p", "."));
        this.timeZone = TimeZone
                .getTimeZone(commandLine.getOptionValue("timezone", Calendar.getInstance().getTimeZone().getID()));

        this.noRename = commandLine.hasOption("n");
        this.moveInsteadCopy = commandLine.hasOption("mv");

        if (this.moveInsteadCopy) {
            this.actionName = "move";
        }

        LOGGER.info(
                "\r\n  _____  _           _        _____            _            \r\n |  __ \\| |         | |      / ____|          | |           \r\n | |__) | |__   ___ | |_ ___| (___   ___  _ __| |_ ___ _ __ \r\n |  ___/| '_ \\ / _ \\| __/ _ \\\\___ \\ / _ \\| '__| __/ _ \\ '__|\r\n | |    | | | | (_) | || (_) |___) | (_) | |  | ||  __/ |   \r\n |_|    |_| |_|\\___/ \\__\\___/_____/ \\___/|_|   \\__\\___|_|   ");

        if (commandLine.hasOption("w")) {
            this.write = true;
            LOGGER.info("'\n-w' specified. Changes are written...");
        } else {
            LOGGER.info(
                    "\nPhotoSorter starting in dry-run mode. No changes written. \nIf you want to write changes add '-w' as an option.\n\nUse '-h' for help.");
        }

        LOGGER.info("Using timezone " + timeZone.getID() + ".");
        LOGGER.info("Using \"" + this.dateFormatFolders + "\" as a date format for folders.");

        if (this.noRename) {
            LOGGER.info("No-rename set. Photos will NOT be renamed.");
        } else {
            LOGGER.info(
                    "Using \"" + this.dateFormatPhotos + "\" as a date format for photos. Photos will be renamed.");
        }
        if (this.moveInsteadCopy) {
            LOGGER.info("Moving files instead of copying.");
        } else {
            LOGGER.info("Copying files. Add '-mv' to move files.");
        }
        LOGGER.info("");

    }

    private void initLogging() {
        System.setProperty("java.util.logging.SimpleFormatter.format", "%5$s %n");
        LOGGER.setLevel(Level.INFO);
        ConsoleHandler handler = new ConsoleHandler();
        handler.setFormatter(new SimpleFormatter());
        handler.setLevel(Level.INFO);
        LOGGER.addHandler(handler);
        LOGGER.setUseParentHandlers(false);
    }

    private Date getDateFromExif(Path photo) throws ImageProcessingException, IOException {
        Metadata metadata = ImageMetadataReader.readMetadata(photo.toFile());

        // obtain the Exif directory
        ExifSubIFDDirectory edirectory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class);
        // query the tag's value
        Date date = edirectory.getDate(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL, this.timeZone);

        /*for (Directory directory : metadata.getDirectories()) {
        for (Tag tag : directory.getTags()) {
            System.out.format("[%s] - %s = %s\n",
                    directory.getName(), tag.getTagName(), tag.getDescription());
        }
        if (directory.hasErrors()) {
            for (String error : directory.getErrors()) {
                System.err.format("ERROR: %s", error);
            }
        }
        }*/

        return date;
    }

    private void parseFile(PhotoFile photoFile, boolean lastFile) throws IOException {

        detectNewEvent(photoFile, this.dateOld, photoFile.getPhotoDate(), lastFile);
        this.dateOld = photoFile.getPhotoDate();

        //LOGGER.finest(photoFile.getFilePath().toString());
    }

    /* private Date parseDateFromFileName(String fileName) throws ParseException {
    String string = "2015-01-05T2254_06470.arw";
    String[] split = fileName.split(this.splitBetweenDateAndRest);
        
    DateFormat format = new SimpleDateFormat(this.dateFormatPhotos);
    Date date = format.parse(split[0]);
    LOGGER.finest("DATE " + date.toString());
        
    return date;
     }     */

    private String generateNewFileName(PhotoFile photoFile) {
        if (photoFile.getPhotoDate() != null) {

            SimpleDateFormat sdf = new SimpleDateFormat(this.dateFormatPhotos);
            sdf.setTimeZone(this.timeZone);

            String newFileName = sdf.format(photoFile.getPhotoDate()) + "_" + this.photoNumberInEvent;
            String fileExt = getFileExt(photoFile.getFilePath().getFileName().toString());

            if (fileExt != null) {
                newFileName = newFileName + "." + fileExt;
                return newFileName;
            }
        }

        return null;
    }

    private String getFileBase(String fileName) {
        String[] tokens = fileName.split("\\.(?=[^\\.]+$)");
        //LOGGER.finest(Arrays.toString(tokens));
        if (tokens.length > 0) {
            String fileBase = "";
            for (int i = 0; i < (tokens.length - 1); i++) {
                fileBase += tokens[i];
            }
            return fileBase;

        } else {
            return null;
        }
    }

    private String getFileExt(String fileName) {
        String[] tokens = fileName.split("\\.(?=[^\\.]+$)");
        //LOGGER.info(Arrays.toString(tokens));
        if (tokens.length > 0) {
            String fileExt = "";
            fileExt += tokens[tokens.length - 1];
            LOGGER.finest("File ext is " + fileExt);
            return fileExt;

        } else {
            return null;
        }
    }

    private void moveRelevantFiles(String targetParent, PhotoFile photoFile) throws IOException {
        //TODO is this all safe?
        List<String> list = photoFile.getSupportedMetaDataFileExtensions();
        //Move original file
        String fileName = photoFile.getFilePath().getFileName().toString();

        if (!this.noRename) {
            //use the date provided for the photo files as a new name
            String newFileName = generateNewFileName(photoFile);
            if (newFileName != null) {
                fileName = newFileName;
            }
        }
        Path targetPath = Paths.get(targetParent + File.separator + fileName);

        if (targetPath != null) {
            if (this.write) {
                LOGGER.info(this.actionName + "-ing to " + targetPath);

                if (this.moveInsteadCopy) {
                    Files.move((photoFile.getFilePath()), targetPath);
                } else {
                    Files.copy((photoFile.getFilePath()), targetPath);
                }
            } else {
                LOGGER.info("Would " + this.actionName + " to " + targetPath);
            }

            //Move metadata files
            for (String ext : list) {
                /* This does not work as additional sidecar files usually include the full file name and the sidecar extension (e.g. filename.arw.xmp and not only filename.xmp).
                String tmpFileBase = getFileBase(photoFile.getFilePath().getFileName().toString());
                 */
                String tmpFileBase = photoFile.getFilePath().getFileName().toString();

                if (fileName != null && tmpFileBase != null) {
                    File movableFile = new File(photoFile.getFilePath().getParent().toString() + File.separator
                            + tmpFileBase + "." + ext);
                    if (movableFile.exists()) {
                        targetPath = Paths.get(targetParent + File.separator + fileName + "." + ext);
                        if (this.write) {
                            LOGGER.info(this.actionName + "-ing meta file to " + targetPath);

                            if (this.moveInsteadCopy) {
                                Files.move((movableFile.toPath()), targetPath);
                            } else {
                                Files.copy((movableFile.toPath()), targetPath);
                            }
                        } else {
                            LOGGER.info("Would " + this.actionName + " meta file to " + targetPath);
                        }
                    }
                } else {
                    LOGGER.info("Filebase of " + photoFile.getFilePath().getFileName().toString()
                            + " could not be determined. Skipping...");
                }
            }
        }

    }

    private void movePhotoToEvent(PhotoFile photoFile, Date eventStartDate) throws IOException {
        SimpleDateFormat sdfEurope = new SimpleDateFormat(this.dateFormatFolders);
        sdfEurope.setTimeZone(this.timeZone);
        String sDateinEurope = sdfEurope.format(eventStartDate) + this.eventFileSuffix;

        String eventPath = this.photosPath + File.separator + sDateinEurope;
        File eventFile = new File(eventPath);

        if (!eventFile.exists() && this.write) {
            boolean success = (new File(eventPath)).mkdirs();
        }

        moveRelevantFiles(eventPath, photoFile);

    }

    private void detectNewEvent(PhotoFile photoFile, Date dateOld, Date dateNew, boolean lastFile)
            throws IOException {
        if (dateOld == null) {
            LOGGER.finest("\nBeginning... Using DateNew for first event");
            this.eventStartDate = dateNew;
            this.dateOld = dateNew;

            movePhotoToEvent(photoFile, this.eventStartDate);
            return;
        }
        long dateDiffInHours = PhotoSorter.getDateDiff(dateOld, dateNew, TimeUnit.HOURS);

        if (dateDiffInHours > hoursBetweenEvents) {
            LOGGER.info("");
            LOGGER.finest("Open event will be closed. HoursbetweenEvents exceeded. Range: \n  " + dateOld
                    + " to \n  " + dateNew);
            this.eventEndDate = dateOld;
            //TODO this handles only the event before the last event. for the last event this has to be repeated and file has to be moved
            this.eventList.add(handleEvent());

            this.eventStartDate = dateNew;

            this.photoNumberInEvent++;

            if (lastFile) {
                //this is the last file. This means the last file's time range triggered an end of another event
                //but should be an event by itself in the event list. This is what we do here
                LOGGER.info("Last file leads to an additional event.");

                this.eventEndDate = dateNew;
                this.eventList.add(handleEvent());
            }
            //after the new startDate for the event is set we can move the file to the new event folder
            movePhotoToEvent(photoFile, this.eventStartDate);
        } else {
            //increase photo number for filenames;
            this.photoNumberInEvent++;
            //move all files to current event
            movePhotoToEvent(photoFile, this.eventStartDate);

            if (lastFile) {
                //if this is the lastfile but has not enough time between, still add it
                LOGGER.finest("Found an event between \n  " + dateOld + " and \n  " + dateNew);
                this.eventEndDate = dateNew;
                this.eventList.add(handleEvent());
            }
        }
    }

    private PhotoEvent handleEvent() {
        SimpleDateFormat sdfEurope = new SimpleDateFormat(this.dateFormatFolders);
        sdfEurope.setTimeZone(this.timeZone);
        String sDateinEurope = sdfEurope.format(this.eventStartDate) + this.eventFileSuffix;
        LOGGER.finest(sDateinEurope + " would be created as an event folder.\n");
        //File eventFile = new File(this.photosPath + File.separator + sDateinEurope);

        //eventFile.createNewFile();

        return new PhotoEvent(this.eventStartDate, this.eventEndDate);
    }

    private void printEventList(List<PhotoEvent> photoEventList) {
        LOGGER.info("\nPrinting list of detected events...");
        int i = 1;
        for (PhotoEvent pEvent : photoEventList) {
            LOGGER.info("Event " + i + " " + pEvent.toString());
            i++;
        }
    }

    private List<PhotoFile> listSourceFiles() throws IOException, ImageProcessingException {
        List<PhotoFile> result = new ArrayList<>();
        Date date;

        try (DirectoryStream<Path> stream = Files.newDirectoryStream(this.photosPath, "*.*")) {
            for (Path entry : stream) {
                date = null;
                PhotoFile photoFile;

                String fileType = detectMimeType(entry);
                //String fileExt = getFileExt(entry.getFileName().toString());

                if (fileType != null && fileType.contains("image")) {

                    date = getDateFromExif(entry);
                }
                if (date != null) {
                    photoFile = new PhotoFile(entry, date);

                    result.add(photoFile);
                } else {
                    LOGGER.info("Date of " + entry.getFileName()
                            + " could not be determined. Skipping for image processing...");
                }
            }
        } catch (DirectoryIteratorException ex) {
            // I/O error encounted during the iteration, the cause is an IOException
            throw ex.getCause();
        }
        return result;
    }

    /*Map<String, String> probeFiletypes() throws IOException, ImageProcessingException, ParseException, TikaException, SAXException {
    Map<String, String> result = new HashMap<>();
        
    LOGGER.info("Probing for filetypes...");
    Date date = null;
    try (DirectoryStream<Path> stream = Files.newDirectoryStream(this.photosPath, "*")) {
        for (Path entry : stream) {
            PhotoFile photoFile;
            //String fileType = Files.probeContentType(entry);
            String fileType = detectMimeType(entry);
            String fileExt = getFileExt(entry.getFileName().toString());
            if (fileType != null && fileType.contains("image")) {
        
                if (result.get(fileType + ":" + fileExt) == null) {
                    result.put(fileType, fileExt);
                    LOGGER.info("Found " + fileType);
        
                }
        
            }
        }
    } catch (DirectoryIteratorException ex) {
        // I/O error encounted during the iteration, the cause is an IOException
        throw ex.getCause();
    }
    return result;
    } */

    /**
     * Get a diff between two dates
     *
     * @param date1    the oldest date
     * @param date2    the newest date
     * @param timeUnit the unit in which you want the diff
     * @return the diff value, in the provided unit
     */
    private static long getDateDiff(Date date1, Date date2, TimeUnit timeUnit) {
        long diffInMillies = date2.getTime() - date1.getTime();
        return timeUnit.convert(diffInMillies, TimeUnit.MILLISECONDS);
    }

    private List<PhotoFile> sortList(List<PhotoFile> files) {
        Collections.sort(files, new Comparator<PhotoFile>() {
            public int compare(PhotoFile o1, PhotoFile o2) {
                return o1.getPhotoDate().compareTo(o2.getPhotoDate());
            }
        });
        return files;
    }

    private void flagAllEvents(List<PhotoFile> sortedList) throws IOException {
        for (int i = 0; i < sortedList.size(); i++) {
            PhotoFile photoFile = sortedList.get(i);

            if (i == sortedList.size() - 1) {
                parseFile(photoFile, true);
            } else {
                parseFile(photoFile, false);
            }
        }

    }

    private String detectMimeType(Path pathToDetect) throws IOException {
        return tika.detect(pathToDetect);
    }

    private List<PhotoEvent> getEventList() {
        return eventList;
    }

    public void setEventList(List<PhotoEvent> eventList) {
        this.eventList = eventList;
    }

    public static void main(String[] args) {
        PhotoConfig photoConfig = new PhotoConfig();

        // create the parser
        CommandLineParser parser = new DefaultParser();
        try {
            // parse the command line arguments
            CommandLine line = parser.parse(photoConfig.getOptions(), args);

            if (line.hasOption("h")) {
                HelpFormatter formatter = new HelpFormatter();
                formatter.printHelp("PhotoSorter", photoConfig.getOptions());
                return;
            }

            PhotoSorter photoSorter = new PhotoSorter(line);
            List<PhotoFile> photoFileList;
            List<PhotoFile> sortedList;

            //photoSorter.probeFiletypes();
            photoFileList = photoSorter.listSourceFiles();
            sortedList = photoSorter.sortList(photoFileList);

            LOGGER.finest(sortedList.toString());
            photoSorter.flagAllEvents(sortedList);

            photoSorter.printEventList(photoSorter.getEventList());

            //LOGGER.info(pathList.toString());

        } catch (org.apache.commons.cli.ParseException e) {
            LOGGER.severe(e.getMessage());
        } catch (IOException e) {
            LOGGER.severe(e.getMessage());
        } catch (ImageProcessingException e) {
            LOGGER.severe(e.getMessage());
        }

    }
}