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