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