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