001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.layer;
003
004import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
005import static org.openstreetmap.josm.tools.I18n.marktr;
006import static org.openstreetmap.josm.tools.I18n.tr;
007import static org.openstreetmap.josm.tools.I18n.trn;
008
009import java.awt.AlphaComposite;
010import java.awt.Color;
011import java.awt.Composite;
012import java.awt.Graphics2D;
013import java.awt.GridBagLayout;
014import java.awt.Point;
015import java.awt.Rectangle;
016import java.awt.TexturePaint;
017import java.awt.event.ActionEvent;
018import java.awt.geom.Area;
019import java.awt.image.BufferedImage;
020import java.io.File;
021import java.util.ArrayList;
022import java.util.Arrays;
023import java.util.Collection;
024import java.util.Collections;
025import java.util.HashMap;
026import java.util.HashSet;
027import java.util.LinkedHashMap;
028import java.util.List;
029import java.util.Map;
030import java.util.Set;
031import java.util.concurrent.Callable;
032import java.util.concurrent.CopyOnWriteArrayList;
033import java.util.regex.Pattern;
034
035import javax.swing.AbstractAction;
036import javax.swing.Action;
037import javax.swing.Icon;
038import javax.swing.JLabel;
039import javax.swing.JOptionPane;
040import javax.swing.JPanel;
041import javax.swing.JScrollPane;
042
043import org.openstreetmap.josm.Main;
044import org.openstreetmap.josm.actions.ExpertToggleAction;
045import org.openstreetmap.josm.actions.RenameLayerAction;
046import org.openstreetmap.josm.actions.ToggleUploadDiscouragedLayerAction;
047import org.openstreetmap.josm.data.APIDataSet;
048import org.openstreetmap.josm.data.Bounds;
049import org.openstreetmap.josm.data.DataSource;
050import org.openstreetmap.josm.data.SelectionChangedListener;
051import org.openstreetmap.josm.data.conflict.Conflict;
052import org.openstreetmap.josm.data.conflict.ConflictCollection;
053import org.openstreetmap.josm.data.coor.LatLon;
054import org.openstreetmap.josm.data.gpx.GpxConstants;
055import org.openstreetmap.josm.data.gpx.GpxData;
056import org.openstreetmap.josm.data.gpx.GpxLink;
057import org.openstreetmap.josm.data.gpx.ImmutableGpxTrack;
058import org.openstreetmap.josm.data.gpx.WayPoint;
059import org.openstreetmap.josm.data.osm.DataIntegrityProblemException;
060import org.openstreetmap.josm.data.osm.DataSet;
061import org.openstreetmap.josm.data.osm.DataSetMerger;
062import org.openstreetmap.josm.data.osm.DatasetConsistencyTest;
063import org.openstreetmap.josm.data.osm.IPrimitive;
064import org.openstreetmap.josm.data.osm.Node;
065import org.openstreetmap.josm.data.osm.OsmPrimitive;
066import org.openstreetmap.josm.data.osm.OsmPrimitiveComparator;
067import org.openstreetmap.josm.data.osm.Relation;
068import org.openstreetmap.josm.data.osm.Way;
069import org.openstreetmap.josm.data.osm.event.AbstractDatasetChangedEvent;
070import org.openstreetmap.josm.data.osm.event.DataSetListenerAdapter;
071import org.openstreetmap.josm.data.osm.event.DataSetListenerAdapter.Listener;
072import org.openstreetmap.josm.data.osm.visitor.AbstractVisitor;
073import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
074import org.openstreetmap.josm.data.osm.visitor.paint.MapRendererFactory;
075import org.openstreetmap.josm.data.osm.visitor.paint.Rendering;
076import org.openstreetmap.josm.data.osm.visitor.paint.relations.MultipolygonCache;
077import org.openstreetmap.josm.data.preferences.IntegerProperty;
078import org.openstreetmap.josm.data.preferences.StringProperty;
079import org.openstreetmap.josm.data.projection.Projection;
080import org.openstreetmap.josm.data.validation.TestError;
081import org.openstreetmap.josm.gui.ExtendedDialog;
082import org.openstreetmap.josm.gui.MapView;
083import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
084import org.openstreetmap.josm.gui.dialogs.LayerListPopup;
085import org.openstreetmap.josm.gui.io.AbstractIOTask;
086import org.openstreetmap.josm.gui.io.AbstractUploadDialog;
087import org.openstreetmap.josm.gui.io.UploadDialog;
088import org.openstreetmap.josm.gui.io.UploadLayerTask;
089import org.openstreetmap.josm.gui.layer.markerlayer.MarkerLayer;
090import org.openstreetmap.josm.gui.progress.PleaseWaitProgressMonitor;
091import org.openstreetmap.josm.gui.progress.ProgressMonitor;
092import org.openstreetmap.josm.gui.util.GuiHelper;
093import org.openstreetmap.josm.gui.widgets.FileChooserManager;
094import org.openstreetmap.josm.gui.widgets.JosmTextArea;
095import org.openstreetmap.josm.io.OsmImporter;
096import org.openstreetmap.josm.tools.CheckParameterUtil;
097import org.openstreetmap.josm.tools.FilteredCollection;
098import org.openstreetmap.josm.tools.GBC;
099import org.openstreetmap.josm.tools.ImageOverlay;
100import org.openstreetmap.josm.tools.ImageProvider;
101import org.openstreetmap.josm.tools.ImageProvider.ImageSizes;
102import org.openstreetmap.josm.tools.date.DateUtils;
103
104/**
105 * A layer that holds OSM data from a specific dataset.
106 * The data can be fully edited.
107 *
108 * @author imi
109 * @since 17
110 */
111public class OsmDataLayer extends AbstractModifiableLayer implements Listener, SelectionChangedListener, UploadToServer, SaveToFile {
112    /** Property used to know if this layer has to be saved on disk */
113    public static final String REQUIRES_SAVE_TO_DISK_PROP = OsmDataLayer.class.getName() + ".requiresSaveToDisk";
114    /** Property used to know if this layer has to be uploaded */
115    public static final String REQUIRES_UPLOAD_TO_SERVER_PROP = OsmDataLayer.class.getName() + ".requiresUploadToServer";
116
117    private boolean requiresSaveToFile;
118    private boolean requiresUploadToServer;
119    private boolean isChanged = true;
120    private int highlightUpdateCount;
121
122    /**
123     * List of validation errors in this layer.
124     * @since 3669
125     */
126    public final List<TestError> validationErrors = new ArrayList<>();
127
128    public static final int DEFAULT_RECENT_RELATIONS_NUMBER = 20;
129    public static final IntegerProperty PROPERTY_RECENT_RELATIONS_NUMBER = new IntegerProperty("properties.last-closed-relations-size",
130            DEFAULT_RECENT_RELATIONS_NUMBER);
131    public static final StringProperty PROPERTY_SAVE_EXTENSION = new StringProperty("save.extension.osm", "osm");
132
133
134    /** List of recent relations */
135    private final Map<Relation, Void> recentRelations = new LinkedHashMap<Relation, Void>(PROPERTY_RECENT_RELATIONS_NUMBER.get()+1, 1.1f, true) {
136        @Override
137        protected boolean removeEldestEntry(Map.Entry<Relation, Void> eldest) {
138            return size() > PROPERTY_RECENT_RELATIONS_NUMBER.get();
139        }
140    };
141
142    /**
143     * Returns list of recently closed relations or null if none.
144     * @return list of recently closed relations or <code>null</code> if none
145     * @since 9668
146     */
147    public ArrayList<Relation> getRecentRelations() {
148        ArrayList<Relation> list = new ArrayList<>(recentRelations.keySet());
149        Collections.reverse(list);
150        return list;
151    }
152
153    /**
154     * Adds recently closed relation.
155     * @param relation new entry for the list of recently closed relations
156     * @since 9668
157     */
158    public void setRecentRelation(Relation relation) {
159        recentRelations.put(relation, null);
160        Main.map.relationListDialog.enableRecentRelations();
161    }
162
163    /**
164     * Remove relation from list of recent relations.
165     * @param relation relation to remove
166     * @since 9668
167     */
168    public void removeRecentRelation(Relation relation) {
169        recentRelations.remove(relation);
170        Main.map.relationListDialog.enableRecentRelations();
171    }
172
173    protected void setRequiresSaveToFile(boolean newValue) {
174        boolean oldValue = requiresSaveToFile;
175        requiresSaveToFile = newValue;
176        if (oldValue != newValue) {
177            propertyChangeSupport.firePropertyChange(REQUIRES_SAVE_TO_DISK_PROP, oldValue, newValue);
178        }
179    }
180
181    protected void setRequiresUploadToServer(boolean newValue) {
182        boolean oldValue = requiresUploadToServer;
183        requiresUploadToServer = newValue;
184        if (oldValue != newValue) {
185            propertyChangeSupport.firePropertyChange(REQUIRES_UPLOAD_TO_SERVER_PROP, oldValue, newValue);
186        }
187    }
188
189    /** the global counter for created data layers */
190    private static int dataLayerCounter;
191
192    /**
193     * Replies a new unique name for a data layer
194     *
195     * @return a new unique name for a data layer
196     */
197    public static String createNewName() {
198        dataLayerCounter++;
199        return tr("Data Layer {0}", dataLayerCounter);
200    }
201
202    public static final class DataCountVisitor extends AbstractVisitor {
203        public int nodes;
204        public int ways;
205        public int relations;
206        public int deletedNodes;
207        public int deletedWays;
208        public int deletedRelations;
209
210        @Override
211        public void visit(final Node n) {
212            nodes++;
213            if (n.isDeleted()) {
214                deletedNodes++;
215            }
216        }
217
218        @Override
219        public void visit(final Way w) {
220            ways++;
221            if (w.isDeleted()) {
222                deletedWays++;
223            }
224        }
225
226        @Override
227        public void visit(final Relation r) {
228            relations++;
229            if (r.isDeleted()) {
230                deletedRelations++;
231            }
232        }
233    }
234
235    public interface CommandQueueListener {
236        void commandChanged(int queueSize, int redoSize);
237    }
238
239    /**
240     * Listener called when a state of this layer has changed.
241     */
242    public interface LayerStateChangeListener {
243        /**
244         * Notifies that the "upload discouraged" (upload=no) state has changed.
245         * @param layer The layer that has been modified
246         * @param newValue The new value of the state
247         */
248        void uploadDiscouragedChanged(OsmDataLayer layer, boolean newValue);
249    }
250
251    private final CopyOnWriteArrayList<LayerStateChangeListener> layerStateChangeListeners = new CopyOnWriteArrayList<>();
252
253    /**
254     * Adds a layer state change listener
255     *
256     * @param listener the listener. Ignored if null or already registered.
257     * @since 5519
258     */
259    public void addLayerStateChangeListener(LayerStateChangeListener listener) {
260        if (listener != null) {
261            layerStateChangeListeners.addIfAbsent(listener);
262        }
263    }
264
265    /**
266     * Removes a layer property change listener
267     *
268     * @param listener the listener. Ignored if null or already registered.
269     * @since 5519
270     */
271    public void removeLayerPropertyChangeListener(LayerStateChangeListener listener) {
272        layerStateChangeListeners.remove(listener);
273    }
274
275    /**
276     * The data behind this layer.
277     */
278    public final DataSet data;
279
280    /**
281     * the collection of conflicts detected in this layer
282     */
283    private final ConflictCollection conflicts;
284
285    /**
286     * a paint texture for non-downloaded area
287     */
288    private static volatile TexturePaint hatched;
289
290    static {
291        createHatchTexture();
292    }
293
294    /**
295     * Replies background color for downloaded areas.
296     * @return background color for downloaded areas. Black by default
297     */
298    public static Color getBackgroundColor() {
299        return Main.pref != null ? Main.pref.getColor(marktr("background"), Color.BLACK) : Color.BLACK;
300    }
301
302    /**
303     * Replies background color for non-downloaded areas.
304     * @return background color for non-downloaded areas. Yellow by default
305     */
306    public static Color getOutsideColor() {
307        return Main.pref != null ? Main.pref.getColor(marktr("outside downloaded area"), Color.YELLOW) : Color.YELLOW;
308    }
309
310    /**
311     * Initialize the hatch pattern used to paint the non-downloaded area
312     */
313    public static void createHatchTexture() {
314        BufferedImage bi = new BufferedImage(15, 15, BufferedImage.TYPE_INT_ARGB);
315        Graphics2D big = bi.createGraphics();
316        big.setColor(getBackgroundColor());
317        Composite comp = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.3f);
318        big.setComposite(comp);
319        big.fillRect(0, 0, 15, 15);
320        big.setColor(getOutsideColor());
321        big.drawLine(0, 15, 15, 0);
322        Rectangle r = new Rectangle(0, 0, 15, 15);
323        hatched = new TexturePaint(bi, r);
324    }
325
326    /**
327     * Construct a new {@code OsmDataLayer}.
328     * @param data OSM data
329     * @param name Layer name
330     * @param associatedFile Associated .osm file (can be null)
331     */
332    public OsmDataLayer(final DataSet data, final String name, final File associatedFile) {
333        super(name);
334        CheckParameterUtil.ensureParameterNotNull(data, "data");
335        this.data = data;
336        this.setAssociatedFile(associatedFile);
337        conflicts = new ConflictCollection();
338        data.addDataSetListener(new DataSetListenerAdapter(this));
339        data.addDataSetListener(MultipolygonCache.getInstance());
340        DataSet.addSelectionListener(this);
341    }
342
343    /**
344     * Return the image provider to get the base icon
345     * @return image provider class which can be modified
346     * @since 8323
347     */
348    protected ImageProvider getBaseIconProvider() {
349        return new ImageProvider("layer", "osmdata_small");
350    }
351
352    @Override
353    public Icon getIcon() {
354        ImageProvider base = getBaseIconProvider().setMaxSize(ImageSizes.LAYER);
355        if (isUploadDiscouraged()) {
356            base.addOverlay(new ImageOverlay(new ImageProvider("warning-small"), 0.5, 0.5, 1.0, 1.0));
357        }
358        return base.get();
359    }
360
361    /**
362     * Draw all primitives in this layer but do not draw modified ones (they
363     * are drawn by the edit layer).
364     * Draw nodes last to overlap the ways they belong to.
365     */
366    @Override public void paint(final Graphics2D g, final MapView mv, Bounds box) {
367        isChanged = false;
368        highlightUpdateCount = data.getHighlightUpdateCount();
369
370        boolean active = mv.getActiveLayer() == this;
371        boolean inactive = !active && Main.pref.getBoolean("draw.data.inactive_color", true);
372        boolean virtual = !inactive && mv.isVirtualNodesEnabled();
373
374        // draw the hatched area for non-downloaded region. only draw if we're the active
375        // and bounds are defined; don't draw for inactive layers or loaded GPX files etc
376        if (active && Main.pref.getBoolean("draw.data.downloaded_area", true) && !data.dataSources.isEmpty()) {
377            // initialize area with current viewport
378            Rectangle b = mv.getBounds();
379            // on some platforms viewport bounds seem to be offset from the left,
380            // over-grow it just to be sure
381            b.grow(100, 100);
382            Area a = new Area(b);
383
384            // now successively subtract downloaded areas
385            for (Bounds bounds : data.getDataSourceBounds()) {
386                if (bounds.isCollapsed()) {
387                    continue;
388                }
389                Point p1 = mv.getPoint(bounds.getMin());
390                Point p2 = mv.getPoint(bounds.getMax());
391                Rectangle r = new Rectangle(Math.min(p1.x, p2.x), Math.min(p1.y, p2.y), Math.abs(p2.x-p1.x), Math.abs(p2.y-p1.y));
392                a.subtract(new Area(r));
393            }
394
395            // paint remainder
396            g.setPaint(hatched);
397            g.fill(a);
398        }
399
400        Rendering painter = MapRendererFactory.getInstance().createActiveRenderer(g, mv, inactive);
401        painter.render(data, virtual, box);
402        Main.map.conflictDialog.paintConflicts(g, mv);
403    }
404
405    @Override public String getToolTipText() {
406        int nodes = new FilteredCollection<>(data.getNodes(), OsmPrimitive.nonDeletedPredicate).size();
407        int ways = new FilteredCollection<>(data.getWays(), OsmPrimitive.nonDeletedPredicate).size();
408        int rels = new FilteredCollection<>(data.getRelations(), OsmPrimitive.nonDeletedPredicate).size();
409
410        String tool = trn("{0} node", "{0} nodes", nodes, nodes)+", ";
411        tool += trn("{0} way", "{0} ways", ways, ways)+", ";
412        tool += trn("{0} relation", "{0} relations", rels, rels);
413
414        File f = getAssociatedFile();
415        if (f != null) {
416            tool = "<html>"+tool+"<br>"+f.getPath()+"</html>";
417        }
418        return tool;
419    }
420
421    @Override public void mergeFrom(final Layer from) {
422        final PleaseWaitProgressMonitor monitor = new PleaseWaitProgressMonitor(tr("Merging layers"));
423        monitor.setCancelable(false);
424        if (from instanceof OsmDataLayer && ((OsmDataLayer) from).isUploadDiscouraged()) {
425            setUploadDiscouraged(true);
426        }
427        mergeFrom(((OsmDataLayer) from).data, monitor);
428        monitor.close();
429    }
430
431    /**
432     * merges the primitives in dataset <code>from</code> into the dataset of
433     * this layer
434     *
435     * @param from  the source data set
436     */
437    public void mergeFrom(final DataSet from) {
438        mergeFrom(from, null);
439    }
440
441    /**
442     * merges the primitives in dataset <code>from</code> into the dataset of this layer
443     *
444     * @param from  the source data set
445     * @param progressMonitor the progress monitor, can be {@code null}
446     */
447    public void mergeFrom(final DataSet from, ProgressMonitor progressMonitor) {
448        final DataSetMerger visitor = new DataSetMerger(data, from);
449        try {
450            visitor.merge(progressMonitor);
451        } catch (DataIntegrityProblemException e) {
452            Main.error(e);
453            JOptionPane.showMessageDialog(
454                    Main.parent,
455                    e.getHtmlMessage() != null ? e.getHtmlMessage() : e.getMessage(),
456                    tr("Error"),
457                    JOptionPane.ERROR_MESSAGE
458            );
459            return;
460        }
461
462        Area a = data.getDataSourceArea();
463
464        // copy the merged layer's data source info.
465        // only add source rectangles if they are not contained in the layer already.
466        for (DataSource src : from.dataSources) {
467            if (a == null || !a.contains(src.bounds.asRect())) {
468                data.dataSources.add(src);
469            }
470        }
471
472        // copy the merged layer's API version
473        if (data.getVersion() == null) {
474            data.setVersion(from.getVersion());
475        }
476
477        int numNewConflicts = 0;
478        for (Conflict<?> c : visitor.getConflicts()) {
479            if (!conflicts.hasConflict(c)) {
480                numNewConflicts++;
481                conflicts.add(c);
482            }
483        }
484        // repaint to make sure new data is displayed properly.
485        if (Main.isDisplayingMapView()) {
486            Main.map.mapView.repaint();
487        }
488        // warn about new conflicts
489        if (numNewConflicts > 0 && Main.map != null && Main.map.conflictDialog != null) {
490            Main.map.conflictDialog.warnNumNewConflicts(numNewConflicts);
491        }
492    }
493
494    @Override
495    public boolean isMergable(final Layer other) {
496        // allow merging between normal layers and discouraged layers with a warning (see #7684)
497        return other instanceof OsmDataLayer;
498    }
499
500    @Override
501    public void visitBoundingBox(final BoundingXYVisitor v) {
502        for (final Node n: data.getNodes()) {
503            if (n.isUsable()) {
504                v.visit(n);
505            }
506        }
507    }
508
509    /**
510     * Clean out the data behind the layer. This means clearing the redo/undo lists,
511     * really deleting all deleted objects and reset the modified flags. This should
512     * be done after an upload, even after a partial upload.
513     *
514     * @param processed A list of all objects that were actually uploaded.
515     *         May be <code>null</code>, which means nothing has been uploaded
516     */
517    public void cleanupAfterUpload(final Collection<IPrimitive> processed) {
518        // return immediately if an upload attempt failed
519        if (processed == null || processed.isEmpty())
520            return;
521
522        Main.main.undoRedo.clean(this);
523
524        // if uploaded, clean the modified flags as well
525        data.cleanupDeletedPrimitives();
526        data.beginUpdate();
527        try {
528            for (OsmPrimitive p: data.allPrimitives()) {
529                if (processed.contains(p)) {
530                    p.setModified(false);
531                }
532            }
533        } finally {
534            data.endUpdate();
535        }
536    }
537
538    @Override
539    public Object getInfoComponent() {
540        final DataCountVisitor counter = new DataCountVisitor();
541        for (final OsmPrimitive osm : data.allPrimitives()) {
542            osm.accept(counter);
543        }
544        final JPanel p = new JPanel(new GridBagLayout());
545
546        String nodeText = trn("{0} node", "{0} nodes", counter.nodes, counter.nodes);
547        if (counter.deletedNodes > 0) {
548            nodeText += " ("+trn("{0} deleted", "{0} deleted", counter.deletedNodes, counter.deletedNodes)+')';
549        }
550
551        String wayText = trn("{0} way", "{0} ways", counter.ways, counter.ways);
552        if (counter.deletedWays > 0) {
553            wayText += " ("+trn("{0} deleted", "{0} deleted", counter.deletedWays, counter.deletedWays)+')';
554        }
555
556        String relationText = trn("{0} relation", "{0} relations", counter.relations, counter.relations);
557        if (counter.deletedRelations > 0) {
558            relationText += " ("+trn("{0} deleted", "{0} deleted", counter.deletedRelations, counter.deletedRelations)+')';
559        }
560
561        p.add(new JLabel(tr("{0} consists of:", getName())), GBC.eol());
562        p.add(new JLabel(nodeText, ImageProvider.get("data", "node"), JLabel.HORIZONTAL), GBC.eop().insets(15, 0, 0, 0));
563        p.add(new JLabel(wayText, ImageProvider.get("data", "way"), JLabel.HORIZONTAL), GBC.eop().insets(15, 0, 0, 0));
564        p.add(new JLabel(relationText, ImageProvider.get("data", "relation"), JLabel.HORIZONTAL), GBC.eop().insets(15, 0, 0, 0));
565        p.add(new JLabel(tr("API version: {0}", (data.getVersion() != null) ? data.getVersion() : tr("unset"))),
566                GBC.eop().insets(15, 0, 0, 0));
567        if (isUploadDiscouraged()) {
568            p.add(new JLabel(tr("Upload is discouraged")), GBC.eop().insets(15, 0, 0, 0));
569        }
570
571        return p;
572    }
573
574    @Override public Action[] getMenuEntries() {
575        List<Action> actions = new ArrayList<>();
576        actions.addAll(Arrays.asList(new Action[]{
577                LayerListDialog.getInstance().createActivateLayerAction(this),
578                LayerListDialog.getInstance().createShowHideLayerAction(),
579                LayerListDialog.getInstance().createDeleteLayerAction(),
580                SeparatorLayerAction.INSTANCE,
581                LayerListDialog.getInstance().createMergeLayerAction(this),
582                LayerListDialog.getInstance().createDuplicateLayerAction(this),
583                new LayerSaveAction(this),
584                new LayerSaveAsAction(this),
585        }));
586        if (ExpertToggleAction.isExpert()) {
587            actions.addAll(Arrays.asList(new Action[]{
588                    new LayerGpxExportAction(this),
589                    new ConvertToGpxLayerAction()}));
590        }
591        actions.addAll(Arrays.asList(new Action[]{
592                SeparatorLayerAction.INSTANCE,
593                new RenameLayerAction(getAssociatedFile(), this)}));
594        if (ExpertToggleAction.isExpert()) {
595            actions.add(new ToggleUploadDiscouragedLayerAction(this));
596        }
597        actions.addAll(Arrays.asList(new Action[]{
598                new ConsistencyTestAction(),
599                SeparatorLayerAction.INSTANCE,
600                new LayerListPopup.InfoAction(this)}));
601        return actions.toArray(new Action[actions.size()]);
602    }
603
604    /**
605     * Converts given OSM dataset to GPX data.
606     * @param data OSM dataset
607     * @param file output .gpx file
608     * @return GPX data
609     */
610    public static GpxData toGpxData(DataSet data, File file) {
611        GpxData gpxData = new GpxData();
612        gpxData.storageFile = file;
613        Set<Node> doneNodes = new HashSet<>();
614        waysToGpxData(data.getWays(), gpxData, doneNodes);
615        nodesToGpxData(data.getNodes(), gpxData, doneNodes);
616        return gpxData;
617    }
618
619    private static void waysToGpxData(Collection<Way> ways, GpxData gpxData, Set<Node> doneNodes) {
620        /* When the dataset has been obtained from a gpx layer and now is being converted back,
621         * the ways have negative ids. The first created way corresponds to the first gpx segment,
622         * and has the highest id (i.e., closest to zero).
623         * Thus, sorting by OsmPrimitive#getUniqueId gives the original order.
624         * (Only works if the data layer has not been saved to and been loaded from an osm file before.)
625         */
626        final List<Way> sortedWays = new ArrayList<>(ways);
627        Collections.sort(sortedWays, new OsmPrimitiveComparator(true, false)); // sort by OsmPrimitive#getUniqueId ascending
628        Collections.reverse(sortedWays); // sort by OsmPrimitive#getUniqueId descending
629        for (Way w : sortedWays) {
630            if (!w.isUsable()) {
631                continue;
632            }
633            Collection<Collection<WayPoint>> trk = new ArrayList<>();
634            Map<String, Object> trkAttr = new HashMap<>();
635
636            if (w.get("name") != null) {
637                trkAttr.put("name", w.get("name"));
638            }
639
640            List<WayPoint> trkseg = null;
641            for (Node n : w.getNodes()) {
642                if (!n.isUsable()) {
643                    trkseg = null;
644                    continue;
645                }
646                if (trkseg == null) {
647                    trkseg = new ArrayList<>();
648                    trk.add(trkseg);
649                }
650                if (!n.isTagged()) {
651                    doneNodes.add(n);
652                }
653                trkseg.add(nodeToWayPoint(n));
654            }
655
656            gpxData.tracks.add(new ImmutableGpxTrack(trk, trkAttr));
657        }
658    }
659
660    private static WayPoint nodeToWayPoint(Node n) {
661        WayPoint wpt = new WayPoint(n.getCoor());
662
663        // Position info
664
665        addDoubleIfPresent(wpt, n, GpxConstants.PT_ELE);
666
667        if (!n.isTimestampEmpty()) {
668            wpt.put("time", DateUtils.fromTimestamp(n.getRawTimestamp()));
669            wpt.setTime();
670        }
671
672        addDoubleIfPresent(wpt, n, GpxConstants.PT_MAGVAR);
673        addDoubleIfPresent(wpt, n, GpxConstants.PT_GEOIDHEIGHT);
674
675        // Description info
676
677        addStringIfPresent(wpt, n, GpxConstants.GPX_NAME);
678        addStringIfPresent(wpt, n, GpxConstants.GPX_DESC, "description");
679        addStringIfPresent(wpt, n, GpxConstants.GPX_CMT, "comment");
680        addStringIfPresent(wpt, n, GpxConstants.GPX_SRC, "source", "source:position");
681
682        Collection<GpxLink> links = new ArrayList<>();
683        for (String key : new String[]{"link", "url", "website", "contact:website"}) {
684            String value = n.get(key);
685            if (value != null) {
686                links.add(new GpxLink(value));
687            }
688        }
689        wpt.put(GpxConstants.META_LINKS, links);
690
691        addStringIfPresent(wpt, n, GpxConstants.PT_SYM, "wpt_symbol");
692        addStringIfPresent(wpt, n, GpxConstants.PT_TYPE);
693
694        // Accuracy info
695        addStringIfPresent(wpt, n, GpxConstants.PT_FIX, "gps:fix");
696        addIntegerIfPresent(wpt, n, GpxConstants.PT_SAT, "gps:sat");
697        addDoubleIfPresent(wpt, n, GpxConstants.PT_HDOP, "gps:hdop");
698        addDoubleIfPresent(wpt, n, GpxConstants.PT_VDOP, "gps:vdop");
699        addDoubleIfPresent(wpt, n, GpxConstants.PT_PDOP, "gps:pdop");
700        addDoubleIfPresent(wpt, n, GpxConstants.PT_AGEOFDGPSDATA, "gps:ageofdgpsdata");
701        addIntegerIfPresent(wpt, n, GpxConstants.PT_DGPSID, "gps:dgpsid");
702
703        return wpt;
704    }
705
706    private static void nodesToGpxData(Collection<Node> nodes, GpxData gpxData, Set<Node> doneNodes) {
707        List<Node> sortedNodes = new ArrayList<>(nodes);
708        sortedNodes.removeAll(doneNodes);
709        Collections.sort(sortedNodes);
710        for (Node n : sortedNodes) {
711            if (n.isIncomplete() || n.isDeleted()) {
712                continue;
713            }
714            gpxData.waypoints.add(nodeToWayPoint(n));
715        }
716    }
717
718    private static void addIntegerIfPresent(WayPoint wpt, OsmPrimitive p, String gpxKey, String ... osmKeys) {
719        List<String> possibleKeys = new ArrayList<>(Arrays.asList(osmKeys));
720        possibleKeys.add(0, gpxKey);
721        for (String key : possibleKeys) {
722            String value = p.get(key);
723            if (value != null) {
724                try {
725                    int i = Integer.parseInt(value);
726                    // Sanity checks
727                    if ((!GpxConstants.PT_SAT.equals(gpxKey) || i >= 0) &&
728                        (!GpxConstants.PT_DGPSID.equals(gpxKey) || (0 <= i && i <= 1023))) {
729                        wpt.put(gpxKey, value);
730                        break;
731                    }
732                } catch (NumberFormatException e) {
733                    if (Main.isTraceEnabled()) {
734                        Main.trace(e.getMessage());
735                    }
736                }
737            }
738        }
739    }
740
741    private static void addDoubleIfPresent(WayPoint wpt, OsmPrimitive p, String gpxKey, String ... osmKeys) {
742        List<String> possibleKeys = new ArrayList<>(Arrays.asList(osmKeys));
743        possibleKeys.add(0, gpxKey);
744        for (String key : possibleKeys) {
745            String value = p.get(key);
746            if (value != null) {
747                try {
748                    double d = Double.parseDouble(value);
749                    // Sanity checks
750                    if (!GpxConstants.PT_MAGVAR.equals(gpxKey) || (0.0 <= d && d < 360.0)) {
751                        wpt.put(gpxKey, value);
752                        break;
753                    }
754                } catch (NumberFormatException e) {
755                    if (Main.isTraceEnabled()) {
756                        Main.trace(e.getMessage());
757                    }
758                }
759            }
760        }
761    }
762
763    private static void addStringIfPresent(WayPoint wpt, OsmPrimitive p, String gpxKey, String ... osmKeys) {
764        List<String> possibleKeys = new ArrayList<>(Arrays.asList(osmKeys));
765        possibleKeys.add(0, gpxKey);
766        for (String key : possibleKeys) {
767            String value = p.get(key);
768            // Sanity checks
769            if (value != null && (!GpxConstants.PT_FIX.equals(gpxKey) || GpxConstants.FIX_VALUES.contains(value))) {
770                wpt.put(gpxKey, value);
771                break;
772            }
773        }
774    }
775
776    /**
777     * Converts OSM data behind this layer to GPX data.
778     * @return GPX data
779     */
780    public GpxData toGpxData() {
781        return toGpxData(data, getAssociatedFile());
782    }
783
784    /**
785     * Action that converts this OSM layer to a GPX layer.
786     */
787    public class ConvertToGpxLayerAction extends AbstractAction {
788        /**
789         * Constructs a new {@code ConvertToGpxLayerAction}.
790         */
791        public ConvertToGpxLayerAction() {
792            super(tr("Convert to GPX layer"), ImageProvider.get("converttogpx"));
793            putValue("help", ht("/Action/ConvertToGpxLayer"));
794        }
795
796        @Override
797        public void actionPerformed(ActionEvent e) {
798            final GpxData gpxData = toGpxData();
799            final GpxLayer gpxLayer = new GpxLayer(gpxData, tr("Converted from: {0}", getName()));
800            if (getAssociatedFile() != null) {
801                final String filename = getAssociatedFile().getName().replaceAll(Pattern.quote(".gpx.osm") + "$", "") + ".gpx";
802                gpxLayer.setAssociatedFile(new File(getAssociatedFile().getParentFile(), filename));
803            }
804            Main.main.addLayer(gpxLayer);
805            if (Main.pref.getBoolean("marker.makeautomarkers", true) && !gpxData.waypoints.isEmpty()) {
806                Main.main.addLayer(new MarkerLayer(gpxData, tr("Converted from: {0}", getName()), null, gpxLayer));
807            }
808            Main.main.removeLayer(OsmDataLayer.this);
809        }
810    }
811
812    /**
813     * Determines if this layer contains data at the given coordinate.
814     * @param coor the coordinate
815     * @return {@code true} if data sources bounding boxes contain {@code coor}
816     */
817    public boolean containsPoint(LatLon coor) {
818        // we'll assume that if this has no data sources
819        // that it also has no borders
820        if (this.data.dataSources.isEmpty())
821            return true;
822
823        boolean layerBoundsPoint = false;
824        for (DataSource src : this.data.dataSources) {
825            if (src.bounds.contains(coor)) {
826                layerBoundsPoint = true;
827                break;
828            }
829        }
830        return layerBoundsPoint;
831    }
832
833    /**
834     * Replies the set of conflicts currently managed in this layer.
835     *
836     * @return the set of conflicts currently managed in this layer
837     */
838    public ConflictCollection getConflicts() {
839        return conflicts;
840    }
841
842    @Override
843    public boolean isUploadable() {
844        return true;
845    }
846
847    @Override
848    public boolean requiresUploadToServer() {
849        return requiresUploadToServer;
850    }
851
852    @Override
853    public boolean requiresSaveToFile() {
854        return getAssociatedFile() != null && requiresSaveToFile;
855    }
856
857    @Override
858    public void onPostLoadFromFile() {
859        setRequiresSaveToFile(false);
860        setRequiresUploadToServer(isModified());
861    }
862
863    /**
864     * Actions run after data has been downloaded to this layer.
865     */
866    public void onPostDownloadFromServer() {
867        setRequiresSaveToFile(true);
868        setRequiresUploadToServer(isModified());
869    }
870
871    @Override
872    public boolean isChanged() {
873        return isChanged || highlightUpdateCount != data.getHighlightUpdateCount();
874    }
875
876    @Override
877    public void onPostSaveToFile() {
878        setRequiresSaveToFile(false);
879        setRequiresUploadToServer(isModified());
880    }
881
882    @Override
883    public void onPostUploadToServer() {
884        setRequiresUploadToServer(isModified());
885        // keep requiresSaveToDisk unchanged
886    }
887
888    private class ConsistencyTestAction extends AbstractAction {
889
890        ConsistencyTestAction() {
891            super(tr("Dataset consistency test"));
892        }
893
894        @Override
895        public void actionPerformed(ActionEvent e) {
896            String result = DatasetConsistencyTest.runTests(data);
897            if (result.isEmpty()) {
898                JOptionPane.showMessageDialog(Main.parent, tr("No problems found"));
899            } else {
900                JPanel p = new JPanel(new GridBagLayout());
901                p.add(new JLabel(tr("Following problems found:")), GBC.eol());
902                JosmTextArea info = new JosmTextArea(result, 20, 60);
903                info.setCaretPosition(0);
904                info.setEditable(false);
905                p.add(new JScrollPane(info), GBC.eop());
906
907                JOptionPane.showMessageDialog(Main.parent, p, tr("Warning"), JOptionPane.WARNING_MESSAGE);
908            }
909        }
910    }
911
912    @Override
913    public void destroy() {
914        DataSet.removeSelectionListener(this);
915    }
916
917    @Override
918    public void processDatasetEvent(AbstractDatasetChangedEvent event) {
919        isChanged = true;
920        setRequiresSaveToFile(true);
921        setRequiresUploadToServer(true);
922    }
923
924    @Override
925    public void selectionChanged(Collection<? extends OsmPrimitive> newSelection) {
926        isChanged = true;
927    }
928
929    @Override
930    public void projectionChanged(Projection oldValue, Projection newValue) {
931         // No reprojection required. The dataset itself is registered as projection
932         // change listener and already got notified.
933    }
934
935    @Override
936    public final boolean isUploadDiscouraged() {
937        return data.isUploadDiscouraged();
938    }
939
940    /**
941     * Sets the "discouraged upload" flag.
942     * @param uploadDiscouraged {@code true} if upload of data managed by this layer is discouraged.
943     * This feature allows to use "private" data layers.
944     */
945    public final void setUploadDiscouraged(boolean uploadDiscouraged) {
946        if (uploadDiscouraged ^ isUploadDiscouraged()) {
947            data.setUploadDiscouraged(uploadDiscouraged);
948            for (LayerStateChangeListener l : layerStateChangeListeners) {
949                l.uploadDiscouragedChanged(this, uploadDiscouraged);
950            }
951        }
952    }
953
954    @Override
955    public final boolean isModified() {
956        return data.isModified();
957    }
958
959    @Override
960    public boolean isSavable() {
961        return true; // With OsmExporter
962    }
963
964    @Override
965    public boolean checkSaveConditions() {
966        if (isDataSetEmpty() && 1 != GuiHelper.runInEDTAndWaitAndReturn(new Callable<Integer>() {
967            @Override
968            public Integer call() {
969                ExtendedDialog dialog = new ExtendedDialog(
970                        Main.parent,
971                        tr("Empty document"),
972                        new String[] {tr("Save anyway"), tr("Cancel")}
973                );
974                dialog.setContent(tr("The document contains no data."));
975                dialog.setButtonIcons(new String[] {"save", "cancel"});
976                return dialog.showDialog().getValue();
977            }
978        })) {
979            return false;
980        }
981
982        ConflictCollection conflictsCol = getConflicts();
983        if (conflictsCol != null && !conflictsCol.isEmpty() && 1 != GuiHelper.runInEDTAndWaitAndReturn(new Callable<Integer>() {
984            @Override
985            public Integer call() {
986                ExtendedDialog dialog = new ExtendedDialog(
987                        Main.parent,
988                        /* I18N: Display title of the window showing conflicts */
989                        tr("Conflicts"),
990                        new String[] {tr("Reject Conflicts and Save"), tr("Cancel")}
991                );
992                dialog.setContent(
993                        tr("There are unresolved conflicts. Conflicts will not be saved and handled as if you rejected all. Continue?"));
994                dialog.setButtonIcons(new String[] {"save", "cancel"});
995                return dialog.showDialog().getValue();
996            }
997        })) {
998            return false;
999        }
1000        return true;
1001    }
1002
1003    /**
1004     * Check the data set if it would be empty on save. It is empty, if it contains
1005     * no objects (after all objects that are created and deleted without being
1006     * transferred to the server have been removed).
1007     *
1008     * @return <code>true</code>, if a save result in an empty data set.
1009     */
1010    private boolean isDataSetEmpty() {
1011        if (data != null) {
1012            for (OsmPrimitive osm : data.allNonDeletedPrimitives()) {
1013                if (!osm.isDeleted() || !osm.isNewOrUndeleted())
1014                    return false;
1015            }
1016        }
1017        return true;
1018    }
1019
1020    @Override
1021    public File createAndOpenSaveFileChooser() {
1022        String extension = PROPERTY_SAVE_EXTENSION.get();
1023        File file = getAssociatedFile();
1024        if (file == null && isRenamed()) {
1025            String filename = Main.pref.get("lastDirectory") + '/' + getName();
1026            if (!OsmImporter.FILE_FILTER.acceptName(filename))
1027                filename = filename + '.' + extension;
1028            file = new File(filename);
1029        }
1030        return new FileChooserManager()
1031            .title(tr("Save OSM file"))
1032            .extension(extension)
1033            .file(file)
1034            .allTypes(true)
1035            .getFileForSave();
1036    }
1037
1038    @Override
1039    public AbstractIOTask createUploadTask(final ProgressMonitor monitor) {
1040        UploadDialog dialog = UploadDialog.getUploadDialog();
1041        return new UploadLayerTask(
1042                dialog.getUploadStrategySpecification(),
1043                this,
1044                monitor,
1045                dialog.getChangeset());
1046    }
1047
1048    @Override
1049    public AbstractUploadDialog getUploadDialog() {
1050        UploadDialog dialog = UploadDialog.getUploadDialog();
1051        dialog.setUploadedPrimitives(new APIDataSet(data));
1052        return dialog;
1053    }
1054}