se.trixon.mapollage.Operation.java Source code

Java tutorial

Introduction

Here is the source code for se.trixon.mapollage.Operation.java

Source

/*
 * Copyright 2018 Patrik Karlstrm.
 *
 * 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
 *
 *      http://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 se.trixon.mapollage;

import com.drew.imaging.ImageProcessingException;
import com.drew.metadata.exif.GpsDescriptor;
import com.drew.metadata.exif.GpsDirectory;
import de.micromata.opengis.kml.v_2_2_0.BalloonStyle;
import de.micromata.opengis.kml.v_2_2_0.Boundary;
import de.micromata.opengis.kml.v_2_2_0.ColorMode;
import de.micromata.opengis.kml.v_2_2_0.Coordinate;
import de.micromata.opengis.kml.v_2_2_0.Document;
import de.micromata.opengis.kml.v_2_2_0.Feature;
import de.micromata.opengis.kml.v_2_2_0.Folder;
import de.micromata.opengis.kml.v_2_2_0.Icon;
import de.micromata.opengis.kml.v_2_2_0.IconStyle;
import de.micromata.opengis.kml.v_2_2_0.Kml;
import de.micromata.opengis.kml.v_2_2_0.KmlFactory;
import de.micromata.opengis.kml.v_2_2_0.LineString;
import de.micromata.opengis.kml.v_2_2_0.LineStyle;
import de.micromata.opengis.kml.v_2_2_0.LinearRing;
import de.micromata.opengis.kml.v_2_2_0.Placemark;
import de.micromata.opengis.kml.v_2_2_0.Point;
import de.micromata.opengis.kml.v_2_2_0.PolyStyle;
import de.micromata.opengis.kml.v_2_2_0.Polygon;
import de.micromata.opengis.kml.v_2_2_0.Style;
import de.micromata.opengis.kml.v_2_2_0.StyleState;
import de.micromata.opengis.kml.v_2_2_0.TimeStamp;
import java.awt.Dimension;
import java.awt.geom.Point2D;
import java.io.File;
import java.io.IOException;
import java.io.StringWriter;
import java.nio.file.FileVisitOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.ResourceBundle;
import java.util.TreeMap;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.SystemUtils;
import se.trixon.almond.util.Dict;
import se.trixon.almond.util.Scaler;
import se.trixon.almond.util.SystemHelper;
import se.trixon.almond.util.ext.GrahamScan;
import se.trixon.mapollage.profile.Profile;
import se.trixon.mapollage.profile.ProfileDescription;
import se.trixon.mapollage.profile.ProfileDescription.DescriptionSegment;
import se.trixon.mapollage.profile.ProfileFolder;
import se.trixon.mapollage.profile.ProfilePath;
import se.trixon.mapollage.profile.ProfilePhoto;
import se.trixon.mapollage.profile.ProfilePlacemark;
import se.trixon.mapollage.profile.ProfileSource;

/**
 *
 * @author Patrik Karlstrm
 */
public class Operation implements Runnable {

    private static final Logger LOGGER = Logger.getLogger(Operation.class.getName());

    private final BalloonStyle mBalloonStyle;
    private final ResourceBundle mBundle;
    private final DateFormat mDateFormatDate = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM);
    private final File mDestinationFile;
    private final HashMap<String, Properties> mDirToDesc = new HashMap<>();
    private final Document mDocument;
    private final HashMap<File, File> mFileThumbMap = new HashMap<>();
    private final List<File> mFiles = new ArrayList<>();
    private final Pattern mFolderByRegexPattern;
    private final HashMap<Folder, ArrayList<Coordinate>> mFolderPolygonInputs = new HashMap<>();
    private final Map<String, Folder> mFolders = new HashMap<>();
    private boolean mInterrupted = false;
    private final Kml mKml = new Kml();
    private final ArrayList<LineNode> mLineNodes = new ArrayList<>();
    private final OperationListener mListener;
    private int mNumOfErrors = 0;
    private int mNumOfExif;
    private int mNumOfGps;
    private int mNumOfPlacemarks;
    private final Options mOptions = Options.getInstance();
    private Folder mPathFolder;
    private Folder mPathGapFolder;
    private PhotoInfo mPhotoInfo;
    private Folder mPolygonFolder;
    private final HashMap<Folder, Folder> mPolygonRemovals = new HashMap<>();
    private final Profile mProfile;
    private final ProfileDescription mProfileDescription;
    private final ProfileFolder mProfileFolder;
    private final ProfilePath mProfilePath;
    private final ProfilePhoto mProfilePhoto;
    private final ProfilePlacemark mProfilePlacemark;
    private final ProfileSource mProfileSource;
    private Folder mRootFolder;
    private final Map<String, Folder> mRootFolders = new HashMap<>();
    private long mStartTime;
    private File mThumbsDir;
    private final SimpleDateFormat mTimeStampDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX");

    public Operation(OperationListener operationListener, Profile profile) {
        mListener = operationListener;
        mProfile = profile;
        mProfileSource = mProfile.getSource();
        mProfileFolder = mProfile.getFolder();
        mProfilePath = mProfile.getPath();
        mProfilePlacemark = mProfile.getPlacemark();
        mProfileDescription = mProfile.getDescription();
        mProfilePhoto = mProfile.getPhoto();
        mDestinationFile = mProfile.getDestinationFile();

        mFolderByRegexPattern = Pattern.compile(mProfileFolder.getRegex());

        mBundle = SystemHelper.getBundle(Operation.class, "Bundle");
        mDocument = mKml.createAndSetDocument().withOpen(true);
        mBalloonStyle = KmlFactory.createBalloonStyle().withId("BalloonStyleId")
                //aabbggrr
                .withBgColor("ff272420").withTextColor("ffeeeeee").withText("$[description]");
    }

    @Override
    public void run() {
        if (!Files.isWritable(mDestinationFile.getParentFile().toPath())) {
            mListener.onOperationLog(String.format(mBundle.getString("insufficient_privileges"),
                    mDestinationFile.getAbsolutePath()));
            Thread.currentThread().interrupt();
            mListener.onOperationInterrupted();
            return;
        }

        mStartTime = System.currentTimeMillis();
        Date date = new Date(mStartTime);
        SimpleDateFormat dateFormat = new SimpleDateFormat();
        mListener.onOperationStarted();
        mListener.onOperationLog(dateFormat.format(date));

        String status;
        mRootFolder = mDocument.createAndAddFolder().withName(getSafeXmlString(mProfileFolder.getRootName()))
                .withOpen(true);

        String href = "<a href=\"https://trixon.se/mapollage/\">Mapollage</a>";
        String description = String.format("<p>%s %s, %s</p>%s", Dict.MADE_WITH.toString(), href,
                dateFormat.format(date), mProfileFolder.getRootDescription().replaceAll("\\n", "<br />"));
        mRootFolder.setDescription(getSafeXmlString(description));

        mListener.onOperationProcessingStarted();
        try {
            mInterrupted = !generateFileList();
        } catch (IOException ex) {
            logError(ex.getMessage());
        }

        if (!mInterrupted && !mFiles.isEmpty()) {
            if (isUsingThumbnails()) {
                mThumbsDir = new File(mDestinationFile.getParent() + String.format("/%s-thumbnails",
                        FilenameUtils.getBaseName(mDestinationFile.getAbsolutePath())));
                try {
                    FileUtils.forceMkdir(mThumbsDir);
                } catch (IOException ex) {
                    logError(String.format("E000 %s", ex.getMessage()));
                }
            }

            mListener.onOperationLog(String.format(mBundle.getString("found_count"), mFiles.size()));
            mListener.onOperationLog("");

            int progress = 0;
            for (File file : mFiles) {
                mListener.onOperationProgress(file.getAbsolutePath());
                mListener.onOperationProgress(++progress, mFiles.size());

                try {
                    addPhoto(file);
                } catch (ImageProcessingException ex) {
                    logError(String.format("E000 %s", ex.getMessage()));
                } catch (IOException ex) {
                    logError(String.format("E000 %s", file.getAbsolutePath()));
                }

                if (Thread.interrupted()) {
                    mInterrupted = true;
                    break;
                }
            }

            if (mProfilePath.isDrawPath() && mLineNodes.size() > 1) {
                addPath();
            }
        }

        if (mInterrupted) {
            status = Dict.TASK_ABORTED.toString();
            mListener.onOperationLog("\n" + status);
            mListener.onOperationInterrupted();
        } else if (!mFiles.isEmpty()) {
            if (mProfilePath.isDrawPolygon()) {
                addPolygons();
            }
            saveToFile();
            mProfile.setLastRun(System.currentTimeMillis());
        }

        if (mNumOfErrors > 0) {
            logError(mBundle.getString("error_description"));
        }
    }

    HashMap<String, Properties> getDirToDesc() {
        return mDirToDesc;
    }

    String getExcludePattern() {
        return mProfileSource.getExcludePattern();
    }

    OperationListener getListener() {
        return mListener;
    }

    ProfileDescription getProfileDescription() {
        return mProfileDescription;
    }

    void logError(String message) {
        mNumOfErrors++;
        mListener.onOperationError(message);
    }

    private void addPath() {
        Collections.sort(mLineNodes, (LineNode o1, LineNode o2) -> o1.getDate().compareTo(o2.getDate()));

        mPathFolder = KmlFactory.createFolder().withName(Dict.PATH_GFX.toString());
        mPathGapFolder = KmlFactory.createFolder().withName(Dict.PATH_GAP_GFX.toString());

        String pattern = getPattern(mProfilePath.getSplitBy());
        SimpleDateFormat dateFormat = new SimpleDateFormat(pattern);

        TreeMap<String, ArrayList<LineNode>> map = new TreeMap<>();

        mLineNodes.forEach((node) -> {
            String key = dateFormat.format(node.getDate());
            if (!map.containsKey(key)) {
                map.put(key, new ArrayList<>());
            }
            map.get(key).add(node);
        });

        //Add paths
        for (ArrayList<LineNode> nodes : map.values()) {
            if (nodes.size() > 1) {
                Placemark path = mPathFolder.createAndAddPlacemark().withName(LineNode.getName(nodes));

                Style pathStyle = path.createAndAddStyle();
                pathStyle.createAndSetLineStyle().withColor("ff0000ff").withWidth(mProfilePath.getWidth());

                LineString line = path.createAndSetLineString().withExtrude(false).withTessellate(true);

                nodes.forEach((node) -> {
                    line.addToCoordinates(node.getLon(), node.getLat());
                });
            }
        }

        //Add path gap
        ArrayList<LineNode> previousNodes = null;
        for (ArrayList<LineNode> nodes : map.values()) {
            if (previousNodes != null) {
                Placemark path = mPathGapFolder.createAndAddPlacemark()
                        .withName(LineNode.getName(previousNodes, nodes));

                Style pathStyle = path.createAndAddStyle();
                pathStyle.createAndSetLineStyle().withColor("ff00ffff").withWidth(mProfilePath.getWidth());

                LineString line = path.createAndSetLineString().withExtrude(false).withTessellate(true);

                LineNode prevLast = previousNodes.get(previousNodes.size() - 1);
                LineNode currentFirst = nodes.get(0);

                line.addToCoordinates(prevLast.getLon(), prevLast.getLat());
                line.addToCoordinates(currentFirst.getLon(), currentFirst.getLat());
            }
            previousNodes = nodes;
        }
    }

    private void addPhoto(File file) throws ImageProcessingException, IOException {
        mPhotoInfo = new PhotoInfo(file, mProfileSource.isIncludeNullCoordinate());
        try {
            mPhotoInfo.init();
        } catch (ImageProcessingException | IOException e) {
            if (mPhotoInfo.hasExif()) {
                mNumOfExif++;
            }

            throw e;
        }

        boolean hasLocation = false;
        if (mPhotoInfo.hasExif()) {
            mNumOfExif++;
            hasLocation = mPhotoInfo.hasGps() && !mPhotoInfo.isZeroCoordinate();
            if (hasLocation) {
                mNumOfGps++;
            }
        } else {
            throw new ImageProcessingException(String.format("E010 %s", file.getAbsolutePath()));
        }

        Date exifDate = mPhotoInfo.getDate();
        if (hasLocation && mProfilePath.isDrawPath()) {
            mLineNodes.add(new LineNode(exifDate, mPhotoInfo.getLat(), mPhotoInfo.getLon()));
        }

        if (hasLocation || mProfileSource.isIncludeNullCoordinate()) {
            Folder folder = getFolder(file, exifDate);

            String imageId = String.format("%08x", FileUtils.checksumCRC32(file));
            String styleNormalId = String.format("s_%s", imageId);
            String styleHighlightId = String.format("s_%s_hl", imageId);
            String styleMapId = String.format("m_%s", imageId);

            Style normalStyle = mDocument.createAndAddStyle().withId(styleNormalId);

            IconStyle normalIconStyle = normalStyle.createAndSetIconStyle().withScale(1.0);

            Style highlightStyle = mDocument.createAndAddStyle().withBalloonStyle(mBalloonStyle)
                    .withId(styleHighlightId);

            IconStyle highlightIconStyle = highlightStyle.createAndSetIconStyle().withScale(1.1);

            if (mProfilePlacemark.isSymbolAsPhoto()) {
                Icon icon = KmlFactory.createIcon()
                        .withHref(String.format("%s/%s.jpg", mThumbsDir.getName(), imageId));
                normalIconStyle.setIcon(icon);
                normalIconStyle.setScale(mProfilePlacemark.getScale());

                double highlightZoom = mProfilePlacemark.getZoom() * mProfilePlacemark.getScale();
                highlightIconStyle.setIcon(icon);
                highlightIconStyle.setScale(highlightZoom);
            }

            if (isUsingThumbnails()) {
                File thumbFile = new File(mThumbsDir, imageId + ".jpg");
                mFileThumbMap.put(file, thumbFile);
                if (Files.isWritable(thumbFile.getParentFile().toPath())) {
                    mPhotoInfo.createThumbnail(thumbFile);
                } else {
                    mListener.onOperationLog(String.format(mBundle.getString("insufficient_privileges"),
                            mDestinationFile.getAbsolutePath()));
                    Thread.currentThread().interrupt();
                    return;
                }
            }

            mDocument.createAndAddStyleMap().withId(styleMapId)
                    .addToPair(KmlFactory.createPair().withKey(StyleState.NORMAL).withStyleUrl("#" + styleNormalId))
                    .addToPair(KmlFactory.createPair().withKey(StyleState.HIGHLIGHT)
                            .withStyleUrl("#" + styleHighlightId));

            Placemark placemark = KmlFactory.createPlacemark()
                    .withName(getSafeXmlString(getPlacemarkName(file, exifDate))).withOpen(Boolean.TRUE)
                    .withStyleUrl("#" + styleMapId);

            String desc = getPlacemarkDescription(file, mPhotoInfo, exifDate);
            if (!StringUtils.isBlank(desc)) {
                placemark.setDescription(desc);
            }

            placemark.createAndSetPoint().addToCoordinates(mPhotoInfo.getLon(), mPhotoInfo.getLat(), 0F);

            if (mProfilePlacemark.isTimestamp()) {
                TimeStamp timeStamp = KmlFactory.createTimeStamp();
                timeStamp.setWhen(mTimeStampDateFormat.format(exifDate));
                placemark.setTimePrimitive(timeStamp);
            }

            folder.addToFeature(placemark);
            mNumOfPlacemarks++;
        }

        mListener.onOperationLog(file.getAbsolutePath());
    }

    private void addPolygon(String name, ArrayList<Coordinate> coordinates, Folder polygonFolder) {
        List<Point2D.Double> inputs = new ArrayList<>();
        coordinates.forEach((coordinate) -> {
            inputs.add(new Point2D.Double(coordinate.getLongitude(), coordinate.getLatitude()));
        });

        try {
            List<Point2D.Double> convexHull = GrahamScan.getConvexHullDouble(inputs);
            Placemark placemark = polygonFolder.createAndAddPlacemark().withName(name);

            Style style = placemark.createAndAddStyle();
            LineStyle lineStyle = style.createAndSetLineStyle().withColor("00000000").withWidth(0.0);

            PolyStyle polyStyle = style.createAndSetPolyStyle().withColor("ccffffff")
                    .withColorMode(ColorMode.RANDOM);

            Polygon polygon = placemark.createAndSetPolygon();
            Boundary boundary = polygon.createAndSetOuterBoundaryIs();
            LinearRing linearRing = boundary.createAndSetLinearRing();

            convexHull.forEach((node) -> {
                linearRing.addToCoordinates(node.x, node.y);

            });
        } catch (IllegalArgumentException e) {
            System.err.println(e);
        }
    }

    private void addPolygons() {
        mPolygonFolder = KmlFactory.createFolder().withName(Dict.POLYGON.toString()).withOpen(false);
        addPolygons(mPolygonFolder, mRootFolder.getFeature());

        scanForFolderRemoval(mPolygonFolder);
        //        while (scanForFolderRemoval(mPolygonFolder, false)) {
        //            //
        //        }

        for (Folder folder : mPolygonRemovals.keySet()) {
            Folder parentFolder = mPolygonRemovals.get(folder);
            parentFolder.getFeature().remove(folder);
        }

        mRootFolder.getFeature().add(mPolygonFolder);
    }

    private void addPolygons(Folder polygonParent, List<Feature> features) {
        for (Feature feature : features) {
            if (feature instanceof Folder) {
                Folder folder = (Folder) feature;

                if (folder != mPathFolder && folder != mPathGapFolder && folder != mPolygonFolder) {
                    Folder polygonFolder = polygonParent.createAndAddFolder().withName(folder.getName())
                            .withOpen(true);
                    mFolderPolygonInputs.put(polygonFolder, new ArrayList<>());
                    addPolygons(polygonFolder, folder.getFeature());

                    if (mFolderPolygonInputs.get(polygonFolder) != null) {
                        addPolygon(folder.getName(), mFolderPolygonInputs.get(polygonFolder), polygonParent);
                    }
                }
            }

            if (feature instanceof Placemark) {
                Placemark placemark = (Placemark) feature;

                Point point = (Point) placemark.getGeometry();
                ArrayList<Coordinate> coordinates = mFolderPolygonInputs.computeIfAbsent(polygonParent,
                        k -> new ArrayList<>());
                coordinates.addAll(point.getCoordinates());
            }
        }

        ArrayList<Coordinate> rootCoordinates = mFolderPolygonInputs.get(mPolygonFolder);
        if (polygonParent == mPolygonFolder && rootCoordinates != null) {
            addPolygon(mPolygonFolder.getName(), rootCoordinates, polygonParent);
        }
    }

    private boolean generateFileList() throws IOException {
        mListener.onOperationLog("");
        mListener.onOperationLog(Dict.GENERATING_FILELIST.toString());
        PathMatcher pathMatcher = mProfileSource.getPathMatcher();

        EnumSet<FileVisitOption> fileVisitOptions;
        if (mProfileSource.isFollowLinks()) {
            fileVisitOptions = EnumSet.of(FileVisitOption.FOLLOW_LINKS);
        } else {
            fileVisitOptions = EnumSet.noneOf(FileVisitOption.class);
        }

        File file = mProfileSource.getDir();
        if (file.isDirectory()) {
            FileVisitor fileVisitor = new FileVisitor(pathMatcher, mFiles, file, this);
            try {
                if (mProfileSource.isRecursive()) {
                    Files.walkFileTree(file.toPath(), fileVisitOptions, Integer.MAX_VALUE, fileVisitor);
                } else {
                    Files.walkFileTree(file.toPath(), fileVisitOptions, 1, fileVisitor);
                }

                if (fileVisitor.isInterrupted()) {
                    return false;
                }
            } catch (IOException ex) {
                throw new IOException(String.format("E000 %s", file.getAbsolutePath()));
            }
        } else if (file.isFile() && pathMatcher.matches(file.toPath().getFileName())) {
            mFiles.add(file);
        }

        if (mFiles.isEmpty()) {
            mListener.onOperationFinished(Dict.FILELIST_EMPTY.toString(), 0);
        } else {
            Collections.sort(mFiles);
        }

        return true;
    }

    private String getDescPhoto(File sourceFile, int orientation) throws IOException {
        Scaler scaler = new Scaler(new Dimension(mPhotoInfo.getOriginalDimension()));
        boolean thumbRef = mProfilePhoto.getReference() == ProfilePhoto.Reference.THUMBNAIL;
        boolean portrait = (orientation == 6 || orientation == 8) && thumbRef;

        if (mProfilePhoto.isLimitWidth()) {
            int widthLimit = portrait ? mProfilePhoto.getHeightLimit() : mProfilePhoto.getWidthLimit();
            scaler.setWidth(widthLimit);
        }

        if (mProfilePhoto.isLimitHeight()) {
            int heightLimit = portrait ? mProfilePhoto.getWidthLimit() : mProfilePhoto.getHeightLimit();
            scaler.setHeight(heightLimit);
        }

        Dimension newDimension = scaler.getDimension();
        String imageTagFormat = "<p><img src='%s' width='%d' height='%d'></p>";

        int width = portrait ? newDimension.height : newDimension.width;
        int height = portrait ? newDimension.width : newDimension.height;

        String imageTag = String.format(imageTagFormat, getImagePath(sourceFile), width, height);

        return imageTag;
    }

    private String getExternalDescription(File file) {
        Properties p = mDirToDesc.get(file.getParent());
        final String key = FilenameUtils.getBaseName(file.getName());
        String desc = p.getProperty(key);
        if (desc == null) {
            if (mProfileDescription.isDefaultTo()) {
                if (mProfileDescription.getDefaultMode() == ProfileDescription.DescriptionMode.CUSTOM) {
                    desc = mProfileDescription.getCustomValue();
                } else if (mProfileDescription.getDefaultMode() == ProfileDescription.DescriptionMode.STATIC) {
                    desc = getStaticDescription();
                }
            } else {
                desc = "&nbsp;";
            }
        }

        return desc;
    }

    private Folder getFolder(File file, Date date) {
        String key;
        Folder folder = null;

        switch (mProfileFolder.getFoldersBy()) {
        case DIR:
            Path relativePath = mProfileSource.getDir().toPath().relativize(file.getParentFile().toPath());
            key = relativePath.toString();
            folder = getFolder(key);
            break;

        case DATE:
            key = mProfileFolder.getFolderDateFormat().format(date);
            folder = getFolder(key);
            break;

        case REGEX:
            key = mProfileFolder.getRegexDefault();
            Matcher matcher = mFolderByRegexPattern.matcher(file.getParent());
            if (matcher.find()) {
                key = matcher.group();
            }
            folder = getFolder(key);
            break;

        case NONE:
            folder = mRootFolder;
            break;
        }

        return folder;
    }

    private Folder getFolder(String key) {
        key = StringUtils.replace(key, "\\", "/");
        String[] levels = StringUtils.split(key, "/");

        Folder parent = mRootFolder;
        String path = "";

        for (int i = 0; i < levels.length; i++) {
            String level = levels[i];
            path = String.format("%s/%s", path, level);
            parent = getFolder(path, parent, level);
            if (i == 0) {
                mFolders.put(path, parent);
            }
        }

        return parent;
    }

    private Folder getFolder(String key, Folder parent, String name) {
        if (!mFolders.containsKey(key)) {
            Folder folder = parent.createAndAddFolder().withName(getSafeXmlString(name));
            mFolders.put(key, folder);
        }

        return mFolders.get(key);
    }

    private String getImagePath(File file) {
        String imageSrc;

        switch (mProfilePhoto.getReference()) {
        case ABSOLUTE:
            imageSrc = String.format("file:///%s", file.getAbsolutePath());
            break;

        case ABSOLUTE_PATH:
            imageSrc = String.format("%s%s", mProfilePhoto.getBaseUrlValue(), file.getName());
            break;

        case RELATIVE:
            Path relativePath = mDestinationFile.toPath().relativize(file.toPath());
            imageSrc = StringUtils.replace(relativePath.toString(), "..", ".", 1);
            break;

        case THUMBNAIL:
            Path thumbPath = mDestinationFile.toPath().relativize(mFileThumbMap.get(file).toPath());
            imageSrc = StringUtils.replace(thumbPath.toString(), "..", ".", 1);
            break;

        default:
            throw new AssertionError();
        }

        if (SystemUtils.IS_OS_WINDOWS) {
            imageSrc = imageSrc.replace("\\", "/");
        }

        if (mProfilePhoto.isForceLowerCaseExtension()) {
            String ext = FilenameUtils.getExtension(imageSrc);
            if (!StringUtils.isBlank(ext) && !StringUtils.isAllLowerCase(ext)) {
                String noExt = FilenameUtils.removeExtension(imageSrc);
                imageSrc = String.format("%s.%s", noExt, ext.toLowerCase());
            }
        }

        return imageSrc;
    }

    private String getPattern(ProfilePath.SplitBy splitBy) {
        switch (splitBy) {
        case NONE:
            return "'NO_SPLIT'";
        case HOUR:
            return "yyyyMMddHH";
        case DAY:
            return "yyyyMMdd";
        case WEEK:
            return "yyyyww";
        case MONTH:
            return "yyyyMM";
        case YEAR:
            return "yyyy";
        default:
            return null;
        }
    }

    private String getPlacemarkDescription(File file, PhotoInfo photoInfo, Date exifDate) throws IOException {
        GpsDirectory gpsDirectory = photoInfo.getGpsDirectory();
        GpsDescriptor gpsDescriptor = null;
        if (gpsDirectory != null) {
            gpsDescriptor = new GpsDescriptor(gpsDirectory);
        }

        String desc = "";
        switch (mProfileDescription.getMode()) {
        case CUSTOM:
            desc = mProfileDescription.getCustomValue();
            break;

        case EXTERNAL:
            desc = getExternalDescription(file);
            break;

        case NONE:
            //Do nothing
            break;

        case STATIC:
            desc = getStaticDescription();
            break;
        }

        if (mProfileDescription.getMode() != ProfileDescription.DescriptionMode.NONE) {
            if (StringUtils.containsIgnoreCase(desc, DescriptionSegment.PHOTO.toString())) {
                desc = StringUtils.replace(desc, DescriptionSegment.PHOTO.toString(),
                        getDescPhoto(file, photoInfo.getOrientation()));
            }

            desc = StringUtils.replace(desc, DescriptionSegment.FILENAME.toString(), file.getName());
            desc = StringUtils.replace(desc, DescriptionSegment.DATE.toString(), mDateFormatDate.format(exifDate));

            if (gpsDirectory != null && gpsDescriptor != null) {
                desc = StringUtils.replace(desc, DescriptionSegment.ALTITUDE.toString(),
                        gpsDescriptor.getGpsAltitudeDescription());
                desc = StringUtils.replace(desc, DescriptionSegment.COORDINATE.toString(),
                        gpsDescriptor.getDegreesMinutesSecondsDescription());

                String bearing = gpsDescriptor.getGpsDirectionDescription(GpsDirectory.TAG_DEST_BEARING);
                desc = StringUtils.replace(desc, DescriptionSegment.BEARING.toString(),
                        bearing == null ? "" : bearing);
            } else {
                desc = StringUtils.replace(desc, DescriptionSegment.ALTITUDE.toString(), "");
                desc = StringUtils.replace(desc, DescriptionSegment.COORDINATE.toString(), "");
                desc = StringUtils.replace(desc, DescriptionSegment.BEARING.toString(), "");
            }

            desc = getSafeXmlString(desc);
        }

        return desc;
    }

    private String getPlacemarkName(File file, Date exifDate) {
        String name;

        switch (mProfilePlacemark.getNameBy()) {
        case DATE:
            try {
                name = mProfilePlacemark.getDateFormat().format(exifDate);
            } catch (IllegalArgumentException ex) {
                name = "invalid exif date";
            } catch (NullPointerException ex) {
                name = "invalid exif date";
                logError(String.format("E011 %s", file.getAbsolutePath()));
            }
            break;

        case FILE:
            name = FilenameUtils.getBaseName(file.getAbsolutePath());
            break;

        case NONE:
            name = "";
            break;

        default:
            throw new AssertionError();
        }

        return name;
    }

    private String getSafeXmlString(String s) {
        if (StringUtils.containsAny(s, '<', '>', '&')) {
            s = new StringBuilder("<![CDATA[").append(s).append("]]>").toString();
        }

        return s;
    }

    private String getStaticDescription() {
        StringBuilder builder = new StringBuilder();

        if (mProfileDescription.hasPhoto()) {
            builder.append(DescriptionSegment.PHOTO.toHtml());
        }

        if (mProfileDescription.hasFilename()) {
            builder.append(DescriptionSegment.FILENAME.toHtml());
        }

        if (mProfileDescription.hasDate()) {
            builder.append(DescriptionSegment.DATE.toHtml());
        }

        if (mProfileDescription.hasCoordinate()) {
            builder.append(DescriptionSegment.COORDINATE.toHtml());
        }

        if (mProfileDescription.hasAltitude()) {
            builder.append(DescriptionSegment.ALTITUDE.toHtml());
        }

        if (mProfileDescription.hasBearing()) {
            builder.append(DescriptionSegment.BEARING.toHtml());
        }

        return builder.toString();
    }

    private boolean isUsingThumbnails() {
        return mProfilePlacemark.isSymbolAsPhoto()
                || mProfilePhoto.getReference() == ProfilePhoto.Reference.THUMBNAIL;
    }

    private void saveToFile() {
        mListener.onOperationLog("");
        List keys = new ArrayList(mRootFolders.keySet());
        Collections.sort(keys);

        keys.stream().forEach((key) -> {
            mRootFolder.getFeature().add(mRootFolders.get((String) key));
        });

        if (mPathFolder != null && !mPathFolder.getFeature().isEmpty()) {
            mRootFolder.getFeature().add(mPathFolder);
        }

        if (mPathGapFolder != null && !mPathGapFolder.getFeature().isEmpty()) {
            mRootFolder.getFeature().add(mPathGapFolder);
        }

        if (isUsingThumbnails()) {
            mListener.onOperationLog(
                    "\n" + String.format(mBundle.getString("stored_thumbnails"), mThumbsDir.getAbsolutePath()));
        }

        try {
            StringWriter stringWriter = new StringWriter();
            mKml.marshal(stringWriter);
            String kmlString = stringWriter.toString();

            if (mOptions.isCleanNs2()) {
                mListener.onOperationLog(mBundle.getString("clean_ns2"));
                kmlString = StringUtils.replace(kmlString, "xmlns:ns2=", "xmlns=");
                kmlString = StringUtils.replace(kmlString, "<ns2:", "<");
                kmlString = StringUtils.replace(kmlString, "</ns2:", "</");
            }

            if (mOptions.isCleanSpace()) {
                mListener.onOperationLog(mBundle.getString("clean_space"));
                kmlString = StringUtils.replace(kmlString, "        ", "\t");
                kmlString = StringUtils.replace(kmlString, "    ", "\t");
            }

            kmlString = StringUtils.replaceEach(kmlString, new String[] { "&lt;", "&gt;", "&amp;" },
                    new String[] { "<", ">", "" });

            if (mOptions.isLogKml()) {
                mListener.onOperationLog("\n");
                mListener.onOperationLog(kmlString);
                mListener.onOperationLog("\n");
            }

            mListener.onOperationLog(String.format(Dict.SAVING.toString(), mDestinationFile.getAbsolutePath()));
            FileUtils.writeStringToFile(mDestinationFile, kmlString, "utf-8");

            String files = mBundle.getString("status_files");
            String exif = mBundle.getString("status_exif");
            String coordinate = mBundle.getString("status_coordinate");
            String time = mBundle.getString("status_time");
            String error = " " + Dict.Dialog.ERRORS.toString().toLowerCase();
            String placemarks = mBundle.getString("status_placemarks");

            int rightPad = files.length();
            rightPad = Math.max(rightPad, exif.length());
            rightPad = Math.max(rightPad, coordinate.length());
            rightPad = Math.max(rightPad, time.length());
            rightPad = Math.max(rightPad, error.length());
            rightPad = Math.max(rightPad, placemarks.length());
            rightPad++;

            int leftPad = 8;
            StringBuilder summaryBuilder = new StringBuilder("\n");

            String filesValue = String.valueOf(mFiles.size());
            summaryBuilder.append(StringUtils.rightPad(files, rightPad)).append(":")
                    .append(StringUtils.leftPad(filesValue, leftPad)).append("\n");

            String exifValue = String.valueOf(mNumOfExif);
            summaryBuilder.append(StringUtils.rightPad(exif, rightPad)).append(":")
                    .append(StringUtils.leftPad(exifValue, leftPad)).append("\n");

            String coordinateValue = String.valueOf(mNumOfGps);
            summaryBuilder.append(StringUtils.rightPad(coordinate, rightPad)).append(":")
                    .append(StringUtils.leftPad(coordinateValue, leftPad)).append("\n");

            String placemarksValue = String.valueOf(mNumOfPlacemarks);
            summaryBuilder.append(StringUtils.rightPad(placemarks, rightPad)).append(":")
                    .append(StringUtils.leftPad(placemarksValue, leftPad)).append("\n");

            String errorValue = String.valueOf(mNumOfErrors);
            summaryBuilder.append(StringUtils.rightPad(error, rightPad)).append(":")
                    .append(StringUtils.leftPad(errorValue, leftPad)).append("\n");

            String timeValue = String.valueOf(Math.round((System.currentTimeMillis() - mStartTime) / 1000.0));
            summaryBuilder.append(StringUtils.rightPad(time, rightPad)).append(":")
                    .append(StringUtils.leftPad(timeValue, leftPad)).append(" s").append("\n");

            mListener.onOperationFinished(summaryBuilder.toString(), mFiles.size());
        } catch (IOException ex) {
            mListener.onOperationFailed(ex.getLocalizedMessage());
        }
    }

    private void scanForFolderRemoval(Folder folder) {
        for (Feature feature : folder.getFeature()) {
            if (feature instanceof Folder) {
                Folder subFolder = (Folder) feature;
                if (subFolder.getFeature().isEmpty()) {
                    mPolygonRemovals.put(subFolder, folder);
                } else {
                    scanForFolderRemoval(subFolder);
                }
            }
        }
    }
}