001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.io.importexport;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.GridBagLayout;
007import java.awt.event.ActionListener;
008import java.awt.event.KeyAdapter;
009import java.awt.event.KeyEvent;
010import java.io.File;
011import java.io.IOException;
012import java.io.OutputStream;
013import java.text.MessageFormat;
014import java.time.Year;
015import java.time.ZoneId;
016import java.util.Arrays;
017import java.util.List;
018import java.util.Optional;
019
020import javax.swing.JButton;
021import javax.swing.JCheckBox;
022import javax.swing.JLabel;
023import javax.swing.JList;
024import javax.swing.JOptionPane;
025import javax.swing.JPanel;
026import javax.swing.JScrollPane;
027import javax.swing.ListSelectionModel;
028
029import org.openstreetmap.josm.data.gpx.GpxConstants;
030import org.openstreetmap.josm.data.gpx.GpxData;
031import org.openstreetmap.josm.gui.ConditionalOptionPaneUtil;
032import org.openstreetmap.josm.gui.ExtendedDialog;
033import org.openstreetmap.josm.gui.MainApplication;
034import org.openstreetmap.josm.gui.layer.GpxLayer;
035import org.openstreetmap.josm.gui.layer.Layer;
036import org.openstreetmap.josm.gui.layer.OsmDataLayer;
037import org.openstreetmap.josm.gui.layer.geoimage.GeoImageLayer;
038import org.openstreetmap.josm.gui.widgets.JosmTextArea;
039import org.openstreetmap.josm.gui.widgets.JosmTextField;
040import org.openstreetmap.josm.io.Compression;
041import org.openstreetmap.josm.io.GpxWriter;
042import org.openstreetmap.josm.spi.preferences.Config;
043import org.openstreetmap.josm.tools.CheckParameterUtil;
044import org.openstreetmap.josm.tools.GBC;
045
046/**
047 * Exports data to a .gpx file. Data may be native GPX or OSM data which will be converted.
048 * @since 1949
049 */
050public class GpxExporter extends FileExporter implements GpxConstants {
051
052    private static final List<Class<? extends Layer>> SUPPORTED_LAYERS = Arrays.asList(
053            GpxLayer.class, OsmDataLayer.class, GeoImageLayer.class);
054
055    private static final String GPL_WARNING = "<html><font color='red' size='-2'>"
056        + tr("Note: GPL is not compatible with the OSM license. Do not upload GPL licensed tracks.") + "</html>";
057
058    private static final String[] LICENSES = {
059            "Creative Commons By-SA",
060            "Open Database License (ODbL)",
061            "public domain",
062            "GNU Lesser Public License (LGPL)",
063            "BSD License (MIT/X11)"};
064
065    private static final String[] URLS = {
066            "https://creativecommons.org/licenses/by-sa/3.0",
067            "http://opendatacommons.org/licenses/odbl/1.0",
068            "public domain",
069            "https://www.gnu.org/copyleft/lesser.html",
070            "http://www.opensource.org/licenses/bsd-license.php"};
071
072    /**
073     * Constructs a new {@code GpxExporter}.
074     */
075    public GpxExporter() {
076        super(GpxImporter.getFileFilter());
077    }
078
079    @Override
080    public boolean acceptFile(File pathname, Layer layer) {
081        return isSupportedLayer(layer) ? super.acceptFile(pathname, layer) : false;
082    }
083
084    @Override
085    public void exportData(File file, Layer layer) throws IOException {
086        exportData(file, layer, false);
087    }
088
089    @Override
090    public void exportDataQuiet(File file, Layer layer) throws IOException {
091        exportData(file, layer, true);
092    }
093
094    private void exportData(File file, Layer layer, boolean quiet) throws IOException {
095        CheckParameterUtil.ensureParameterNotNull(layer, "layer");
096        if (!isSupportedLayer(layer))
097            throw new IllegalArgumentException(MessageFormat.format("Expected instance of OsmDataLayer or GpxLayer. Got ''{0}''.", layer
098                    .getClass().getName()));
099        CheckParameterUtil.ensureParameterNotNull(file, "file");
100
101        String fn = file.getPath();
102        if (fn.indexOf('.') == -1) {
103            fn += ".gpx";
104            file = new File(fn);
105        }
106
107        GpxData gpxData;
108        if (quiet) {
109            gpxData = getGpxData(layer, file);
110            try (OutputStream fo = Compression.getCompressedFileOutputStream(file)) {
111                GpxWriter w = new GpxWriter(fo);
112                w.write(gpxData);
113                w.close();
114                fo.flush();
115            }
116            return;
117        }
118
119        // open the dialog asking for options
120        JPanel p = new JPanel(new GridBagLayout());
121
122        // At this moment, we only need to know the attributes of the GpxData,
123        // conversion of OsmDataLayer (if needed) will be done after the dialog is closed.
124        if (layer instanceof GpxLayer) {
125            gpxData = ((GpxLayer) layer).data;
126        } else if (layer instanceof GeoImageLayer) {
127            gpxData = ((GeoImageLayer) layer).getFauxGpxData();
128        } else {
129            gpxData = new GpxData();
130        }
131
132        p.add(new JLabel(tr("GPS track description")), GBC.eol());
133        JosmTextArea desc = new JosmTextArea(3, 40);
134        desc.setWrapStyleWord(true);
135        desc.setLineWrap(true);
136        desc.setText(gpxData.getString(META_DESC));
137        p.add(new JScrollPane(desc), GBC.eop().fill(GBC.BOTH));
138
139        JCheckBox author = new JCheckBox(tr("Add author information"), Config.getPref().getBoolean("lastAddAuthor", true));
140        p.add(author, GBC.eol());
141
142        JLabel nameLabel = new JLabel(tr("Real name"));
143        p.add(nameLabel, GBC.std().insets(10, 0, 5, 0));
144        JosmTextField authorName = new JosmTextField();
145        p.add(authorName, GBC.eol().fill(GBC.HORIZONTAL));
146        nameLabel.setLabelFor(authorName);
147
148        JLabel emailLabel = new JLabel(tr("E-Mail"));
149        p.add(emailLabel, GBC.std().insets(10, 0, 5, 0));
150        JosmTextField email = new JosmTextField();
151        p.add(email, GBC.eol().fill(GBC.HORIZONTAL));
152        emailLabel.setLabelFor(email);
153
154        JLabel copyrightLabel = new JLabel(tr("Copyright (URL)"));
155        p.add(copyrightLabel, GBC.std().insets(10, 0, 5, 0));
156        JosmTextField copyright = new JosmTextField();
157        p.add(copyright, GBC.std().fill(GBC.HORIZONTAL));
158        copyrightLabel.setLabelFor(copyright);
159
160        JButton predefined = new JButton(tr("Predefined"));
161        p.add(predefined, GBC.eol().insets(5, 0, 0, 0));
162
163        JLabel copyrightYearLabel = new JLabel(tr("Copyright year"));
164        p.add(copyrightYearLabel, GBC.std().insets(10, 0, 5, 5));
165        JosmTextField copyrightYear = new JosmTextField("");
166        p.add(copyrightYear, GBC.eol().fill(GBC.HORIZONTAL));
167        copyrightYearLabel.setLabelFor(copyrightYear);
168
169        JLabel warning = new JLabel("<html><font size='-2'>&nbsp;</html");
170        p.add(warning, GBC.eol().fill(GBC.HORIZONTAL).insets(15, 0, 0, 0));
171        addDependencies(gpxData, author, authorName, email, copyright, predefined, copyrightYear, nameLabel, emailLabel,
172                copyrightLabel, copyrightYearLabel, warning);
173
174        p.add(new JLabel(tr("Keywords")), GBC.eol());
175        JosmTextField keywords = new JosmTextField();
176        keywords.setText(gpxData.getString(META_KEYWORDS));
177        p.add(keywords, GBC.eol().fill(GBC.HORIZONTAL));
178
179        boolean sel = Config.getPref().getBoolean("gpx.export.colors", true);
180        JCheckBox colors = new JCheckBox(tr("Save track colors in GPX file"), sel);
181        p.add(colors, GBC.eol().fill(GBC.HORIZONTAL));
182        JCheckBox garmin = new JCheckBox(tr("Use Garmin compatible GPX extensions"),
183                Config.getPref().getBoolean("gpx.export.colors.garmin", false));
184        garmin.setEnabled(sel);
185        p.add(garmin, GBC.eol().fill(GBC.HORIZONTAL).insets(20, 0, 0, 0));
186
187        boolean hasPrefs = !gpxData.getLayerPrefs().isEmpty();
188        JCheckBox layerPrefs = new JCheckBox(tr("Save layer specific preferences"),
189                hasPrefs && Config.getPref().getBoolean("gpx.export.prefs", true));
190        layerPrefs.setEnabled(hasPrefs);
191        p.add(layerPrefs, GBC.eop().fill(GBC.HORIZONTAL));
192
193        ExtendedDialog ed = new ExtendedDialog(MainApplication.getMainFrame(),
194                tr("Export options"),
195                tr("Export and Save"), tr("Cancel"))
196            .setButtonIcons("exportgpx", "cancel")
197            .setContent(p);
198
199        colors.addActionListener(l -> {
200            garmin.setEnabled(colors.isSelected());
201        });
202
203        garmin.addActionListener(l -> {
204            if (garmin.isSelected() && !ConditionalOptionPaneUtil.showConfirmationDialog(
205                    "gpx_color_garmin",
206                    ed,
207                    new JLabel("<html>" + tr("Garmin track extensions only support 16 colors.") + "<br>"
208                                        + tr("If you continue, the closest supported track color will be used.")
209                             + "</html>"),
210                    tr("Information"),
211                    JOptionPane.OK_CANCEL_OPTION,
212                    JOptionPane.INFORMATION_MESSAGE,
213                    JOptionPane.OK_OPTION)) {
214                garmin.setSelected(false);
215            }
216        });
217
218        if (ed.showDialog().getValue() != 1) {
219            setCanceled(true);
220            return;
221        }
222        setCanceled(false);
223
224        Config.getPref().putBoolean("lastAddAuthor", author.isSelected());
225        if (!authorName.getText().isEmpty()) {
226            Config.getPref().put("lastAuthorName", authorName.getText());
227        }
228        if (!copyright.getText().isEmpty()) {
229            Config.getPref().put("lastCopyright", copyright.getText());
230        }
231        Config.getPref().putBoolean("gpx.export.colors", colors.isSelected());
232        Config.getPref().putBoolean("gpx.export.colors.garmin", garmin.isSelected());
233        if (hasPrefs) {
234            Config.getPref().putBoolean("gpx.export.prefs", layerPrefs.isSelected());
235        }
236        ColorFormat cFormat = null;
237        if (colors.isSelected()) {
238            cFormat = garmin.isSelected() ? ColorFormat.GPXX : ColorFormat.GPXD;
239        }
240
241        gpxData = getGpxData(layer, file);
242
243        // add author and copyright details to the gpx data
244        if (author.isSelected()) {
245            if (!authorName.getText().isEmpty()) {
246                gpxData.put(META_AUTHOR_NAME, authorName.getText());
247                gpxData.put(META_COPYRIGHT_AUTHOR, authorName.getText());
248            }
249            if (!email.getText().isEmpty()) {
250                gpxData.put(META_AUTHOR_EMAIL, email.getText());
251            }
252            if (!copyright.getText().isEmpty()) {
253                gpxData.put(META_COPYRIGHT_LICENSE, copyright.getText());
254            }
255            if (!copyrightYear.getText().isEmpty()) {
256                gpxData.put(META_COPYRIGHT_YEAR, copyrightYear.getText());
257            }
258        }
259
260        // add the description to the gpx data
261        if (!desc.getText().isEmpty()) {
262            gpxData.put(META_DESC, desc.getText());
263        }
264
265        // add keywords to the gpx data
266        if (!keywords.getText().isEmpty()) {
267            gpxData.put(META_KEYWORDS, keywords.getText());
268        }
269
270        try (OutputStream fo = Compression.getCompressedFileOutputStream(file)) {
271            GpxWriter w = new GpxWriter(fo);
272            w.write(gpxData, cFormat, layerPrefs.isSelected());
273            w.close();
274            fo.flush();
275        }
276    }
277
278    /**
279     * Returns the list of supported layers.
280     * @return the list of supported layers
281     * @since 18068
282     */
283    public static List<Class<? extends Layer>> getSupportedLayers() {
284        return SUPPORTED_LAYERS;
285    }
286
287    /**
288     * Determines if the given layer is supported by this action.
289     * @param layer layer to test
290     * @return {@code true} if the given layer is supported by this action
291     * @since 18068
292     */
293    public static boolean isSupportedLayer(Layer layer) {
294        return SUPPORTED_LAYERS.stream().anyMatch(c -> c.isInstance(layer));
295    }
296
297    private static GpxData getGpxData(Layer layer, File file) {
298        if (layer instanceof OsmDataLayer) {
299            return ((OsmDataLayer) layer).toGpxData();
300        } else if (layer instanceof GpxLayer) {
301            return ((GpxLayer) layer).data;
302        } else if (layer instanceof GeoImageLayer) {
303            return ((GeoImageLayer) layer).getFauxGpxData();
304        }
305        return OsmDataLayer.toGpxData(MainApplication.getLayerManager().getEditDataSet(), file);
306    }
307
308    private static void enableCopyright(final GpxData data, final JosmTextField copyright, final JButton predefined,
309            final JosmTextField copyrightYear, final JLabel copyrightLabel, final JLabel copyrightYearLabel,
310            final JLabel warning, boolean enable) {
311        copyright.setEnabled(enable);
312        predefined.setEnabled(enable);
313        copyrightYear.setEnabled(enable);
314        copyrightLabel.setEnabled(enable);
315        copyrightYearLabel.setEnabled(enable);
316        warning.setText(enable ? GPL_WARNING : "<html><font size='-2'>&nbsp;</html");
317
318        if (enable) {
319            if (copyrightYear.getText().isEmpty()) {
320                copyrightYear.setText(Optional.ofNullable(data.getString(META_COPYRIGHT_YEAR)).orElseGet(
321                        () -> Year.now(ZoneId.systemDefault()).toString()));
322            }
323            if (copyright.getText().isEmpty()) {
324                copyright.setText(Optional.ofNullable(data.getString(META_COPYRIGHT_LICENSE)).orElseGet(
325                        () -> Config.getPref().get("lastCopyright", "https://creativecommons.org/licenses/by-sa/2.5")));
326                copyright.setCaretPosition(0);
327            }
328        } else {
329            copyrightYear.setText("");
330            copyright.setText("");
331        }
332    }
333
334    // CHECKSTYLE.OFF: ParameterNumber
335
336    /**
337     * Add all those listeners to handle the enable state of the fields.
338     * @param data GPX data
339     * @param author Author checkbox
340     * @param authorName Author name textfield
341     * @param email E-mail textfield
342     * @param copyright Copyright textfield
343     * @param predefined Predefined button
344     * @param copyrightYear Copyright year textfield
345     * @param nameLabel Name label
346     * @param emailLabel E-mail label
347     * @param copyrightLabel Copyright label
348     * @param copyrightYearLabel Copyright year label
349     * @param warning Warning label
350     */
351    private static void addDependencies(
352            final GpxData data,
353            final JCheckBox author,
354            final JosmTextField authorName,
355            final JosmTextField email,
356            final JosmTextField copyright,
357            final JButton predefined,
358            final JosmTextField copyrightYear,
359            final JLabel nameLabel,
360            final JLabel emailLabel,
361            final JLabel copyrightLabel,
362            final JLabel copyrightYearLabel,
363            final JLabel warning) {
364
365        // CHECKSTYLE.ON: ParameterNumber
366        ActionListener authorActionListener = e -> {
367            boolean b = author.isSelected();
368            authorName.setEnabled(b);
369            email.setEnabled(b);
370            nameLabel.setEnabled(b);
371            emailLabel.setEnabled(b);
372            if (b) {
373                authorName.setText(Optional.ofNullable(data.getString(META_AUTHOR_NAME)).orElseGet(
374                        () -> Config.getPref().get("lastAuthorName")));
375                email.setText(Optional.ofNullable(data.getString(META_AUTHOR_EMAIL)).orElseGet(
376                        () -> Config.getPref().get("lastAuthorEmail")));
377            } else {
378                authorName.setText("");
379                email.setText("");
380            }
381            boolean isAuthorSet = !authorName.getText().isEmpty();
382            GpxExporter.enableCopyright(data, copyright, predefined, copyrightYear, copyrightLabel, copyrightYearLabel, warning,
383                    b && isAuthorSet);
384        };
385        author.addActionListener(authorActionListener);
386
387        KeyAdapter authorNameListener = new KeyAdapter() {
388            @Override public void keyReleased(KeyEvent e) {
389                boolean b = !authorName.getText().isEmpty() && author.isSelected();
390                GpxExporter.enableCopyright(data, copyright, predefined, copyrightYear, copyrightLabel, copyrightYearLabel, warning, b);
391            }
392        };
393        authorName.addKeyListener(authorNameListener);
394
395        predefined.addActionListener(e -> {
396            JList<String> l = new JList<>(LICENSES);
397            l.setVisibleRowCount(LICENSES.length);
398            l.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
399            int answer = JOptionPane.showConfirmDialog(
400                    MainApplication.getMainFrame(),
401                    new JScrollPane(l),
402                    tr("Choose a predefined license"),
403                    JOptionPane.OK_CANCEL_OPTION,
404                    JOptionPane.QUESTION_MESSAGE
405            );
406            if (answer != JOptionPane.OK_OPTION || l.getSelectedIndex() == -1)
407                return;
408            StringBuilder license = new StringBuilder();
409            for (int i : l.getSelectedIndices()) {
410                if (i == 2) {
411                    license = new StringBuilder("public domain");
412                    break;
413                }
414                if (license.length() > 0) {
415                    license.append(", ");
416                }
417                license.append(URLS[i]);
418            }
419            copyright.setText(license.toString());
420            copyright.setCaretPosition(0);
421        });
422
423        authorActionListener.actionPerformed(null);
424        authorNameListener.keyReleased(null);
425    }
426}