/*
This file is part of RouteConverter.
RouteConverter 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 2 of the License, or
(at your option) any later version.
RouteConverter 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 RouteConverter; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
Copyright (C) 2007 Christian Pesch. All Rights Reserved.
*/
package slash.navigation.converter.gui.helpers;
import slash.common.type.CompactCalendar;
import slash.navigation.base.BaseNavigationPosition;
import slash.navigation.base.NavigationFormatParser;
import slash.navigation.base.ParserResult;
import slash.navigation.base.Wgs84Position;
import slash.navigation.base.Wgs84Route;
import slash.navigation.common.NavigationPosition;
import slash.navigation.converter.gui.RouteConverter;
import slash.navigation.converter.gui.models.PositionsModel;
import slash.navigation.gui.Application;
import slash.navigation.gui.events.ContinousRange;
import slash.navigation.gui.events.RangeOperation;
import slash.navigation.gui.notifications.NotificationManager;
import slash.navigation.photo.PhotoFormat;
import slash.navigation.photo.PhotoNavigationFormatRegistry;
import slash.navigation.photo.PhotoPosition;
import javax.swing.*;
import java.awt.event.ActionEvent;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.logging.Logger;
import java.util.prefs.Preferences;
import static java.lang.Math.min;
import static java.lang.String.format;
import static java.lang.System.currentTimeMillis;
import static java.util.Collections.singletonList;
import static java.util.concurrent.Executors.newSingleThreadExecutor;
import static javax.swing.JOptionPane.ERROR_MESSAGE;
import static javax.swing.JOptionPane.showMessageDialog;
import static javax.swing.SwingUtilities.invokeLater;
import static javax.swing.event.TableModelEvent.ALL_COLUMNS;
import static slash.common.helpers.ExceptionHelper.getLocalizedMessage;
import static slash.common.io.Directories.ensureDirectory;
import static slash.common.io.Files.collectFiles;
import static slash.common.type.CompactCalendar.fromMillis;
import static slash.navigation.base.WaypointType.Photo;
import static slash.navigation.converter.gui.helpers.TagStrategy.Create_Tagged_Photo_In_Subdirectory;
import static slash.navigation.gui.events.Range.asRange;
import static slash.navigation.gui.helpers.JTableHelper.scrollToPosition;
import static slash.navigation.photo.TagState.NotTaggable;
import static slash.navigation.photo.TagState.Taggable;
import static slash.navigation.photo.TagState.Tagged;
/**
* Helps to tag photos with GPS data.
*
* @author Christian Pesch
*/
public class GeoTagger {
private static final Preferences preferences = Preferences.userNodeForPackage(GeoTagger.class);
private static final Logger log = Logger.getLogger(GeoTagger.class.getName());
private static final String CLOSEST_POSITION_BY_COORDINATES_THRESHOLD_PREFERENCE = "closestPositionByCoordinatesThreshold";
private static final String CLOSEST_POSITION_BY_TIME_THRESHOLD_PREFERENCE = "closestPositionByTimeThreshold";
private final JFrame frame;
private final JTable photosView;
private final PositionsModel photosModel;
private final ExecutorService executor = newSingleThreadExecutor();
private static final Object notificationMutex = new Object();
private boolean running = true;
public GeoTagger(JTable photosView, PositionsModel photosModel, JFrame frame) {
this.photosView = photosView;
this.photosModel = photosModel;
this.frame = frame;
}
public void interrupt() {
synchronized (notificationMutex) {
this.running = false;
}
}
public void dispose() {
interrupt();
executor.shutdownNow();
}
private interface Operation {
String getName();
boolean run(int index, NavigationPosition position) throws Exception;
String getMessagePrefix();
}
private NotificationManager getNotificationManager() {
return Application.getInstance().getContext().getNotificationManager();
}
private static class CancelAction extends AbstractAction {
private boolean canceled = false;
public boolean isCanceled() {
return canceled;
}
public void actionPerformed(ActionEvent e) {
this.canceled = true;
}
}
public void addPhotos(final List<File> filesAndDirectories) {
synchronized (notificationMutex) {
this.running = true;
}
final CancelAction cancelAction = new CancelAction();
executor.execute(new Runnable() {
public void run() {
final int[] count = new int[1];
try {
final List<File> files = collectFiles(filesAndDirectories);
final Exception[] lastException = new Exception[1];
lastException[0] = null;
for (File file : files) {
try {
final PhotoPosition position = extractPhotoPosition(file);
invokeLater(new Runnable() {
public void run() {
photosModel.add(photosModel.getRowCount(), new ArrayList<BaseNavigationPosition>(singletonList(position)));
scrollToPosition(photosView, photosModel.getRowCount() - 1);
}
});
synchronized (notificationMutex) {
if (cancelAction.isCanceled() || !running)
break;
}
} catch (Exception e) {
log.warning(format("Error while running operation AddPhotos on file %s: %s", file, e));
lastException[0] = e;
}
getNotificationManager().showNotification(MessageFormat.format(
RouteConverter.getBundle().getString("add-photos-progress"), count[0]++, files.size()), cancelAction);
}
if (lastException[0] != null)
showMessageDialog(frame,
MessageFormat.format(RouteConverter.getBundle().getString("add-photos-error"), getLocalizedMessage(lastException[0])),
frame.getTitle(), ERROR_MESSAGE);
} finally {
invokeLater(new Runnable() {
public void run() {
getNotificationManager().showNotification(MessageFormat.format(
RouteConverter.getBundle().getString("add-photos-finished"), count[0]), null);
}
});
}
}
});
}
private PhotoPosition extractPhotoPosition(File file) throws IOException {
PhotoPosition position = extractMetadata(file);
updateClosestPositionForTagging(position);
return position;
}
private void updateClosestPositionForTagging(PhotoPosition position) {
position.setTagState(NotTaggable);
position.setClosestPositionForTagging(null);
PositionsModel originalPositionsModel = RouteConverter.getInstance().getConvertPanel().getPositionsModel();
int index = getClosestPositionByCoordinates(position);
if (index != -1) {
position.setTagState(Tagged);
position.setClosestPositionForTagging(originalPositionsModel.getPosition(index));
} else {
index = getClosestPositionByTime(position);
if (index != -1) {
position.setTagState(Taggable);
position.setClosestPositionForTagging(originalPositionsModel.getPosition(index));
}
}
}
private int getClosestPositionByCoordinates(NavigationPosition position) {
PositionsModel originalPositionsModel = RouteConverter.getInstance().getConvertPanel().getPositionsModel();
double threshold = preferences.getDouble(CLOSEST_POSITION_BY_COORDINATES_THRESHOLD_PREFERENCE, 25);
return position.hasCoordinates() ? originalPositionsModel.getClosestPosition(position.getLongitude(), position.getLatitude(), threshold) : -1;
}
private int getClosestPositionByTime(NavigationPosition position) {
if (!position.hasTime())
return -1;
RouteConverter r = RouteConverter.getInstance();
PositionsModel originalPositionsModel = r.getConvertPanel().getPositionsModel();
CompactCalendar time = position.getTime();
if (!time.getTimeZoneId().equals(r.getPhotoTimeZone().getTimeZoneId()))
time = time.asUTCTimeInTimeZone(r.getPhotoTimeZone().getTimeZone());
long threshold = preferences.getLong(CLOSEST_POSITION_BY_TIME_THRESHOLD_PREFERENCE, 5 * 1000);
return originalPositionsModel.getClosestPosition(time, threshold);
}
private PhotoPosition extractMetadata(File file) throws IOException {
long start = currentTimeMillis();
try {
NavigationFormatParser parser = new NavigationFormatParser(new PhotoNavigationFormatRegistry());
ParserResult parserResult = parser.read(file);
if (parserResult.isSuccessful()) {
Wgs84Route route = Wgs84Route.class.cast(parserResult.getTheRoute());
if (route.getPositionCount() > 0)
return (PhotoPosition) route.getPosition(0);
}
return new PhotoPosition(NotTaggable, fromMillis(file.lastModified()), "No Metadata found", file);
} finally {
long end = currentTimeMillis();
log.info("Extracting metadata from " + file + " took " + (end - start) + " milliseconds");
}
}
private void executeOperation(final JTable positionsTable,
final PositionsModel positionsModel,
final int[] rows,
final Operation operation) {
synchronized (notificationMutex) {
this.running = true;
}
final CancelAction cancelAction = new CancelAction();
executor.execute(new Runnable() {
public void run() {
final int[] count = new int[1];
try {
invokeLater(new Runnable() {
public void run() {
if (positionsTable != null && rows.length > 0)
scrollToPosition(positionsTable, rows[0]);
}
});
final Exception[] lastException = new Exception[1];
lastException[0] = null;
final int maximumRangeLength = rows.length > 99 ? rows.length / 100 : rows.length;
new ContinousRange(rows, new RangeOperation() {
public void performOnIndex(final int index) {
NavigationPosition position = positionsModel.getPosition(index);
try {
operation.run(index, position);
} catch (Exception e) {
log.warning(format("Error while running operation %s on position %d: %s", operation, index, e));
lastException[0] = e;
}
String progressMessage = RouteConverter.getBundle().getString(operation.getMessagePrefix() + "progress");
getNotificationManager().showNotification(MessageFormat.format(progressMessage, count[0]++, rows.length), cancelAction);
}
public void performOnRange(final int firstIndex, final int lastIndex) {
invokeLater(new Runnable() {
public void run() {
positionsModel.fireTableRowsUpdated(firstIndex, lastIndex, ALL_COLUMNS);
if (positionsTable != null) {
scrollToPosition(positionsTable, min(lastIndex + maximumRangeLength, positionsModel.getRowCount() - 1));
}
}
});
}
public boolean isInterrupted() {
synchronized (notificationMutex) {
return cancelAction.isCanceled() || !running;
}
}
}).performMonotonicallyIncreasing(maximumRangeLength);
if (lastException[0] != null) {
String errorMessage = RouteConverter.getBundle().getString(operation.getMessagePrefix() + "error");
showMessageDialog(frame,
MessageFormat.format(errorMessage, getLocalizedMessage(lastException[0])), frame.getTitle(), ERROR_MESSAGE);
}
} finally {
invokeLater(new Runnable() {
public void run() {
String finishedMessage = RouteConverter.getBundle().getString(operation.getMessagePrefix() + "finished");
getNotificationManager().showNotification(MessageFormat.format(finishedMessage, count[0]), null);
}
});
}
}
});
}
private void updateMetaData(PhotoPosition position, NavigationPosition closestPositionForTagging,
TagStrategy tagStrategy) throws IOException {
File source = position.getOrigin(File.class);
File target = null;
long start = currentTimeMillis();
try {
position.setLongitude(closestPositionForTagging.getLongitude());
position.setLatitude(closestPositionForTagging.getLatitude());
position.setElevation(closestPositionForTagging.getElevation());
position.setSpeed(closestPositionForTagging.getSpeed());
position.setWaypointType(Photo);
if (tagStrategy.equals(Create_Tagged_Photo_In_Subdirectory)) {
File subDirectory = createSubDirectory(source, "tagged");
target = new File(subDirectory, source.getName());
} else {
File subDirectory = createSubDirectory(source, "bak");
File sourceBackup = new File(subDirectory, source.getName());
if (!source.renameTo(sourceBackup))
throw new IOException(format("Cannot rename %s to %s", source.getPath(), subDirectory.getPath()));
target = source;
source = sourceBackup;
}
new PhotoFormat().write(position, source, new FileOutputStream(target));
position.setTagState(Tagged);
if (closestPositionForTagging instanceof Wgs84Position) {
Wgs84Position wgs84Position = Wgs84Position.class.cast(closestPositionForTagging);
wgs84Position.setDescription(source.getAbsolutePath());
wgs84Position.setWaypointType(Photo);
wgs84Position.setOrigin(source);
PositionsModel originalPositionsModel = RouteConverter.getInstance().getConvertPanel().getPositionsModel();
int index = originalPositionsModel.getIndex(wgs84Position);
originalPositionsModel.fireTableRowsUpdated(index, index, ALL_COLUMNS);
}
} finally {
long end = currentTimeMillis();
log.info("Updating metadata of " + target + " took " + (end - start) + " milliseconds");
}
}
private File createSubDirectory(File source, String name) throws IOException {
File subDirectory = new File(source.getParentFile(), name);
return ensureDirectory(subDirectory);
}
public void updateClosestPositionsForTagging() {
int[] rows = asRange(0, photosModel.getRowCount() - 1);
executeOperation(photosView, photosModel, rows, new Operation() {
public String getName() {
return "UpdateClosestPositionForTagging";
}
public boolean run(int index, NavigationPosition navigationPosition) throws Exception {
if (!(navigationPosition instanceof PhotoPosition))
return false;
PhotoPosition position = PhotoPosition.class.cast(navigationPosition);
if (position.getTagState().equals(Tagged))
return false;
updateClosestPositionForTagging(position);
return true;
}
public String getMessagePrefix() {
return "update-closest-position-";
}
});
}
public void tagPhotos() {
int[] rows = photosView.getSelectedRows();
if (rows.length > 0) {
final TagStrategy tagStrategy = RouteConverter.getInstance().getTagStrategyPreference();
executeOperation(photosView, photosModel, rows, new Operation() {
public String getName() {
return "TagPhotosTagger";
}
public boolean run(int index, NavigationPosition navigationPosition) throws Exception {
if (!(navigationPosition instanceof PhotoPosition))
return false;
PhotoPosition position = PhotoPosition.class.cast(navigationPosition);
if (!position.getTagState().equals(Taggable))
return false;
NavigationPosition closestPositionForTagging = position.getClosestPositionForTagging();
if (closestPositionForTagging == null)
return false;
updateMetaData(position, closestPositionForTagging, tagStrategy);
return true;
}
public String getMessagePrefix() {
return "tag-photos-";
}
});
}
}
}