]> gitweb.fperrin.net Git - GpsPrune.git/blob - src/tim/prune/save/GpxExporter.java
Version 20.4, May 2021
[GpsPrune.git] / src / tim / prune / save / GpxExporter.java
1 package tim.prune.save;
2
3 import java.awt.BorderLayout;
4 import java.awt.Component;
5 import java.awt.FlowLayout;
6 import java.awt.GridLayout;
7 import java.awt.event.ActionEvent;
8 import java.awt.event.ActionListener;
9 import java.io.File;
10 import java.io.FileOutputStream;
11 import java.io.IOException;
12 import java.io.OutputStreamWriter;
13 import java.io.Writer;
14
15 import javax.swing.BorderFactory;
16 import javax.swing.Box;
17 import javax.swing.BoxLayout;
18 import javax.swing.ButtonGroup;
19 import javax.swing.JButton;
20 import javax.swing.JCheckBox;
21 import javax.swing.JDialog;
22 import javax.swing.JFileChooser;
23 import javax.swing.JFrame;
24 import javax.swing.JLabel;
25 import javax.swing.JOptionPane;
26 import javax.swing.JPanel;
27 import javax.swing.JRadioButton;
28 import javax.swing.JTextField;
29 import javax.swing.border.EtchedBorder;
30
31 import tim.prune.App;
32 import tim.prune.GenericFunction;
33 import tim.prune.GpsPrune;
34 import tim.prune.I18nManager;
35 import tim.prune.UpdateMessageBroker;
36 import tim.prune.config.Config;
37 import tim.prune.data.AudioClip;
38 import tim.prune.data.Coordinate;
39 import tim.prune.data.DataPoint;
40 import tim.prune.data.Field;
41 import tim.prune.data.MediaObject;
42 import tim.prune.data.Photo;
43 import tim.prune.data.RecentFile;
44 import tim.prune.data.SourceInfo;
45 import tim.prune.data.Timestamp;
46 import tim.prune.data.TrackInfo;
47 import tim.prune.data.UnitSetLibrary;
48 import tim.prune.gui.DialogCloser;
49 import tim.prune.load.GenericFileFilter;
50 import tim.prune.save.xml.GpxCacherList;
51 import tim.prune.save.xml.XmlUtils;
52
53
54 /**
55  * Class to export track information
56  * into a specified Gpx file
57  */
58 public class GpxExporter extends GenericFunction implements Runnable
59 {
60         private TrackInfo _trackInfo = null;
61         private JDialog _dialog = null;
62         private JTextField _nameField = null;
63         private JTextField _descriptionField = null;
64         private PointTypeSelector _pointTypeSelector = null;
65         private JCheckBox _timestampsCheckbox = null;
66         private JCheckBox _copySourceCheckbox = null;
67         private JPanel _encodingsPanel = null;
68         private JRadioButton _useSystemRadio = null, _forceUtf8Radio = null;
69         private File _exportFile = null;
70         /** Remember the previous sourceInfo object to tell whether it has changed */
71         private SourceInfo _previousSourceInfo = null;
72
73         /** this program name */
74         private static final String GPX_CREATOR = "GpsPrune v" + GpsPrune.VERSION_NUMBER + " activityworkshop.net";
75
76
77         /**
78          * Constructor
79          * @param inApp app object
80          */
81         public GpxExporter(App inApp)
82         {
83                 super(inApp);
84                 _trackInfo = inApp.getTrackInfo();
85         }
86
87         /** Get name key */
88         public String getNameKey() {
89                 return "function.exportgpx";
90         }
91
92         /**
93          * Show the dialog to select options and export file
94          */
95         public void begin()
96         {
97                 // Make dialog window
98                 if (_dialog == null)
99                 {
100                         _dialog = new JDialog(_parentFrame, I18nManager.getText(getNameKey()), true);
101                         _dialog.setLocationRelativeTo(_parentFrame);
102                         _dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
103                         _dialog.getContentPane().add(makeDialogComponents());
104                         _dialog.pack();
105                 }
106                 _pointTypeSelector.init(_app.getTrackInfo());
107                 _encodingsPanel.setVisible(!XmlUtils.isSystemUtf8());
108                 if (!XmlUtils.isSystemUtf8())
109                 {
110                         String systemEncoding = XmlUtils.getSystemEncoding();
111                         _useSystemRadio.setText(I18nManager.getText("dialog.exportgpx.encoding.system")
112                                 + " (" + (systemEncoding == null ? "unknown" : systemEncoding) + ")");
113                 }
114                 setFileTitle();
115                 _dialog.setVisible(true);
116         }
117
118
119         /**
120          * Create dialog components
121          * @return Panel containing all gui elements in dialog
122          */
123         private Component makeDialogComponents()
124         {
125                 JPanel dialogPanel = new JPanel();
126                 dialogPanel.setLayout(new BorderLayout());
127                 JPanel mainPanel = new JPanel();
128                 mainPanel.setLayout(new BoxLayout(mainPanel, BoxLayout.Y_AXIS));
129                 // Make a panel for the name/desc text boxes
130                 JPanel descPanel = new JPanel();
131                 descPanel.setLayout(new GridLayout(2, 2));
132                 descPanel.add(new JLabel(I18nManager.getText("dialog.exportgpx.name")));
133                 _nameField = new JTextField(10);
134                 descPanel.add(_nameField);
135                 descPanel.add(new JLabel(I18nManager.getText("dialog.exportgpx.desc")));
136                 _descriptionField = new JTextField(10);
137                 descPanel.add(_descriptionField);
138                 mainPanel.add(descPanel);
139                 mainPanel.add(Box.createVerticalStrut(5));
140                 // point type selection (track points, waypoints, photo points)
141                 _pointTypeSelector = new PointTypeSelector();
142                 mainPanel.add(_pointTypeSelector);
143                 // checkboxes for timestamps and copying
144                 JPanel checkPanel = new JPanel();
145                 _timestampsCheckbox = new JCheckBox(I18nManager.getText("dialog.exportgpx.includetimestamps"));
146                 _timestampsCheckbox.setSelected(true);
147                 checkPanel.add(_timestampsCheckbox);
148                 _copySourceCheckbox = new JCheckBox(I18nManager.getText("dialog.exportgpx.copysource"));
149                 _copySourceCheckbox.setSelected(true);
150                 checkPanel.add(_copySourceCheckbox);
151                 mainPanel.add(checkPanel);
152                 // panel for selecting character encoding
153                 _encodingsPanel = new JPanel();
154                 if (!XmlUtils.isSystemUtf8())
155                 {
156                         // only add this panel if system isn't utf8 (or can't be identified yet)
157                         _encodingsPanel.setBorder(BorderFactory.createCompoundBorder(
158                                 BorderFactory.createEtchedBorder(EtchedBorder.LOWERED), BorderFactory.createEmptyBorder(4, 4, 4, 4)));
159                         _encodingsPanel.setLayout(new BorderLayout());
160                         _encodingsPanel.add(new JLabel(I18nManager.getText("dialog.exportgpx.encoding")), BorderLayout.NORTH);
161                         JPanel radioPanel = new JPanel();
162                         radioPanel.setLayout(new FlowLayout());
163                         ButtonGroup radioGroup = new ButtonGroup();
164                         _useSystemRadio = new JRadioButton(I18nManager.getText("dialog.exportgpx.encoding.system"));
165                         _forceUtf8Radio = new JRadioButton(I18nManager.getText("dialog.exportgpx.encoding.utf8"));
166                         radioGroup.add(_useSystemRadio);
167                         radioGroup.add(_forceUtf8Radio);
168                         radioPanel.add(_useSystemRadio);
169                         radioPanel.add(_forceUtf8Radio);
170                         _useSystemRadio.setSelected(true);
171                         _encodingsPanel.add(radioPanel, BorderLayout.CENTER);
172                         mainPanel.add(_encodingsPanel);
173                 }
174                 dialogPanel.add(mainPanel, BorderLayout.CENTER);
175
176                 // close dialog if escape pressed
177                 _nameField.addKeyListener(new DialogCloser(_dialog));
178                 // button panel at bottom
179                 JPanel buttonPanel = new JPanel();
180                 buttonPanel.setLayout(new FlowLayout(FlowLayout.RIGHT));
181                 JButton okButton = new JButton(I18nManager.getText("button.ok"));
182                 ActionListener okListener = new ActionListener() {
183                         public void actionPerformed(ActionEvent e)
184                         {
185                                 startExport();
186                         }
187                 };
188                 okButton.addActionListener(okListener);
189                 _descriptionField.addActionListener(okListener);
190                 buttonPanel.add(okButton);
191                 JButton cancelButton = new JButton(I18nManager.getText("button.cancel"));
192                 cancelButton.addActionListener(new ActionListener() {
193                         public void actionPerformed(ActionEvent e) {
194                                 _dialog.dispose();
195                         }
196                 });
197                 buttonPanel.add(cancelButton);
198                 dialogPanel.add(buttonPanel, BorderLayout.SOUTH);
199                 dialogPanel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 15));
200                 return dialogPanel;
201         }
202
203         /**
204          * Set the suggestion for the name with which to export
205          */
206         private void setFileTitle()
207         {
208                 // Get the most recent file info
209                 SourceInfo currentSource = _app.getTrackInfo().getFileInfo().getLastFileInfo();
210                 if (currentSource != _previousSourceInfo)
211                 {
212                         String lastTitle = currentSource.getFileTitle();
213                         if (lastTitle != null && !lastTitle.equals(""))
214                         {
215                                 // Take the title of the last file loaded
216                                 _nameField.setText(lastTitle);
217                         }
218                 }
219                 if (_nameField.getText().equals(""))
220                 {
221                         // no name given in the field already, so try to overwrite it
222                         String lastTitle = _app.getTrackInfo().getFileInfo().getLastFileTitle();
223                         if (lastTitle != null && !lastTitle.equals(""))
224                         {
225                                 _nameField.setText(lastTitle);
226                         }
227                 }
228                 // Remember this source info so we don't use it again
229                 _previousSourceInfo = currentSource;
230         }
231
232         /**
233          * Start the export process based on the input parameters
234          */
235         private void startExport()
236         {
237                 // OK pressed, so check selections
238                 if (!_pointTypeSelector.getAnythingSelected())
239                 {
240                         JOptionPane.showMessageDialog(_parentFrame, I18nManager.getText("dialog.save.notypesselected"),
241                                 I18nManager.getText("dialog.saveoptions.title"), JOptionPane.WARNING_MESSAGE);
242                         return;
243                 }
244                 // Choose output file
245                 File saveFile = chooseGpxFile(_parentFrame);
246                 if (saveFile != null)
247                 {
248                         // New file or overwrite confirmed, so initiate export in separate thread
249                         _exportFile = saveFile;
250                         new Thread(this).start();
251                 }
252         }
253
254
255         /**
256          * Select a GPX file to save to
257          * @param inParentFrame parent frame for file chooser dialog
258          * @return selected File, or null if selection cancelled
259          */
260         public static File chooseGpxFile(JFrame inParentFrame)
261         {
262                 File saveFile = null;
263                 JFileChooser fileChooser = new JFileChooser();
264                 fileChooser.setDialogType(JFileChooser.SAVE_DIALOG);
265                 fileChooser.setFileFilter(new GenericFileFilter("filetype.gpx", new String[] {"gpx"}));
266                 fileChooser.setAcceptAllFileFilterUsed(false);
267                 // start from directory in config which should be set
268                 String configDir = Config.getConfigString(Config.KEY_TRACK_DIR);
269                 if (configDir != null) {fileChooser.setCurrentDirectory(new File(configDir));}
270
271                 // Allow choose again if an existing file is selected
272                 boolean chooseAgain = false;
273                 do
274                 {
275                         chooseAgain = false;
276                         if (fileChooser.showSaveDialog(inParentFrame) == JFileChooser.APPROVE_OPTION)
277                         {
278                                 // OK pressed and file chosen
279                                 File file = fileChooser.getSelectedFile();
280                                 // Check file extension
281                                 if (!file.getName().toLowerCase().endsWith(".gpx"))
282                                 {
283                                         file = new File(file.getAbsolutePath() + ".gpx");
284                                 }
285                                 // Check if file exists and if necessary prompt for overwrite
286                                 Object[] buttonTexts = {I18nManager.getText("button.overwrite"), I18nManager.getText("button.cancel")};
287                                 if (!file.exists() || JOptionPane.showOptionDialog(inParentFrame,
288                                                 I18nManager.getText("dialog.save.overwrite.text"),
289                                                 I18nManager.getText("dialog.save.overwrite.title"), JOptionPane.YES_NO_OPTION,
290                                                 JOptionPane.WARNING_MESSAGE, null, buttonTexts, buttonTexts[1])
291                                         == JOptionPane.YES_OPTION)
292                                 {
293                                         // new file or overwrite confirmed
294                                         saveFile = file;
295                                 }
296                                 else
297                                 {
298                                         // file exists and overwrite cancelled - select again
299                                         chooseAgain = true;
300                                 }
301                         }
302                 } while (chooseAgain);
303                 return saveFile;
304         }
305
306
307         /**
308          * Run method for controlling separate thread for exporting
309          */
310         public void run()
311         {
312                 // Instantiate source file cachers in case we want to copy output
313                 GpxCacherList gpxCachers = null;
314                 if (_copySourceCheckbox.isSelected()) {
315                         gpxCachers = new GpxCacherList(_trackInfo.getFileInfo());
316                 }
317                 OutputStreamWriter writer = null;
318                 try
319                 {
320                         // normal writing to file - firstly specify UTF8 encoding if requested
321                         if (_forceUtf8Radio != null && _forceUtf8Radio.isSelected())
322                                 writer = new OutputStreamWriter(new FileOutputStream(_exportFile), "UTF-8");
323                         else
324                                 writer = new OutputStreamWriter(new FileOutputStream(_exportFile));
325                         // TODO: Move to new method
326                         SettingsForExport settings = new SettingsForExport();
327                         settings.setExportTrackPoints(_pointTypeSelector.getTrackpointsSelected());
328                         settings.setExportWaypoints(_pointTypeSelector.getWaypointsSelected());
329                         settings.setExportPhotoPoints(_pointTypeSelector.getPhotopointsSelected());
330                         settings.setExportAudiopoints(_pointTypeSelector.getAudiopointsSelected());
331                         settings.setExportJustSelection(_pointTypeSelector.getJustSelection());
332                         settings.setExportTimestamps(_timestampsCheckbox.isSelected());
333                         // write file
334                         final int numPoints = exportData(writer, _trackInfo, _nameField.getText(),
335                                 _descriptionField.getText(), settings, gpxCachers);
336
337                         // close file
338                         writer.close();
339                         // Store directory in config for later
340                         Config.setConfigString(Config.KEY_TRACK_DIR, _exportFile.getParentFile().getAbsolutePath());
341                         // Add to recent file list
342                         Config.getRecentFileList().addFile(new RecentFile(_exportFile, true));
343                         // Show confirmation
344                         UpdateMessageBroker.informSubscribers();
345                         UpdateMessageBroker.informSubscribers(I18nManager.getText("confirm.save.ok1")
346                                  + " " + numPoints + " " + I18nManager.getText("confirm.save.ok2")
347                                  + " " + _exportFile.getAbsolutePath());
348                         // export successful so need to close dialog and return
349                         _dialog.dispose();
350                         _app.informDataSaved();
351                         return;
352                 }
353                 catch (IOException ioe)
354                 {
355                         // System.out.println("Exception: " + ioe.getClass().getName() + " - " + ioe.getMessage());
356                         try {
357                                 if (writer != null) writer.close();
358                         }
359                         catch (IOException ioe2) {}
360                         JOptionPane.showMessageDialog(_parentFrame,
361                                 I18nManager.getText("error.save.failed") + " : " + ioe.getMessage(),
362                                 I18nManager.getText("error.save.dialogtitle"), JOptionPane.ERROR_MESSAGE);
363                 }
364                 // if not returned already, export failed so need to recall the file selection
365                 startExport();
366         }
367
368
369         /**
370          * Export the information to the given writer
371          * @param inWriter writer object
372          * @param inInfo track info object
373          * @param inName name of track (optional)
374          * @param inDesc description of track (optional)
375          * @param inExportSettings flags for what to export and how
376          * @param inGpxCachers list of Gpx cachers containing input data
377          * @return number of points written
378          * @throws IOException if io errors occur on write
379          */
380         public static int exportData(OutputStreamWriter inWriter, TrackInfo inInfo, String inName,
381                 String inDesc, SettingsForExport inSettings, GpxCacherList inGpxCachers) throws IOException
382         {
383                 // Write or copy headers
384                 inWriter.write(getXmlHeaderString(inWriter));
385                 final String gpxHeader = getGpxHeaderString(inGpxCachers);
386                 final boolean isVersion1_1 = (gpxHeader.toUpperCase().indexOf("GPX/1/1") > 0);
387                 inWriter.write(gpxHeader);
388                 // name and description
389                 String trackName = (inName != null && !inName.equals("")) ? XmlUtils.fixCdata(inName) : "GpsPruneTrack";
390                 String desc      = (inDesc != null && !inDesc.equals("")) ? XmlUtils.fixCdata(inDesc) : "Export from GpsPrune";
391                 writeNameAndDescription(inWriter, trackName, desc, isVersion1_1);
392
393                 DataPoint point = null;
394                 final boolean exportWaypoints = inSettings.getExportWaypoints();
395                 final boolean exportSelection = inSettings.getExportJustSelection();
396                 final boolean exportTimestamps = inSettings.getExportTimestamps();
397                 // Examine selection
398                 int selStart = -1, selEnd = -1;
399                 if (exportSelection) {
400                         selStart = inInfo.getSelection().getStart();
401                         selEnd = inInfo.getSelection().getEnd();
402                 }
403                 // Loop over waypoints
404                 final int numPoints = inInfo.getTrack().getNumPoints();
405                 int numSaved = 0;
406                 for (int i=0; i<numPoints; i++)
407                 {
408                         point = inInfo.getTrack().getPoint(i);
409                         if (!exportSelection || (i>=selStart && i<=selEnd))
410                         {
411                                 // Make a wpt element for each waypoint
412                                 if (point.isWaypoint() && exportWaypoints)
413                                 {
414                                         String pointSource = (inGpxCachers == null? null : getPointSource(inGpxCachers, point));
415                                         if (pointSource != null)
416                                         {
417                                                 // If timestamp checkbox is off, strip time
418                                                 if (!exportTimestamps) {
419                                                         pointSource = stripTime(pointSource);
420                                                 }
421                                                 inWriter.write('\t');
422                                                 inWriter.write(pointSource);
423                                                 inWriter.write('\n');
424                                         }
425                                         else {
426                                                 exportWaypoint(point, inWriter, inSettings);
427                                         }
428                                         numSaved++;
429                                 }
430                         }
431                 }
432                 // Export both route points and then track points
433                 if (inSettings.getExportTrackPoints() || inSettings.getExportPhotoPoints() || inSettings.getExportAudioPoints())
434                 {
435                         // Output all route points (if any)
436                         numSaved += writeTrackPoints(inWriter, inInfo, inSettings,
437                                 true, inGpxCachers, "<rtept", "\t<rte><number>1</number>\n",
438                                 null, "\t</rte>\n");
439                         // Output all track points, if any
440                         String trackStart = "\t<trk>\n\t\t<name>" + trackName + "</name>\n\t\t<number>1</number>\n\t\t<trkseg>\n";
441                         numSaved += writeTrackPoints(inWriter, inInfo, inSettings,
442                                 false, inGpxCachers, "<trkpt", trackStart,
443                                 "\t</trkseg>\n\t<trkseg>\n", "\t\t</trkseg>\n\t</trk>\n");
444                 }
445
446                 inWriter.write("</gpx>\n");
447                 return numSaved;
448         }
449
450
451         /**
452          * Write the name and description according to the GPX version number
453          * @param inWriter writer object
454          * @param inName name, or null if none supplied
455          * @param inDesc description, or null if none supplied
456          * @param inIsVersion1_1 true if gpx version 1.1, false for version 1.0
457          */
458         private static void writeNameAndDescription(OutputStreamWriter inWriter,
459                 String inName, String inDesc, boolean inIsVersion1_1) throws IOException
460         {
461                 // Position of name and description fields needs to be different for GPX1.0 and GPX1.1
462                 if (inIsVersion1_1)
463                 {
464                         // GPX 1.1 has the name and description inside a metadata tag
465                         inWriter.write("\t<metadata>\n");
466                 }
467                 if (inName != null && !inName.equals(""))
468                 {
469                         if (inIsVersion1_1) {inWriter.write('\t');}
470                         inWriter.write("\t<name>");
471                         inWriter.write(inName);
472                         inWriter.write("</name>\n");
473                 }
474                 if (inIsVersion1_1) {inWriter.write('\t');}
475                 inWriter.write("\t<desc>");
476                 inWriter.write(inDesc);
477                 inWriter.write("</desc>\n");
478                 if (inIsVersion1_1)
479                 {
480                         inWriter.write("\t</metadata>\n");
481                 }
482         }
483
484         /**
485          * Loop through the track outputting the relevant track points
486          * @param inWriter writer object for output
487          * @param inInfo track info object containing track
488          * @param inSettings export settings defining what should be exported
489          * @param inOnlyCopies true to only export if source can be copied
490          * @param inCachers list of GpxCachers
491          * @param inPointTag tag to match for each point
492          * @param inStartTag start tag to output
493          * @param inSegmentTag tag to output between segments (or null)
494          * @param inEndTag end tag to output
495          */
496         private static int writeTrackPoints(OutputStreamWriter inWriter,
497                 TrackInfo inInfo, SettingsForExport inSettings,
498                 boolean inOnlyCopies, GpxCacherList inCachers, String inPointTag,
499                 String inStartTag, String inSegmentTag, String inEndTag)
500         throws IOException
501         {
502                 // Note: Too many input parameters to this method but avoids duplication
503                 // of output functionality for writing track points and route points
504                 int numPoints = inInfo.getTrack().getNumPoints();
505                 int selStart = inInfo.getSelection().getStart();
506                 int selEnd = inInfo.getSelection().getEnd();
507                 int numSaved = 0;
508                 final boolean exportSelection = inSettings.getExportJustSelection();
509                 final boolean exportTrackPoints = inSettings.getExportTrackPoints();
510                 final boolean exportPhotos = inSettings.getExportPhotoPoints();
511                 final boolean exportAudios = inSettings.getExportAudioPoints();
512                 final boolean exportTimestamps = inSettings.getExportTimestamps();
513                 // Loop over track points
514                 for (int i=0; i<numPoints; i++)
515                 {
516                         DataPoint point = inInfo.getTrack().getPoint(i);
517                         if ((!exportSelection || (i>=selStart && i<=selEnd)) && !point.isWaypoint())
518                         {
519                                 if ((point.getPhoto()==null && exportTrackPoints) || (point.getPhoto()!=null && exportPhotos)
520                                         || (point.getAudio()!=null && exportAudios))
521                                 {
522                                         // get the source from the point (if any)
523                                         String pointSource = getPointSource(inCachers, point);
524                                         // Clear point source if it's the wrong type of point (eg changed from waypoint or route point)
525                                         if (pointSource != null && !pointSource.trim().toLowerCase().startsWith(inPointTag)) {
526                                                 pointSource = null;
527                                         }
528                                         if (pointSource != null || !inOnlyCopies)
529                                         {
530                                                 // restart track segment if necessary
531                                                 if ((numSaved > 0) && point.getSegmentStart() && (inSegmentTag != null)) {
532                                                         inWriter.write(inSegmentTag);
533                                                 }
534                                                 if (numSaved == 0) {inWriter.write(inStartTag);}
535                                                 if (pointSource != null)
536                                                 {
537                                                         // If timestamps checkbox is off, strip the time
538                                                         if (!exportTimestamps) {
539                                                                 pointSource = stripTime(pointSource);
540                                                         }
541                                                         inWriter.write(pointSource);
542                                                         inWriter.write('\n');
543                                                 }
544                                                 else
545                                                 {
546                                                         if (!inOnlyCopies) {
547                                                                 exportTrackpoint(point, inWriter, inSettings);
548                                                         }
549                                                 }
550                                                 numSaved++;
551                                         }
552                                 }
553                         }
554                 }
555                 if (numSaved > 0) {
556                         inWriter.write(inEndTag);
557                 }
558                 return numSaved;
559         }
560
561
562         /**
563          * Get the point source for the specified point
564          * @param inCachers list of GPX cachers to ask for source
565          * @param inPoint point object
566          * @return xml source if available, or null otherwise
567          */
568         private static String getPointSource(GpxCacherList inCachers, DataPoint inPoint)
569         {
570                 if (inCachers == null || inPoint == null) {return null;}
571                 String source = inCachers.getSourceString(inPoint);
572                 if (source == null || !inPoint.isModified()) {return source;}
573                 // Point has been modified - maybe it's possible to modify the source
574                 source = replaceGpxTags(source, "lat=\"", "\"", inPoint.getLatitude().output(Coordinate.FORMAT_DECIMAL_FORCE_POINT));
575                 source = replaceGpxTags(source, "lon=\"", "\"", inPoint.getLongitude().output(Coordinate.FORMAT_DECIMAL_FORCE_POINT));
576                 source = replaceGpxTags(source, "<ele>", "</ele>", inPoint.getAltitude().getStringValue(UnitSetLibrary.UNITS_METRES));
577                 source = replaceGpxTags(source, "<time>", "</time>", inPoint.getTimestamp().getText(Timestamp.Format.ISO8601, null));
578                 if (inPoint.isWaypoint())
579                 {
580                         source = replaceGpxTags(source, "<name>", "</name>", XmlUtils.fixCdata(inPoint.getWaypointName()));
581                         if (source != null)
582                         {
583                                 source = source.replaceAll("<description>", "<desc>").replaceAll("</description>", "</desc>");
584                         }
585                         source = replaceGpxTags(source, "<desc>", "</desc>",
586                                 XmlUtils.fixCdata(inPoint.getFieldValue(Field.DESCRIPTION)));
587                         source = replaceGpxTags(source, "<cmt>", "</cmt>", inPoint.getFieldValue(Field.COMMENT));
588                 }
589                 // photo / audio links
590                 if (source != null && (inPoint.hasMedia() || source.indexOf("</link>") > 0)) {
591                         source = replaceMediaLinks(source, makeMediaLink(inPoint));
592                 }
593                 return source;
594         }
595
596         /**
597          * Replace the given value into the given XML string
598          * @param inSource source XML for point
599          * @param inStartTag start tag for field
600          * @param inEndTag end tag for field
601          * @param inValue value to replace between start tag and end tag
602          * @return modified String, or null if not possible
603          */
604         private static String replaceGpxTags(String inSource, String inStartTag, String inEndTag, String inValue)
605         {
606                 if (inSource == null) {return null;}
607                 // Look for start and end tags within source
608                 final int startPos = inSource.indexOf(inStartTag);
609                 final int endPos = inSource.indexOf(inEndTag, startPos+inStartTag.length());
610                 if (startPos > 0 && endPos > 0)
611                 {
612                         String origValue = inSource.substring(startPos + inStartTag.length(), endPos);
613                         if (inValue != null && origValue.equals(inValue)) {
614                                 // Value unchanged
615                                 return inSource;
616                         }
617                         else if (inValue == null || inValue.equals("")) {
618                                 // Need to delete value
619                                 return inSource.substring(0, startPos) + inSource.substring(endPos + inEndTag.length());
620                         }
621                         else {
622                                 // Need to replace value
623                                 return inSource.substring(0, startPos+inStartTag.length()) + inValue + inSource.substring(endPos);
624                         }
625                 }
626                 // Value not found for this field in original source
627                 if (inValue == null || inValue.equals("")) {return inSource;}
628                 return null;
629         }
630
631
632         /**
633          * Replace the media tags in the given XML string
634          * @param inSource source XML for point
635          * @param inValue value for the current point
636          * @return modified String, or null if not possible
637          */
638         private static String replaceMediaLinks(String inSource, String inValue)
639         {
640                 if (inSource == null) {return null;}
641                 // Note that this method is very similar to replaceGpxTags except there can be multiple link tags
642                 // and the tags must have attributes.  So either one heavily parameterized method or two.
643                 // Look for start and end tags within source
644                 final String STARTTEXT = "<link";
645                 final String ENDTEXT = "</link>";
646                 final int startPos = inSource.indexOf(STARTTEXT);
647                 final int endPos = inSource.lastIndexOf(ENDTEXT);
648                 if (startPos > 0 && endPos > 0)
649                 {
650                         String origValue = inSource.substring(startPos, endPos + ENDTEXT.length());
651                         if (inValue != null && origValue.equals(inValue)) {
652                                 // Value unchanged
653                                 return inSource;
654                         }
655                         else if (inValue == null || inValue.equals("")) {
656                                 // Need to delete value
657                                 return inSource.substring(0, startPos) + inSource.substring(endPos + ENDTEXT.length());
658                         }
659                         else {
660                                 // Need to replace value
661                                 return inSource.substring(0, startPos) + inValue + inSource.substring(endPos + ENDTEXT.length());
662                         }
663                 }
664                 // Value not found for this field in original source
665                 if (inValue == null || inValue.equals("")) {return inSource;}
666                 return null;
667         }
668
669
670         /**
671          * Get the header string for the xml document including encoding
672          * @param inWriter writer object
673          * @return header string defining encoding
674          */
675         private static String getXmlHeaderString(OutputStreamWriter inWriter)
676         {
677                 return "<?xml version=\"1.0\" encoding=\"" + XmlUtils.getEncoding(inWriter) + "\"?>\n";
678         }
679
680
681         /**
682          * Get the header string for the gpx tag
683          * @param inCachers cacher list to ask for headers, if available
684          * @return header string from cachers or as default
685          */
686         private static String getGpxHeaderString(GpxCacherList inCachers)
687         {
688                 String gpxHeader = null;
689                 if (inCachers != null) {gpxHeader = inCachers.getFirstHeader();}
690                 if (gpxHeader == null || gpxHeader.length() < 5)
691                 {
692                         // TODO: Consider changing this to default to GPX 1.1
693                         // Create default (1.0) header
694                         gpxHeader = "<gpx version=\"1.0\" creator=\"" + GPX_CREATOR
695                                 + "\"\n xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n"
696                                 + " xmlns=\"http://www.topografix.com/GPX/1/0\""
697                                 + " xsi:schemaLocation=\"http://www.topografix.com/GPX/1/0 http://www.topografix.com/GPX/1/0/gpx.xsd\">\n";
698                 }
699                 return gpxHeader + "\n";
700         }
701
702
703         /**
704          * Export the specified waypoint into the file
705          * @param inPoint waypoint to export
706          * @param inWriter writer object
707          * @param inSettings export settings
708          * @throws IOException on write failure
709          */
710         private static void exportWaypoint(DataPoint inPoint, Writer inWriter,
711                 SettingsForExport inSettings)
712                 throws IOException
713         {
714                 inWriter.write("\t<wpt lat=\"");
715                 inWriter.write(inPoint.getLatitude().output(Coordinate.FORMAT_DECIMAL_FORCE_POINT));
716                 inWriter.write("\" lon=\"");
717                 inWriter.write(inPoint.getLongitude().output(Coordinate.FORMAT_DECIMAL_FORCE_POINT));
718                 inWriter.write("\">\n");
719                 // altitude if available
720                 if (inPoint.hasAltitude() || inSettings.getExportMissingAltitudesAsZero())
721                 {
722                         inWriter.write("\t\t<ele>");
723                         inWriter.write(inPoint.hasAltitude() ? inPoint.getAltitude().getStringValue(UnitSetLibrary.UNITS_METRES) : "0");
724                         inWriter.write("</ele>\n");
725                 }
726                 // timestamp if available (some waypoints have timestamps, some not)
727                 if (inPoint.hasTimestamp() && inSettings.getExportTimestamps())
728                 {
729                         inWriter.write("\t\t<time>");
730                         inWriter.write(inPoint.getTimestamp().getText(Timestamp.Format.ISO8601, null));
731                         inWriter.write("</time>\n");
732                 }
733                 // write waypoint name after elevation and time
734                 inWriter.write("\t\t<name>");
735                 inWriter.write(XmlUtils.fixCdata(inPoint.getWaypointName().trim()));
736                 inWriter.write("</name>\n");
737                 // description, if any
738                 final String desc = XmlUtils.fixCdata(inPoint.getFieldValue(Field.DESCRIPTION));
739                 if (desc != null && !desc.equals(""))
740                 {
741                         inWriter.write("\t\t<desc>");
742                         inWriter.write(desc);
743                         inWriter.write("</desc>\n");
744                 }
745                 // comment, if any
746                 final String comment = XmlUtils.fixCdata(inPoint.getFieldValue(Field.COMMENT));
747                 if (comment != null && !comment.equals(""))
748                 {
749                         inWriter.write("\t\t<cmt>");
750                         inWriter.write(comment);
751                         inWriter.write("</cmt>\n");
752                 }
753                 // Media links, if any
754                 if (inSettings.getExportPhotoPoints() && inPoint.getPhoto() != null)
755                 {
756                         inWriter.write("\t\t");
757                         inWriter.write(makeMediaLink(inPoint.getPhoto()));
758                         inWriter.write('\n');
759                 }
760                 if (inSettings.getExportAudioPoints() && inPoint.getAudio() != null)
761                 {
762                         inWriter.write("\t\t");
763                         inWriter.write(makeMediaLink(inPoint.getAudio()));
764                         inWriter.write('\n');
765                 }
766                 // write waypoint type if any
767                 String type = inPoint.getFieldValue(Field.WAYPT_TYPE);
768                 if (type != null)
769                 {
770                         type = type.trim();
771                         if (!type.equals(""))
772                         {
773                                 inWriter.write("\t\t<type>");
774                                 inWriter.write(type);
775                                 inWriter.write("</type>\n");
776                         }
777                 }
778                 inWriter.write("\t</wpt>\n");
779         }
780
781
782         /**
783          * Export the specified trackpoint into the file
784          * @param inPoint trackpoint to export
785          * @param inWriter writer object
786          * @param inSettings export settings
787          */
788         private static void exportTrackpoint(DataPoint inPoint, Writer inWriter, SettingsForExport inSettings)
789                 throws IOException
790         {
791                 inWriter.write("\t\t\t<trkpt lat=\"");
792                 inWriter.write(inPoint.getLatitude().output(Coordinate.FORMAT_DECIMAL_FORCE_POINT));
793                 inWriter.write("\" lon=\"");
794                 inWriter.write(inPoint.getLongitude().output(Coordinate.FORMAT_DECIMAL_FORCE_POINT));
795                 inWriter.write("\">\n");
796                 // altitude
797                 if (inPoint.hasAltitude() || inSettings.getExportMissingAltitudesAsZero())
798                 {
799                         inWriter.write("\t\t\t\t<ele>");
800                         inWriter.write(inPoint.hasAltitude() ? inPoint.getAltitude().getStringValue(UnitSetLibrary.UNITS_METRES) : "0");
801                         inWriter.write("</ele>\n");
802                 }
803                 // Maybe take timestamp from photo if the point hasn't got one
804                 Timestamp pointTimestamp = getPointTimestamp(inPoint, inSettings);
805                 // timestamp if available (and selected)
806                 if (pointTimestamp != null && inSettings.getExportTimestamps())
807                 {
808                         inWriter.write("\t\t\t\t<time>");
809                         inWriter.write(pointTimestamp.getText(Timestamp.Format.ISO8601, null));
810                         inWriter.write("</time>\n");
811                 }
812                 // photo, audio
813                 if (inPoint.getPhoto() != null && inSettings.getExportPhotoPoints())
814                 {
815                         inWriter.write("\t\t\t\t");
816                         inWriter.write(makeMediaLink(inPoint.getPhoto()));
817                         inWriter.write("\n");
818                 }
819                 if (inPoint.getAudio() != null && inSettings.getExportAudioPoints()) {
820                         inWriter.write(makeMediaLink(inPoint.getAudio()));
821                 }
822                 inWriter.write("\t\t\t</trkpt>\n");
823         }
824
825
826         /**
827          * Make the xml for the media link(s)
828          * @param inPoint point to generate text for
829          * @return link tags, or null if no links
830          */
831         private static String makeMediaLink(DataPoint inPoint)
832         {
833                 Photo photo = inPoint.getPhoto();
834                 AudioClip audio = inPoint.getAudio();
835                 if (photo == null && audio == null) {
836                         return null;
837                 }
838                 String linkText = "";
839                 if (photo != null) {
840                         linkText = makeMediaLink(photo);
841                 }
842                 if (audio != null) {
843                         linkText += makeMediaLink(audio);
844                 }
845                 return linkText;
846         }
847
848         /**
849          * Make the media link for a single media item
850          * @param inMedia media item, either photo or audio
851          * @return link for this media
852          */
853         private static String makeMediaLink(MediaObject inMedia)
854         {
855                 if (inMedia.getFile() != null)
856                         // file link
857                         return "<link href=\"" + inMedia.getFile().getAbsolutePath() + "\"><text>" + inMedia.getName() + "</text></link>";
858                 if (inMedia.getUrl() != null)
859                         // url link
860                         return "<link href=\"" + inMedia.getUrl() + "\"><text>" + inMedia.getName() + "</text></link>";
861                 // No link available, must have been loaded from zip file - no link possible
862                 return "";
863         }
864
865
866         /**
867          * Strip the time from a GPX point source string
868          * @param inPointSource point source to copy
869          * @return point source with timestamp removed
870          */
871         private static String stripTime(String inPointSource)
872         {
873                 return inPointSource.replaceAll("[ \t]*<time>.*?</time>", "");
874         }
875
876         /**
877          * Get the timestamp from the point or its media
878          * @param inPoint point object
879          * @param inSettings export settings
880          * @return Timestamp object if available, or null
881          */
882         private static Timestamp getPointTimestamp(DataPoint inPoint, SettingsForExport inSettings)
883         {
884                 if (inPoint.hasTimestamp())
885                 {
886                         return inPoint.getTimestamp();
887                 }
888                 if (inPoint.getPhoto() != null && inSettings.getExportPhotoPoints())
889                 {
890                         if (inPoint.getPhoto().hasTimestamp())
891                         {
892                                 return inPoint.getPhoto().getTimestamp();
893                         }
894                 }
895                 if (inPoint.getAudio() != null && inSettings.getExportAudioPoints())
896                 {
897                         if (inPoint.getAudio().hasTimestamp())
898                         {
899                                 return inPoint.getAudio().getTimestamp();
900                         }
901                 }
902                 return null;
903         }
904 }