]> gitweb.fperrin.net Git - GpsPrune.git/blobdiff - tim/prune/App.java
Version 6, October 2008
[GpsPrune.git] / tim / prune / App.java
index 4ccaed688e40dcfd1aef3b1d7e464fad4b1b26ee..ecde5481a1d269ed23028cb48208d4adcf6abb94 100644 (file)
@@ -1,38 +1,61 @@
 package tim.prune;
 
 import java.util.EmptyStackException;
-import java.util.List;
+import java.util.Set;
 import java.util.Stack;
 
 import javax.swing.JFrame;
 import javax.swing.JOptionPane;
 
+import tim.prune.browser.BrowserLauncher;
+import tim.prune.browser.UrlGenerator;
+import tim.prune.correlate.PhotoCorrelator;
+import tim.prune.correlate.PointPair;
+import tim.prune.data.Coordinate;
 import tim.prune.data.DataPoint;
 import tim.prune.data.Field;
+import tim.prune.data.LatLonRectangle;
+import tim.prune.data.Latitude;
+import tim.prune.data.Longitude;
+import tim.prune.data.Photo;
+import tim.prune.data.PhotoList;
 import tim.prune.data.Track;
 import tim.prune.data.TrackInfo;
 import tim.prune.edit.FieldEditList;
 import tim.prune.edit.PointEditor;
 import tim.prune.edit.PointNameEditor;
 import tim.prune.gui.MenuManager;
+import tim.prune.gui.TimeOffsetDialog;
 import tim.prune.gui.UndoManager;
 import tim.prune.load.FileLoader;
+import tim.prune.load.GpsLoader;
 import tim.prune.load.JpegLoader;
+import tim.prune.save.ExifSaver;
 import tim.prune.save.FileSaver;
+import tim.prune.save.GpxExporter;
 import tim.prune.save.KmlExporter;
 import tim.prune.save.PovExporter;
 import tim.prune.threedee.ThreeDException;
 import tim.prune.threedee.ThreeDWindow;
 import tim.prune.threedee.WindowFactory;
+import tim.prune.undo.UndoAddTimeOffset;
 import tim.prune.undo.UndoCompress;
+import tim.prune.undo.UndoConnectPhoto;
+import tim.prune.undo.UndoConnectPhotoWithClone;
+import tim.prune.undo.UndoCorrelatePhotos;
+import tim.prune.undo.UndoCreatePoint;
+import tim.prune.undo.UndoCutAndMove;
 import tim.prune.undo.UndoDeleteDuplicates;
+import tim.prune.undo.UndoDeletePhoto;
 import tim.prune.undo.UndoDeletePoint;
 import tim.prune.undo.UndoDeleteRange;
+import tim.prune.undo.UndoDisconnectPhoto;
 import tim.prune.undo.UndoEditPoint;
 import tim.prune.undo.UndoException;
 import tim.prune.undo.UndoInsert;
 import tim.prune.undo.UndoLoad;
 import tim.prune.undo.UndoLoadPhotos;
+import tim.prune.undo.UndoMergeTrackSegments;
 import tim.prune.undo.UndoOperation;
 import tim.prune.undo.UndoRearrangeWaypoints;
 import tim.prune.undo.UndoReverseSection;
@@ -51,10 +74,14 @@ public class App
        private MenuManager _menuManager = null;
        private FileLoader _fileLoader = null;
        private JpegLoader _jpegLoader = null;
+       private GpsLoader _gpsLoader = null;
+       private FileSaver _fileSaver = null;
+       private KmlExporter _kmlExporter = null;
+       private GpxExporter _gpxExporter = null;
        private PovExporter _povExporter = null;
+       private BrowserLauncher _browserLauncher = null;
        private Stack _undoStack = null;
-       private UpdateMessageBroker _broker = null;
-       private boolean _reversePointsConfirmed = false;
+       private boolean _mangleTimestampsConfirmed = false;
 
        // Constants
        public static final int REARRANGE_TO_START   = 0;
@@ -65,15 +92,13 @@ public class App
        /**
         * Constructor
         * @param inFrame frame object for application
-        * @param inBroker message broker
         */
-       public App(JFrame inFrame, UpdateMessageBroker inBroker)
+       public App(JFrame inFrame)
        {
                _frame = inFrame;
                _undoStack = new Stack();
-               _broker = inBroker;
-               _track = new Track(_broker);
-               _trackInfo = new TrackInfo(_track, _broker);
+               _track = new Track();
+               _trackInfo = new TrackInfo(_track);
        }
 
 
@@ -91,7 +116,8 @@ public class App
         */
        public boolean hasDataUnsaved()
        {
-               return _undoStack.size() > _lastSavePosition;
+               return (_undoStack.size() > _lastSavePosition
+                       && (_track.getNumPoints() > 0 || _trackInfo.getPhotoList().getNumPhotos() > 0));
        }
 
        /**
@@ -124,15 +150,24 @@ public class App
 
 
        /**
-        * Add a photo or a directory of photos which are already correlated
+        * Add a photo or a directory of photos
         */
        public void addPhotos()
        {
                if (_jpegLoader == null)
                        _jpegLoader = new JpegLoader(this, _frame);
-               _jpegLoader.openFile();
+               _jpegLoader.openDialog(new LatLonRectangle(_track.getLatRange(), _track.getLonRange()));
        }
 
+       /**
+        * Start a load from Gps
+        */
+       public void beginLoadFromGps()
+       {
+               if (_gpsLoader == null)
+                       _gpsLoader = new GpsLoader(this, _frame);
+               _gpsLoader.openDialog();
+       }
 
        /**
         * Save the file in the selected format
@@ -146,8 +181,12 @@ public class App
                }
                else
                {
-                       FileSaver saver = new FileSaver(this, _frame, _track);
-                       saver.showDialog(_fileLoader.getLastUsedDelimiter());
+                       if (_fileSaver == null) {
+                               _fileSaver = new FileSaver(this, _frame, _track);
+                       }
+                       char delim = ',';
+                       if (_fileLoader != null) {delim = _fileLoader.getLastUsedDelimiter();}
+                       _fileSaver.showDialog(delim);
                }
        }
 
@@ -164,8 +203,34 @@ public class App
                }
                else
                {
-                       KmlExporter exporter = new KmlExporter(this, _frame, _track);
-                       exporter.showDialog();
+                       // Invoke the export
+                       if (_kmlExporter == null)
+                       {
+                               _kmlExporter = new KmlExporter(_frame, _trackInfo);
+                       }
+                       _kmlExporter.showDialog();
+               }
+       }
+
+
+       /**
+        * Export track data as Gpx
+        */
+       public void exportGpx()
+       {
+               if (_track == null)
+               {
+                       JOptionPane.showMessageDialog(_frame, I18nManager.getText("error.save.nodata"),
+                               I18nManager.getText("error.save.dialogtitle"), JOptionPane.ERROR_MESSAGE);
+               }
+               else
+               {
+                       // Invoke the export
+                       if (_gpxExporter == null)
+                       {
+                               _gpxExporter = new GpxExporter(_frame, _trackInfo);
+                       }
+                       _gpxExporter.showDialog();
                }
        }
 
@@ -196,6 +261,7 @@ public class App
         * @param inX X component of unit vector
         * @param inY Y component of unit vector
         * @param inZ Z component of unit vector
+        * @param inAltitudeCap altitude cap
         */
        private void exportPov(boolean inDefineSettings, double inX, double inY, double inZ, int inAltitudeCap)
        {
@@ -210,7 +276,7 @@ public class App
                        // Make new exporter if necessary
                        if (_povExporter == null)
                        {
-                               _povExporter = new PovExporter(this, _frame, _track);
+                               _povExporter = new PovExporter(_frame, _track);
                        }
                        // Specify angles if necessary
                        if (inDefineSettings)
@@ -229,6 +295,9 @@ public class App
         */
        public void exit()
        {
+               // grab focus
+               _frame.toFront();
+               _frame.requestFocus();
                // check if ok to exit
                Object[] buttonTexts = {I18nManager.getText("button.exit"), I18nManager.getText("button.cancel")};
                if (!hasDataUnsaved()
@@ -262,7 +331,8 @@ public class App
 
        /**
         * Complete the point edit
-        * @param inEditList list of edits
+        * @param inEditList field values to edit
+        * @param inUndoList field values before edit
         */
        public void completePointEdit(FieldEditList inEditList, FieldEditList inUndoList)
        {
@@ -275,6 +345,8 @@ public class App
                        if (_track.editPoint(currentPoint, inEditList))
                        {
                                _undoStack.push(undo);
+                               // Confirm point edit
+                               UpdateMessageBroker.informSubscribers(I18nManager.getText("confirm.point.edit"));
                        }
                }
        }
@@ -292,7 +364,7 @@ public class App
                        {
                                // Open point dialog to display details
                                PointNameEditor editor = new PointNameEditor(this, _frame);
-                               editor.showDialog(_track, currentPoint);
+                               editor.showDialog(currentPoint);
                        }
                }
        }
@@ -303,19 +375,53 @@ public class App
         */
        public void deleteCurrentPoint()
        {
-               if (_track != null)
+               if (_track == null) {return;}
+               DataPoint currentPoint = _trackInfo.getCurrentPoint();
+               if (currentPoint != null)
                {
-                       DataPoint currentPoint = _trackInfo.getCurrentPoint();
-                       if (currentPoint != null)
+                       boolean deletePhoto = false;
+                       Photo currentPhoto = currentPoint.getPhoto();
+                       if (currentPhoto != null)
                        {
-                               // add information to undo stack
-                               int pointIndex = _trackInfo.getSelection().getCurrentPointIndex();
-                               UndoOperation undo = new UndoDeletePoint(pointIndex, currentPoint);
-                               // call track to delete point
-                               if (_trackInfo.deletePoint())
+                               // Confirm deletion of photo or decoupling
+                               int response = JOptionPane.showConfirmDialog(_frame,
+                                       I18nManager.getText("dialog.deletepoint.deletephoto") + " " + currentPhoto.getFile().getName(),
+                                       I18nManager.getText("dialog.deletepoint.title"),
+                                       JOptionPane.YES_NO_CANCEL_OPTION);
+                               if (response == JOptionPane.CANCEL_OPTION || response == JOptionPane.CLOSED_OPTION)
                                {
-                                       _undoStack.push(undo);
+                                       // cancel pressed- abort delete
+                                       return;
                                }
+                               if (response == JOptionPane.YES_OPTION) {deletePhoto = true;}
+                       }
+                       // store necessary information to undo it later
+                       int pointIndex = _trackInfo.getSelection().getCurrentPointIndex();
+                       int photoIndex = _trackInfo.getPhotoList().getPhotoIndex(currentPhoto);
+                       DataPoint nextTrackPoint = _trackInfo.getTrack().getNextTrackPoint(pointIndex + 1);
+                       // Construct Undo object
+                       UndoOperation undo = new UndoDeletePoint(pointIndex, currentPoint, photoIndex,
+                               nextTrackPoint != null && nextTrackPoint.getSegmentStart());
+                       // call track to delete point
+                       if (_trackInfo.deletePoint())
+                       {
+                               // Delete was successful so add undo info to stack
+                               _undoStack.push(undo);
+                               if (currentPhoto != null)
+                               {
+                                       // delete photo if necessary
+                                       if (deletePhoto)
+                                       {
+                                               _trackInfo.getPhotoList().deletePhoto(photoIndex);
+                                       }
+                                       else
+                                       {
+                                               // decouple photo from point
+                                               currentPhoto.setDataPoint(null);
+                                       }
+                               }
+                               // Confirm
+                               UpdateMessageBroker.informSubscribers(I18nManager.getText("confirm.deletepoint.single"));
                        }
                }
        }
@@ -328,12 +434,80 @@ public class App
        {
                if (_track != null)
                {
-                       // add information to undo stack
-                       UndoOperation undo = new UndoDeleteRange(_trackInfo);
-                       // call track to delete point
-                       if (_trackInfo.deleteRange())
+                       // Find out if photos should be deleted or not
+                       int selStart = _trackInfo.getSelection().getStart();
+                       int selEnd = _trackInfo.getSelection().getEnd();
+                       if (selStart >= 0 && selEnd >= selStart)
                        {
-                               _undoStack.push(undo);
+                               int numToDelete = selEnd - selStart + 1;
+                               boolean[] deletePhotos = new boolean[numToDelete];
+                               Photo[] photosToDelete = new Photo[numToDelete];
+                               boolean deleteAll = false;
+                               boolean deleteNone = false;
+                               String[] questionOptions = {I18nManager.getText("button.yes"), I18nManager.getText("button.no"),
+                                       I18nManager.getText("button.yestoall"), I18nManager.getText("button.notoall"),
+                                       I18nManager.getText("button.cancel")};
+                               DataPoint point = null;
+                               for (int i=0; i<numToDelete; i++)
+                               {
+                                       point = _trackInfo.getTrack().getPoint(i + selStart);
+                                       if (point != null && point.getPhoto() != null)
+                                       {
+                                               if (deleteAll)
+                                               {
+                                                       deletePhotos[i] = true;
+                                                       photosToDelete[i] = point.getPhoto();
+                                               }
+                                               else if (deleteNone) {deletePhotos[i] = false;}
+                                               else
+                                               {
+                                                       int response = JOptionPane.showOptionDialog(_frame,
+                                                               I18nManager.getText("dialog.deletepoint.deletephoto") + " " + point.getPhoto().getFile().getName(),
+                                                               I18nManager.getText("dialog.deletepoint.title"),
+                                                               JOptionPane.YES_NO_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE, null,
+                                                               questionOptions, questionOptions[1]);
+                                                       // check for cancel or close
+                                                       if (response == 4 || response == -1) {return;}
+                                                       // check for yes or yes to all
+                                                       if (response == 0 || response == 2)
+                                                       {
+                                                               deletePhotos[i] = true;
+                                                               photosToDelete[i] = point.getPhoto();
+                                                               if (response == 2) {deleteAll = true;}
+                                                       }
+                                                       // check for no to all
+                                                       if (response == 3) {deleteNone = true;}
+                                               }
+                                       }
+                               }
+                               // add information to undo stack
+                               UndoDeleteRange undo = new UndoDeleteRange(_trackInfo);
+                               // delete requested photos
+                               for (int i=0; i<numToDelete; i++)
+                               {
+                                       point = _trackInfo.getTrack().getPoint(i + selStart);
+                                       if (point != null && point.getPhoto() != null)
+                                       {
+                                               if (deletePhotos[i])
+                                               {
+                                                       // delete photo from list
+                                                       _trackInfo.getPhotoList().deletePhoto(_trackInfo.getPhotoList().getPhotoIndex(point.getPhoto()));
+                                               }
+                                               else
+                                               {
+                                                       // decouple from point
+                                                       point.getPhoto().setDataPoint(null);
+                                               }
+                                       }
+                               }
+                               // call track to delete range
+                               if (_trackInfo.deleteRange())
+                               {
+                                       _undoStack.push(undo);
+                                       // Confirm
+                                       UpdateMessageBroker.informSubscribers("" + numToDelete + " "
+                                               + I18nManager.getText("confirm.deletepoint.multi"));
+                               }
                        }
                }
        }
@@ -356,17 +530,18 @@ public class App
                                String message = null;
                                if (numDeleted == 1)
                                {
-                                       message = "1 " + I18nManager.getText("dialog.deleteduplicates.single.text");
+                                       message = "1 " + I18nManager.getText("confirm.deleteduplicates.single");
                                }
                                else
                                {
-                                       message = "" + numDeleted + " " + I18nManager.getText("dialog.deleteduplicates.multi.text");
+                                       message = "" + numDeleted + " " + I18nManager.getText("confirm.deleteduplicates.multi");
                                }
-                               JOptionPane.showMessageDialog(_frame, message,
-                                       I18nManager.getText("dialog.deleteduplicates.title"), JOptionPane.INFORMATION_MESSAGE);
+                               // Pass message to broker
+                               UpdateMessageBroker.informSubscribers(message);
                        }
                        else
                        {
+                               // No duplicates found to delete
                                JOptionPane.showMessageDialog(_frame,
                                        I18nManager.getText("dialog.deleteduplicates.nonefound"),
                                        I18nManager.getText("dialog.deleteduplicates.title"), JOptionPane.INFORMATION_MESSAGE);
@@ -395,11 +570,8 @@ public class App
                {
                        undo.setNumPointsDeleted(numPointsDeleted);
                        _undoStack.add(undo);
-                       JOptionPane.showMessageDialog(_frame,
-                               I18nManager.getText("dialog.compresstrack.text") + " - "
-                                + numPointsDeleted + " "
-                                + (numPointsDeleted==1?I18nManager.getText("dialog.compresstrack.single.text"):I18nManager.getText("dialog.compresstrack.multi.text")),
-                               I18nManager.getText("dialog.compresstrack.title"), JOptionPane.INFORMATION_MESSAGE);
+                       UpdateMessageBroker.informSubscribers("" + numPointsDeleted + " "
+                                + (numPointsDeleted==1?I18nManager.getText("confirm.deletepoint.single"):I18nManager.getText("confirm.deletepoint.multi")));
                }
                else
                {
@@ -410,7 +582,7 @@ public class App
 
 
        /**
-        * Reverse a section of the track
+        * Reverse the currently selected section of the track
         */
        public void reverseRange()
        {
@@ -418,17 +590,78 @@ public class App
                int selStart = _trackInfo.getSelection().getStart();
                int selEnd = _trackInfo.getSelection().getEnd();
                if (!_track.hasData(Field.TIMESTAMP, selStart, selEnd)
-                       || _reversePointsConfirmed
+                       || _mangleTimestampsConfirmed
                        || (JOptionPane.showConfirmDialog(_frame,
                                 I18nManager.getText("dialog.confirmreversetrack.text"),
                                 I18nManager.getText("dialog.confirmreversetrack.title"),
-                                JOptionPane.YES_NO_OPTION) == JOptionPane.YES_OPTION && (_reversePointsConfirmed = true)))
+                                JOptionPane.YES_NO_OPTION) == JOptionPane.YES_OPTION && (_mangleTimestampsConfirmed = true)))
                {
-                       UndoReverseSection undo = new UndoReverseSection(selStart, selEnd);
+                       UndoReverseSection undo = new UndoReverseSection(_track, selStart, selEnd);
                        // call track to reverse range
                        if (_track.reverseRange(selStart, selEnd))
                        {
                                _undoStack.add(undo);
+                               // Confirm
+                               UpdateMessageBroker.informSubscribers(I18nManager.getText("confirm.reverserange"));
+                       }
+               }
+       }
+
+       /**
+        * Trigger the dialog to add a time offset to the current selection
+        */
+       public void beginAddTimeOffset()
+       {
+               int selStart = _trackInfo.getSelection().getStart();
+               int selEnd = _trackInfo.getSelection().getEnd();
+               if (!_track.hasData(Field.TIMESTAMP, selStart, selEnd)) {
+                       JOptionPane.showMessageDialog(_frame,
+                               I18nManager.getText("dialog.addtimeoffset.notimestamps"),
+                               I18nManager.getText("dialog.addtimeoffset.title"), JOptionPane.ERROR_MESSAGE);
+               }
+               else {
+                       TimeOffsetDialog timeDialog = new TimeOffsetDialog(this, _frame);
+                       timeDialog.showDialog();
+               }
+       }
+
+       /**
+        * Complete the add time offset function with the specified offset
+        * @param inTimeOffset time offset to add (+ve for add, -ve for subtract)
+        */
+       public void finishAddTimeOffset(long inTimeOffset)
+       {
+               // Construct undo information
+               int selStart = _trackInfo.getSelection().getStart();
+               int selEnd = _trackInfo.getSelection().getEnd();
+               UndoAddTimeOffset undo = new UndoAddTimeOffset(selStart, selEnd, inTimeOffset);
+               if (_trackInfo.getTrack().addTimeOffset(selStart, selEnd, inTimeOffset))
+               {
+                       _undoStack.add(undo);
+                       UpdateMessageBroker.informSubscribers(DataSubscriber.DATA_EDITED);
+                       UpdateMessageBroker.informSubscribers(I18nManager.getText("confirm.addtimeoffset"));
+               }
+       }
+
+
+       /**
+        * Merge the track segments within the current selection
+        */
+       public void mergeTrackSegments()
+       {
+               if (_trackInfo.getSelection().hasRangeSelected())
+               {
+                       // Maybe could check segment start flags to see if it's worth merging
+                       // If first track point is already start and no other seg starts then do nothing
+
+                       int selStart = _trackInfo.getSelection().getStart();
+                       int selEnd = _trackInfo.getSelection().getEnd();
+                       // Make undo object
+                       UndoMergeTrackSegments undo = new UndoMergeTrackSegments(_track, selStart, selEnd);
+                       // Call track to merge segments
+                       if (_track.mergeTrackSegments(selStart, selEnd)) {
+                               _undoStack.add(undo);
+                               UpdateMessageBroker.informSubscribers(I18nManager.getText("confirm.mergetracksegments"));
                        }
                }
        }
@@ -457,8 +690,30 @@ public class App
        }
 
 
+       /**
+        * Create a new point at the given lat/long coordinates
+        * @param inLat latitude
+        * @param inLong longitude
+        */
+       public void createPoint(double inLat, double inLong)
+       {
+               // create undo object
+               UndoCreatePoint undo = new UndoCreatePoint();
+               // create point and add to track
+               DataPoint point = new DataPoint(new Latitude(inLat, Coordinate.FORMAT_NONE), new Longitude(inLong, Coordinate.FORMAT_NONE), null);
+               point.setSegmentStart(true);
+               _track.appendPoints(new DataPoint[] {point});
+               _trackInfo.getSelection().selectPoint(_trackInfo.getTrack().getNumPoints()-1);
+               // add undo object to stack
+               _undoStack.add(undo);
+               // update listeners
+               UpdateMessageBroker.informSubscribers(I18nManager.getText("confirm.createpoint"));
+       }
+
+
        /**
         * Rearrange the waypoints into track order
+        * @param inFunction nearest point, all to end or all to start
         */
        public void rearrangeWaypoints(int inFunction)
        {
@@ -477,6 +732,7 @@ public class App
                if (success)
                {
                        _undoStack.add(undo);
+                       UpdateMessageBroker.informSubscribers(I18nManager.getText("confirm.rearrangewaypoints"));
                }
                else
                {
@@ -486,6 +742,46 @@ public class App
        }
 
 
+       /**
+        * Cut the current selection and move it to before the currently selected point
+        */
+       public void cutAndMoveSelection()
+       {
+               int startIndex = _trackInfo.getSelection().getStart();
+               int endIndex = _trackInfo.getSelection().getEnd();
+               int pointIndex = _trackInfo.getSelection().getCurrentPointIndex();
+               // If timestamps would be mangled by cut/move, confirm
+               if (!_track.hasData(Field.TIMESTAMP, startIndex, endIndex)
+                       || _mangleTimestampsConfirmed
+                       || (JOptionPane.showConfirmDialog(_frame,
+                                I18nManager.getText("dialog.confirmcutandmove.text"),
+                                I18nManager.getText("dialog.confirmcutandmove.title"),
+                                JOptionPane.YES_NO_OPTION) == JOptionPane.YES_OPTION && (_mangleTimestampsConfirmed = true)))
+               {
+                       // Find points to set segment flags
+                       DataPoint firstTrackPoint = _track.getNextTrackPoint(startIndex, endIndex);
+                       DataPoint nextTrackPoint = _track.getNextTrackPoint(endIndex+1);
+                       DataPoint moveToTrackPoint = _track.getNextTrackPoint(pointIndex);
+                       // Make undo object
+                       UndoCutAndMove undo = new UndoCutAndMove(_track, startIndex, endIndex, pointIndex);
+                       // Call track info to move track section
+                       if (_track.cutAndMoveSection(startIndex, endIndex, pointIndex))
+                       {
+                               // Set segment start flags (first track point, next track point, move to point)
+                               if (firstTrackPoint != null) {firstTrackPoint.setSegmentStart(true);}
+                               if (nextTrackPoint != null) {nextTrackPoint.setSegmentStart(true);}
+                               if (moveToTrackPoint != null) {moveToTrackPoint.setSegmentStart(true);}
+
+                               // Add undo object to stack, set confirm message
+                               _undoStack.add(undo);
+                               _trackInfo.getSelection().deselectRange();
+                               UpdateMessageBroker.informSubscribers();
+                               UpdateMessageBroker.informSubscribers(I18nManager.getText("confirm.cutandmove"));
+                       }
+               }
+       }
+
+
        /**
         * Open a new window with the 3d view
         */
@@ -527,51 +823,102 @@ public class App
         */
        public void selectNone()
        {
+               // deselect point, range and photo
                _trackInfo.getSelection().clearAll();
        }
 
+
        /**
         * Receive loaded data and optionally merge with current Track
         * @param inFieldArray array of fields
         * @param inDataArray array of data
+        * @param inAltFormat altitude format
+        * @param inFilename filename used
         */
        public void informDataLoaded(Field[] inFieldArray, Object[][] inDataArray, int inAltFormat, String inFilename)
        {
+               informDataLoaded(inFieldArray, inDataArray, inAltFormat, inFilename, false);
+       }
+
+       /**
+        * Receive loaded data and optionally merge with current Track
+        * @param inFieldArray array of fields
+        * @param inDataArray array of data
+        * @param inAltFormat altitude format
+        * @param inFilename filename used
+        * @param inOverrideAppend true to override append question and always append
+        */
+       public void informDataLoaded(Field[] inFieldArray, Object[][] inDataArray, int inAltFormat,
+               String inFilename, boolean inOverrideAppend)
+       {
+               // Check whether loaded array can be properly parsed into a Track
+               Track loadedTrack = new Track();
+               loadedTrack.load(inFieldArray, inDataArray, inAltFormat);
+               if (loadedTrack.getNumPoints() <= 0)
+               {
+                       JOptionPane.showMessageDialog(_frame,
+                               I18nManager.getText("error.load.nopoints"),
+                               I18nManager.getText("error.load.dialogtitle"),
+                               JOptionPane.ERROR_MESSAGE);
+                       return;
+               }
                // Decide whether to load or append
                if (_track != null && _track.getNumPoints() > 0)
                {
                        // ask whether to replace or append
-                       int answer = JOptionPane.showConfirmDialog(_frame,
-                               I18nManager.getText("dialog.openappend.text"),
-                               I18nManager.getText("dialog.openappend.title"),
-                               JOptionPane.YES_NO_CANCEL_OPTION);
+                       int answer = JOptionPane.YES_OPTION;
+                       if (!inOverrideAppend) {
+                               answer = JOptionPane.showConfirmDialog(_frame,
+                                       I18nManager.getText("dialog.openappend.text"),
+                                       I18nManager.getText("dialog.openappend.title"),
+                                       JOptionPane.YES_NO_CANCEL_OPTION);
+                       }
                        if (answer == JOptionPane.YES_OPTION)
                        {
                                // append data to current Track
-                               Track loadedTrack = new Track(_broker);
-                               loadedTrack.load(inFieldArray, inDataArray, inAltFormat);
                                _undoStack.add(new UndoLoad(_track.getNumPoints(), loadedTrack.getNumPoints()));
                                _track.combine(loadedTrack);
-                               _trackInfo.getFileInfo().addFile();
+                               // set filename if currently empty
+                               if (_trackInfo.getFileInfo().getNumFiles() == 0)
+                               {
+                                       _trackInfo.getFileInfo().setFile(inFilename);
+                               }
+                               else
+                               {
+                                       _trackInfo.getFileInfo().addFile();
+                               }
                        }
                        else if (answer == JOptionPane.NO_OPTION)
                        {
                                // Don't append, replace data
-                               _undoStack.add(new UndoLoad(_trackInfo, inDataArray.length));
+                               PhotoList photos = null;
+                               if (_trackInfo.getPhotoList().hasCorrelatedPhotos())
+                               {
+                                       photos = _trackInfo.getPhotoList().cloneList();
+                               }
+                               _undoStack.add(new UndoLoad(_trackInfo, inDataArray.length, photos));
                                _lastSavePosition = _undoStack.size();
+                               // TODO: Should be possible to reuse the Track object already loaded?
+                               _trackInfo.selectPoint(null);
                                _trackInfo.loadTrack(inFieldArray, inDataArray, inAltFormat);
                                _trackInfo.getFileInfo().setFile(inFilename);
+                               if (photos != null)
+                               {
+                                       _trackInfo.getPhotoList().removeCorrelatedPhotos();
+                               }
                        }
                }
                else
                {
                        // currently no data held, so use received data
-                       _undoStack.add(new UndoLoad(_trackInfo, inDataArray.length));
+                       _undoStack.add(new UndoLoad(_trackInfo, inDataArray.length, null));
                        _lastSavePosition = _undoStack.size();
                        _trackInfo.loadTrack(inFieldArray, inDataArray, inAltFormat);
                        _trackInfo.getFileInfo().setFile(inFilename);
                }
-               _broker.informSubscribers();
+               UpdateMessageBroker.informSubscribers();
+               // Update status bar
+               UpdateMessageBroker.informSubscribers(I18nManager.getText("confirm.loadfile") + " '" + inFilename + "'");
                // update menu
                _menuManager.informFileLoaded();
        }
@@ -579,38 +926,264 @@ public class App
 
        /**
         * Accept a list of loaded photos
-        * @param inPhotoList List of Photo objects
+        * @param inPhotoSet Set of Photo objects
         */
-       public void informPhotosLoaded(List inPhotoList)
+       public void informPhotosLoaded(Set inPhotoSet)
        {
-               if (inPhotoList != null && !inPhotoList.isEmpty())
+               if (inPhotoSet != null && !inPhotoSet.isEmpty())
                {
-                       // TODO: Attempt to restrict loaded photos to current area (if any) ?
-                       int numAdded = _trackInfo.addPhotos(inPhotoList);
-                       if (numAdded > 0)
+                       int[] numsAdded = _trackInfo.addPhotos(inPhotoSet);
+                       int numPhotosAdded = numsAdded[0];
+                       int numPointsAdded = numsAdded[1];
+                       if (numPhotosAdded > 0)
                        {
-                               _undoStack.add(new UndoLoadPhotos(numAdded));
+                               // Save numbers so load can be undone
+                               _undoStack.add(new UndoLoadPhotos(numPhotosAdded, numPointsAdded));
                        }
-                       if (numAdded == 1)
+                       if (numPhotosAdded == 1)
                        {
-                               JOptionPane.showMessageDialog(_frame,
-                                       "" + numAdded + " " + I18nManager.getText("dialog.jpegload.photoadded"),
-                                       I18nManager.getText("dialog.jpegload.title"), JOptionPane.INFORMATION_MESSAGE);
+                               UpdateMessageBroker.informSubscribers("" + numPhotosAdded + " " + I18nManager.getText("confirm.jpegload.single"));
                        }
                        else
                        {
-                               JOptionPane.showMessageDialog(_frame,
-                                       "" + numAdded + " " + I18nManager.getText("dialog.jpegload.photosadded"),
-                                       I18nManager.getText("dialog.jpegload.title"), JOptionPane.INFORMATION_MESSAGE);
+                               UpdateMessageBroker.informSubscribers("" + numPhotosAdded + " " + I18nManager.getText("confirm.jpegload.multi"));
                        }
                        // TODO: Improve message when photo(s) fail to load (eg already added)
-                       _broker.informSubscribers();
+                       UpdateMessageBroker.informSubscribers();
                        // update menu
                        _menuManager.informFileLoaded();
                }
        }
 
 
+       /**
+        * Connect the current photo to the current point
+        */
+       public void connectPhotoToPoint()
+       {
+               Photo photo = _trackInfo.getCurrentPhoto();
+               DataPoint point = _trackInfo.getCurrentPoint();
+               if (photo != null && point != null)
+               {
+                       if (point.getPhoto() != null)
+                       {
+                               // point already has a photo, confirm cloning of new point
+                               if (JOptionPane.showConfirmDialog(_frame,
+                                       I18nManager.getText("dialog.connectphoto.clonepoint"),
+                                       I18nManager.getText("dialog.connect.title"),
+                                       JOptionPane.OK_CANCEL_OPTION) == JOptionPane.OK_OPTION)
+                               {
+                                       // Create undo, clone point and attach
+                                       int pointIndex = _trackInfo.getSelection().getCurrentPointIndex() + 1;
+                                       // insert new point after current one
+                                       point = point.clonePoint();
+                                       UndoConnectPhotoWithClone undo = new UndoConnectPhotoWithClone(
+                                               point, photo.getFile().getName(), pointIndex);
+                                       _track.insertPoint(point, pointIndex);
+                                       photo.setDataPoint(point);
+                                       point.setPhoto(photo);
+                                       _undoStack.add(undo);
+                                       UpdateMessageBroker.informSubscribers(DataSubscriber.SELECTION_CHANGED);
+                                       UpdateMessageBroker.informSubscribers(I18nManager.getText("confirm.photo.connect"));
+                               }
+                       }
+                       else
+                       {
+                               // point doesn't currently have a photo, so just connect it
+                               _undoStack.add(new UndoConnectPhoto(point, photo.getFile().getName()));
+                               photo.setDataPoint(point);
+                               point.setPhoto(photo);
+                               UpdateMessageBroker.informSubscribers(DataSubscriber.SELECTION_CHANGED);
+                               UpdateMessageBroker.informSubscribers(I18nManager.getText("confirm.photo.connect"));
+                       }
+               }
+       }
+
+
+       /**
+        * Disconnect the current photo from its point
+        */
+       public void disconnectPhotoFromPoint()
+       {
+               Photo photo = _trackInfo.getCurrentPhoto();
+               if (photo != null && photo.getDataPoint() != null)
+               {
+                       DataPoint point = photo.getDataPoint();
+                       _undoStack.add(new UndoDisconnectPhoto(point, photo.getFile().getName()));
+                       // disconnect
+                       photo.setDataPoint(null);
+                       point.setPhoto(null);
+                       UpdateMessageBroker.informSubscribers(DataSubscriber.SELECTION_CHANGED);
+                       UpdateMessageBroker.informSubscribers(I18nManager.getText("confirm.photo.disconnect"));
+               }
+       }
+
+
+       /**
+        * Remove the current photo, if any
+        */
+       public void deleteCurrentPhoto()
+       {
+               // Delete the current photo, and optionally its point too, keeping undo information
+               Photo currentPhoto = _trackInfo.getCurrentPhoto();
+               if (currentPhoto != null)
+               {
+                       // Photo is selected, see if it has a point or not
+                       boolean photoDeleted = false;
+                       UndoDeletePhoto undoAction = null;
+                       if (currentPhoto.getDataPoint() == null)
+                       {
+                               // no point attached, so just delete photo
+                               undoAction = new UndoDeletePhoto(currentPhoto, _trackInfo.getSelection().getCurrentPhotoIndex(),
+                                       null, -1);
+                               photoDeleted = _trackInfo.deleteCurrentPhoto(false);
+                       }
+                       else
+                       {
+                               // point is attached, so need to confirm point deletion
+                               undoAction = new UndoDeletePhoto(currentPhoto, _trackInfo.getSelection().getCurrentPhotoIndex(),
+                                       currentPhoto.getDataPoint(), _trackInfo.getTrack().getPointIndex(currentPhoto.getDataPoint()));
+                               int response = JOptionPane.showConfirmDialog(_frame,
+                                       I18nManager.getText("dialog.deletephoto.deletepoint"),
+                                       I18nManager.getText("dialog.deletephoto.title"),
+                                       JOptionPane.YES_NO_CANCEL_OPTION);
+                               boolean deletePointToo = (response == JOptionPane.YES_OPTION);
+                               // Cancel delete if cancel pressed or dialog closed
+                               if (response == JOptionPane.YES_OPTION || response == JOptionPane.NO_OPTION)
+                               {
+                                       photoDeleted = _trackInfo.deleteCurrentPhoto(deletePointToo);
+                               }
+                       }
+                       // Add undo information to stack if necessary
+                       if (photoDeleted)
+                       {
+                               _undoStack.add(undoAction);
+                       }
+               }
+       }
+
+
+       /**
+        * Begin the photo correlation process by invoking dialog
+        */
+       public void beginCorrelatePhotos()
+       {
+               PhotoCorrelator correlator = new PhotoCorrelator(this, _frame);
+               // TODO: Do we need to keep a reference to this Photo Correlator object to reuse it later?
+               correlator.begin();
+       }
+
+
+       /**
+        * Finish the photo correlation process
+        * @param inPointPairs array of PointPair objects describing operation
+        */
+       public void finishCorrelatePhotos(PointPair[] inPointPairs)
+       {
+               // TODO: This method is too big for App, but where should it go?
+               if (inPointPairs != null && inPointPairs.length > 0)
+               {
+                       // begin to construct undo information
+                       UndoCorrelatePhotos undo = new UndoCorrelatePhotos(_trackInfo);
+                       // loop over Photos
+                       int arraySize = inPointPairs.length;
+                       int i = 0, numPhotos = 0;
+                       int numPointsToCreate = 0;
+                       PointPair pair = null;
+                       for (i=0; i<arraySize; i++)
+                       {
+                               pair = inPointPairs[i];
+                               if (pair != null && pair.isValid())
+                               {
+                                       if (pair.getMinSeconds() == 0L)
+                                       {
+                                               // exact match
+                                               Photo pointPhoto = pair.getPointBefore().getPhoto();
+                                               if (pointPhoto == null)
+                                               {
+                                                       // photo coincides with photoless point so connect the two
+                                                       pair.getPointBefore().setPhoto(pair.getPhoto());
+                                                       pair.getPhoto().setDataPoint(pair.getPointBefore());
+                                               }
+                                               else if (pointPhoto.equals(pair.getPhoto()))
+                                               {
+                                                       // photo is already connected, nothing to do
+                                               }
+                                               else
+                                               {
+                                                       // point is already connected to a different photo, so need to clone point
+                                                       numPointsToCreate++;
+                                               }
+                                       }
+                                       else
+                                       {
+                                               // photo time falls between two points, so need to interpolate new one
+                                               numPointsToCreate++;
+                                       }
+                                       numPhotos++;
+                               }
+                       }
+                       // Second loop, to create points if necessary
+                       if (numPointsToCreate > 0)
+                       {
+                               // make new array for added points
+                               DataPoint[] addedPoints = new DataPoint[numPointsToCreate];
+                               int pointNum = 0;
+                               DataPoint pointToAdd = null;
+                               for (i=0; i<arraySize; i++)
+                               {
+                                       pair = inPointPairs[i];
+                                       if (pair != null && pair.isValid())
+                                       {
+                                               pointToAdd = null;
+                                               if (pair.getMinSeconds() == 0L && pair.getPointBefore().getPhoto() != null
+                                                && !pair.getPointBefore().getPhoto().equals(pair.getPhoto()))
+                                               {
+                                                       // clone point
+                                                       pointToAdd = pair.getPointBefore().clonePoint();
+                                               }
+                                               else if (pair.getMinSeconds() > 0L)
+                                               {
+                                                       // interpolate point
+                                                       pointToAdd = DataPoint.interpolate(pair.getPointBefore(), pair.getPointAfter(), pair.getFraction());
+                                               }
+                                               if (pointToAdd != null)
+                                               {
+                                                       // link photo to point
+                                                       pointToAdd.setPhoto(pair.getPhoto());
+                                                       pair.getPhoto().setDataPoint(pointToAdd);
+                                                       // set to start of segment so not joined in track
+                                                       pointToAdd.setSegmentStart(true);
+                                                       // add to point array
+                                                       addedPoints[pointNum] = pointToAdd;
+                                                       pointNum++;
+                                               }
+                                       }
+                               }
+                               // expand track
+                               _track.appendPoints(addedPoints);
+                       }
+                       // add undo information to stack
+                       undo.setNumPhotosCorrelated(numPhotos);
+                       _undoStack.add(undo);
+                       // confirm correlation
+                       UpdateMessageBroker.informSubscribers("" + numPhotos + " "
+                                + (numPhotos==1?I18nManager.getText("confirm.correlate.single"):I18nManager.getText("confirm.correlate.multi")));
+                       // observers already informed by track update
+               }
+       }
+
+
+       /**
+        * Save the coordinates of photos in their exif data
+        */
+       public void saveExif()
+       {
+               ExifSaver saver = new ExifSaver(_frame);
+               saver.saveExifInformation(_trackInfo.getPhotoList());
+       }
+
+
        /**
         * Inform the app that the data has been saved
         */
@@ -627,6 +1200,7 @@ public class App
        {
                if (_undoStack.isEmpty())
                {
+                       // Nothing to undo
                        JOptionPane.showMessageDialog(_frame, I18nManager.getText("dialog.undo.none.text"),
                                I18nManager.getText("dialog.undo.none.title"), JOptionPane.INFORMATION_MESSAGE);
                }
@@ -657,7 +1231,7 @@ public class App
                        _undoStack.clear();
                        _lastSavePosition = 0;
                        if (unsaved) _lastSavePosition = -1;
-                       _broker.informSubscribers();
+                       UpdateMessageBroker.informSubscribers();
                }
        }
 
@@ -674,10 +1248,9 @@ public class App
                        {
                                ((UndoOperation) _undoStack.pop()).performUndo(_trackInfo);
                        }
-                       JOptionPane.showMessageDialog(_frame, "" + inNumUndos + " "
-                                + (inNumUndos==1?I18nManager.getText("dialog.confirmundo.single.text"):I18nManager.getText("dialog.confirmundo.multiple.text")),
-                               I18nManager.getText("dialog.confirmundo.title"),
-                               JOptionPane.INFORMATION_MESSAGE);
+                       String message = "" + inNumUndos + " "
+                                + (inNumUndos==1?I18nManager.getText("confirm.undo.single"):I18nManager.getText("confirm.undo.multi"));
+                       UpdateMessageBroker.informSubscribers(message);
                }
                catch (UndoException ue)
                {
@@ -686,7 +1259,7 @@ public class App
                                I18nManager.getText("error.undofailed.title"),
                                JOptionPane.ERROR_MESSAGE);
                        _undoStack.clear();
-                       _broker.informSubscribers();
+                       UpdateMessageBroker.informSubscribers();
                }
                catch (EmptyStackException empty) {}
        }
@@ -711,4 +1284,31 @@ public class App
                }
                return num;
        }
+
+       /**
+        * Show a brief help message
+        */
+       public void showHelp()
+       {
+               // show the dialog and offer to open home page
+               Object[] buttonTexts = {I18nManager.getText("button.showwebpage"), I18nManager.getText("button.cancel")};
+               if (JOptionPane.showOptionDialog(_frame, I18nManager.getText("dialog.help.help"),
+                               I18nManager.getText("menu.help"), JOptionPane.YES_NO_OPTION,
+                               JOptionPane.INFORMATION_MESSAGE, null, buttonTexts, buttonTexts[1])
+                       == JOptionPane.YES_OPTION)
+               {
+                       // User selected to launch home page
+                       if (_browserLauncher == null) {_browserLauncher = new BrowserLauncher();}
+                       _browserLauncher.launchBrowser("http://activityworkshop.net/software/prune/index.html");
+               }
+       }
+
+       /**
+        * Show a map url in an external browser
+        */
+       public void showExternalMap(int inSourceIndex)
+       {
+               if (_browserLauncher == null) {_browserLauncher = new BrowserLauncher();}
+               _browserLauncher.launchBrowser(UrlGenerator.generateUrl(inSourceIndex, _trackInfo));
+       }
 }