001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.io.session;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.GraphicsEnvironment;
007import java.io.BufferedInputStream;
008import java.io.File;
009import java.io.FileInputStream;
010import java.io.FileNotFoundException;
011import java.io.IOException;
012import java.io.InputStream;
013import java.lang.reflect.InvocationTargetException;
014import java.net.URI;
015import java.net.URISyntaxException;
016import java.nio.charset.StandardCharsets;
017import java.util.ArrayList;
018import java.util.Collections;
019import java.util.Enumeration;
020import java.util.HashMap;
021import java.util.List;
022import java.util.Map;
023import java.util.Map.Entry;
024import java.util.TreeMap;
025import java.util.zip.ZipEntry;
026import java.util.zip.ZipException;
027import java.util.zip.ZipFile;
028
029import javax.swing.JOptionPane;
030import javax.swing.SwingUtilities;
031import javax.xml.parsers.ParserConfigurationException;
032
033import org.openstreetmap.josm.Main;
034import org.openstreetmap.josm.data.ViewportData;
035import org.openstreetmap.josm.data.coor.EastNorth;
036import org.openstreetmap.josm.data.coor.LatLon;
037import org.openstreetmap.josm.data.projection.Projection;
038import org.openstreetmap.josm.data.projection.Projections;
039import org.openstreetmap.josm.gui.ExtendedDialog;
040import org.openstreetmap.josm.gui.layer.Layer;
041import org.openstreetmap.josm.gui.progress.NullProgressMonitor;
042import org.openstreetmap.josm.gui.progress.ProgressMonitor;
043import org.openstreetmap.josm.io.Compression;
044import org.openstreetmap.josm.io.IllegalDataException;
045import org.openstreetmap.josm.tools.JosmRuntimeException;
046import org.openstreetmap.josm.tools.MultiMap;
047import org.openstreetmap.josm.tools.Utils;
048import org.w3c.dom.Document;
049import org.w3c.dom.Element;
050import org.w3c.dom.Node;
051import org.w3c.dom.NodeList;
052import org.xml.sax.SAXException;
053
054/**
055 * Reads a .jos session file and loads the layers in the process.
056 * @since 4668
057 */
058public class SessionReader {
059
060    private static final Map<String, Class<? extends SessionLayerImporter>> sessionLayerImporters = new HashMap<>();
061
062    private URI sessionFileURI;
063    private boolean zip; // true, if session file is a .joz file; false if it is a .jos file
064    private ZipFile zipFile;
065    private List<Layer> layers = new ArrayList<>();
066    private int active = -1;
067    private final List<Runnable> postLoadTasks = new ArrayList<>();
068    private ViewportData viewport;
069
070    static {
071        registerSessionLayerImporter("osm-data", OsmDataSessionImporter.class);
072        registerSessionLayerImporter("imagery", ImagerySessionImporter.class);
073        registerSessionLayerImporter("tracks", GpxTracksSessionImporter.class);
074        registerSessionLayerImporter("geoimage", GeoImageSessionImporter.class);
075        registerSessionLayerImporter("markers", MarkerSessionImporter.class);
076        registerSessionLayerImporter("osm-notes", NoteSessionImporter.class);
077    }
078
079    /**
080     * Register a session layer importer.
081     *
082     * @param layerType layer type
083     * @param importer importer for this layer class
084     */
085    public static void registerSessionLayerImporter(String layerType, Class<? extends SessionLayerImporter> importer) {
086        sessionLayerImporters.put(layerType, importer);
087    }
088
089    /**
090     * Returns the session layer importer for the given layer type.
091     * @param layerType layer type to import
092     * @return session layer importer for the given layer
093     */
094    public static SessionLayerImporter getSessionLayerImporter(String layerType) {
095        Class<? extends SessionLayerImporter> importerClass = sessionLayerImporters.get(layerType);
096        if (importerClass == null)
097            return null;
098        SessionLayerImporter importer = null;
099        try {
100            importer = importerClass.getConstructor().newInstance();
101        } catch (ReflectiveOperationException e) {
102            throw new JosmRuntimeException(e);
103        }
104        return importer;
105    }
106
107    /**
108     * @return list of layers that are later added to the mapview
109     */
110    public List<Layer> getLayers() {
111        return layers;
112    }
113
114    /**
115     * @return active layer, or {@code null} if not set
116     * @since 6271
117     */
118    public Layer getActive() {
119        // layers is in reverse order because of the way TreeMap is built
120        return (active >= 0 && active < layers.size()) ? layers.get(layers.size()-1-active) : null;
121    }
122
123    /**
124     * @return actions executed in EDT after layers have been added (message dialog, etc.)
125     */
126    public List<Runnable> getPostLoadTasks() {
127        return postLoadTasks;
128    }
129
130    /**
131     * Return the viewport (map position and scale).
132     * @return The viewport. Can be null when no viewport info is found in the file.
133     */
134    public ViewportData getViewport() {
135        return viewport;
136    }
137
138    /**
139     * A class that provides some context for the individual {@link SessionLayerImporter}
140     * when doing the import.
141     */
142    public class ImportSupport {
143
144        private final String layerName;
145        private final int layerIndex;
146        private final List<LayerDependency> layerDependencies;
147
148        /**
149         * Path of the file inside the zip archive.
150         * Used as alternative return value for getFile method.
151         */
152        private String inZipPath;
153
154        /**
155         * Constructs a new {@code ImportSupport}.
156         * @param layerName layer name
157         * @param layerIndex layer index
158         * @param layerDependencies layer dependencies
159         */
160        public ImportSupport(String layerName, int layerIndex, List<LayerDependency> layerDependencies) {
161            this.layerName = layerName;
162            this.layerIndex = layerIndex;
163            this.layerDependencies = layerDependencies;
164        }
165
166        /**
167         * Add a task, e.g. a message dialog, that should
168         * be executed in EDT after all layers have been added.
169         * @param task task to run in EDT
170         */
171        public void addPostLayersTask(Runnable task) {
172            postLoadTasks.add(task);
173        }
174
175        /**
176         * Return an InputStream for a URI from a .jos/.joz file.
177         *
178         * The following forms are supported:
179         *
180         * - absolute file (both .jos and .joz):
181         *         "file:///home/user/data.osm"
182         *         "file:/home/user/data.osm"
183         *         "file:///C:/files/data.osm"
184         *         "file:/C:/file/data.osm"
185         *         "/home/user/data.osm"
186         *         "C:\files\data.osm"          (not a URI, but recognized by File constructor on Windows systems)
187         * - standalone .jos files:
188         *     - relative uri:
189         *         "save/data.osm"
190         *         "../project2/data.osm"
191         * - for .joz files:
192         *     - file inside zip archive:
193         *         "layers/01/data.osm"
194         *     - relativ to the .joz file:
195         *         "../save/data.osm"           ("../" steps out of the archive)
196         * @param uriStr URI as string
197         * @return the InputStream
198         *
199         * @throws IOException Thrown when no Stream can be opened for the given URI, e.g. when the linked file has been deleted.
200         */
201        public InputStream getInputStream(String uriStr) throws IOException {
202            File file = getFile(uriStr);
203            if (file != null) {
204                try {
205                    return new BufferedInputStream(Compression.getUncompressedFileInputStream(file));
206                } catch (FileNotFoundException e) {
207                    throw new IOException(tr("File ''{0}'' does not exist.", file.getPath()), e);
208                }
209            } else if (inZipPath != null) {
210                ZipEntry entry = zipFile.getEntry(inZipPath);
211                if (entry != null) {
212                    return zipFile.getInputStream(entry);
213                }
214            }
215            throw new IOException(tr("Unable to locate file  ''{0}''.", uriStr));
216        }
217
218        /**
219         * Return a File for a URI from a .jos/.joz file.
220         *
221         * Returns null if the URI points to a file inside the zip archive.
222         * In this case, inZipPath will be set to the corresponding path.
223         * @param uriStr the URI as string
224         * @return the resulting File
225         * @throws IOException if any I/O error occurs
226         */
227        public File getFile(String uriStr) throws IOException {
228            inZipPath = null;
229            try {
230                URI uri = new URI(uriStr);
231                if ("file".equals(uri.getScheme()))
232                    // absolute path
233                    return new File(uri);
234                else if (uri.getScheme() == null) {
235                    // Check if this is an absolute path without 'file:' scheme part.
236                    // At this point, (as an exception) platform dependent path separator will be recognized.
237                    // (This form is discouraged, only for users that like to copy and paste a path manually.)
238                    File file = new File(uriStr);
239                    if (file.isAbsolute())
240                        return file;
241                    else {
242                        // for relative paths, only forward slashes are permitted
243                        if (isZip()) {
244                            if (uri.getPath().startsWith("../")) {
245                                // relative to session file - "../" step out of the archive
246                                String relPath = uri.getPath().substring(3);
247                                return new File(sessionFileURI.resolve(relPath));
248                            } else {
249                                // file inside zip archive
250                                inZipPath = uriStr;
251                                return null;
252                            }
253                        } else
254                            return new File(sessionFileURI.resolve(uri));
255                    }
256                } else
257                    throw new IOException(tr("Unsupported scheme ''{0}'' in URI ''{1}''.", uri.getScheme(), uriStr));
258            } catch (URISyntaxException e) {
259                throw new IOException(e);
260            }
261        }
262
263        /**
264         * Determines if we are reading from a .joz file.
265         * @return {@code true} if we are reading from a .joz file, {@code false} otherwise
266         */
267        public boolean isZip() {
268            return zip;
269        }
270
271        /**
272         * Name of the layer that is currently imported.
273         * @return layer name
274         */
275        public String getLayerName() {
276            return layerName;
277        }
278
279        /**
280         * Index of the layer that is currently imported.
281         * @return layer index
282         */
283        public int getLayerIndex() {
284            return layerIndex;
285        }
286
287        /**
288         * Dependencies - maps the layer index to the importer of the given
289         * layer. All the dependent importers have loaded completely at this point.
290         * @return layer dependencies
291         */
292        public List<LayerDependency> getLayerDependencies() {
293            return layerDependencies;
294        }
295
296        @Override
297        public String toString() {
298            return "ImportSupport [layerName=" + layerName + ", layerIndex=" + layerIndex + ", layerDependencies="
299                    + layerDependencies + ", inZipPath=" + inZipPath + ']';
300        }
301    }
302
303    public static class LayerDependency {
304        private final Integer index;
305        private final Layer layer;
306        private final SessionLayerImporter importer;
307
308        public LayerDependency(Integer index, Layer layer, SessionLayerImporter importer) {
309            this.index = index;
310            this.layer = layer;
311            this.importer = importer;
312        }
313
314        public SessionLayerImporter getImporter() {
315            return importer;
316        }
317
318        public Integer getIndex() {
319            return index;
320        }
321
322        public Layer getLayer() {
323            return layer;
324        }
325    }
326
327    private static void error(String msg) throws IllegalDataException {
328        throw new IllegalDataException(msg);
329    }
330
331    private void parseJos(Document doc, ProgressMonitor progressMonitor) throws IllegalDataException {
332        Element root = doc.getDocumentElement();
333        if (!"josm-session".equals(root.getTagName())) {
334            error(tr("Unexpected root element ''{0}'' in session file", root.getTagName()));
335        }
336        String version = root.getAttribute("version");
337        if (!"0.1".equals(version)) {
338            error(tr("Version ''{0}'' of session file is not supported. Expected: 0.1", version));
339        }
340
341        Element viewportEl = getElementByTagName(root, "viewport");
342        if (viewportEl != null) {
343            EastNorth center = null;
344            Element centerEl = getElementByTagName(viewportEl, "center");
345            if (centerEl != null && centerEl.hasAttribute("lat") && centerEl.hasAttribute("lon")) {
346                try {
347                    LatLon centerLL = new LatLon(Double.parseDouble(centerEl.getAttribute("lat")),
348                            Double.parseDouble(centerEl.getAttribute("lon")));
349                    center = Projections.project(centerLL);
350                } catch (NumberFormatException ex) {
351                    Main.warn(ex);
352                }
353            }
354            if (center != null) {
355                Element scaleEl = getElementByTagName(viewportEl, "scale");
356                if (scaleEl != null && scaleEl.hasAttribute("meter-per-pixel")) {
357                    try {
358                        double meterPerPixel = Double.parseDouble(scaleEl.getAttribute("meter-per-pixel"));
359                        Projection proj = Main.getProjection();
360                        // Get a "typical" distance in east/north units that
361                        // corresponds to a couple of pixels. Shouldn't be too
362                        // large, to keep it within projection bounds and
363                        // not too small to avoid rounding errors.
364                        double dist = 0.01 * proj.getDefaultZoomInPPD();
365                        LatLon ll1 = proj.eastNorth2latlon(new EastNorth(center.east() - dist, center.north()));
366                        LatLon ll2 = proj.eastNorth2latlon(new EastNorth(center.east() + dist, center.north()));
367                        double meterPerEasting = ll1.greatCircleDistance(ll2) / dist / 2;
368                        double scale = meterPerPixel / meterPerEasting; // unit: easting per pixel
369                        viewport = new ViewportData(center, scale);
370                    } catch (NumberFormatException ex) {
371                        Main.warn(ex);
372                    }
373                }
374            }
375        }
376
377        Element layersEl = getElementByTagName(root, "layers");
378        if (layersEl == null) return;
379
380        String activeAtt = layersEl.getAttribute("active");
381        try {
382            active = !activeAtt.isEmpty() ? (Integer.parseInt(activeAtt)-1) : -1;
383        } catch (NumberFormatException e) {
384            Main.warn("Unsupported value for 'active' layer attribute. Ignoring it. Error was: "+e.getMessage());
385            active = -1;
386        }
387
388        MultiMap<Integer, Integer> deps = new MultiMap<>();
389        Map<Integer, Element> elems = new HashMap<>();
390
391        NodeList nodes = layersEl.getChildNodes();
392
393        for (int i = 0; i < nodes.getLength(); ++i) {
394            Node node = nodes.item(i);
395            if (node.getNodeType() == Node.ELEMENT_NODE) {
396                Element e = (Element) node;
397                if ("layer".equals(e.getTagName())) {
398                    if (!e.hasAttribute("index")) {
399                        error(tr("missing mandatory attribute ''index'' for element ''layer''"));
400                    }
401                    Integer idx = null;
402                    try {
403                        idx = Integer.valueOf(e.getAttribute("index"));
404                    } catch (NumberFormatException ex) {
405                        Main.warn(ex);
406                    }
407                    if (idx == null) {
408                        error(tr("unexpected format of attribute ''index'' for element ''layer''"));
409                    } else if (elems.containsKey(idx)) {
410                        error(tr("attribute ''index'' ({0}) for element ''layer'' must be unique", Integer.toString(idx)));
411                    }
412                    elems.put(idx, e);
413
414                    deps.putVoid(idx);
415                    String depStr = e.getAttribute("depends");
416                    if (!depStr.isEmpty()) {
417                        for (String sd : depStr.split(",")) {
418                            Integer d = null;
419                            try {
420                                d = Integer.valueOf(sd);
421                            } catch (NumberFormatException ex) {
422                                Main.warn(ex);
423                            }
424                            if (d != null) {
425                                deps.put(idx, d);
426                            }
427                        }
428                    }
429                }
430            }
431        }
432
433        List<Integer> sorted = Utils.topologicalSort(deps);
434        final Map<Integer, Layer> layersMap = new TreeMap<>(Collections.reverseOrder());
435        final Map<Integer, SessionLayerImporter> importers = new HashMap<>();
436        final Map<Integer, String> names = new HashMap<>();
437
438        progressMonitor.setTicksCount(sorted.size());
439        LAYER: for (int idx: sorted) {
440            Element e = elems.get(idx);
441            if (e == null) {
442                error(tr("missing layer with index {0}", idx));
443                return;
444            } else if (!e.hasAttribute("name")) {
445                error(tr("missing mandatory attribute ''name'' for element ''layer''"));
446                return;
447            }
448            String name = e.getAttribute("name");
449            names.put(idx, name);
450            if (!e.hasAttribute("type")) {
451                error(tr("missing mandatory attribute ''type'' for element ''layer''"));
452                return;
453            }
454            String type = e.getAttribute("type");
455            SessionLayerImporter imp = getSessionLayerImporter(type);
456            if (imp == null && !GraphicsEnvironment.isHeadless()) {
457                CancelOrContinueDialog dialog = new CancelOrContinueDialog();
458                dialog.show(
459                        tr("Unable to load layer"),
460                        tr("Cannot load layer of type ''{0}'' because no suitable importer was found.", type),
461                        JOptionPane.WARNING_MESSAGE,
462                        progressMonitor
463                        );
464                if (dialog.isCancel()) {
465                    progressMonitor.cancel();
466                    return;
467                } else {
468                    continue;
469                }
470            } else if (imp != null) {
471                importers.put(idx, imp);
472                List<LayerDependency> depsImp = new ArrayList<>();
473                for (int d : deps.get(idx)) {
474                    SessionLayerImporter dImp = importers.get(d);
475                    if (dImp == null) {
476                        CancelOrContinueDialog dialog = new CancelOrContinueDialog();
477                        dialog.show(
478                                tr("Unable to load layer"),
479                                tr("Cannot load layer {0} because it depends on layer {1} which has been skipped.", idx, d),
480                                JOptionPane.WARNING_MESSAGE,
481                                progressMonitor
482                                );
483                        if (dialog.isCancel()) {
484                            progressMonitor.cancel();
485                            return;
486                        } else {
487                            continue LAYER;
488                        }
489                    }
490                    depsImp.add(new LayerDependency(d, layersMap.get(d), dImp));
491                }
492                ImportSupport support = new ImportSupport(name, idx, depsImp);
493                Layer layer = null;
494                Exception exception = null;
495                try {
496                    layer = imp.load(e, support, progressMonitor.createSubTaskMonitor(1, false));
497                    if (layer == null) {
498                        throw new IllegalStateException("Importer " + imp + " returned null for " + support);
499                    }
500                } catch (IllegalDataException | IllegalStateException | IOException ex) {
501                    exception = ex;
502                }
503                if (exception != null) {
504                    Main.error(exception);
505                    if (!GraphicsEnvironment.isHeadless()) {
506                        CancelOrContinueDialog dialog = new CancelOrContinueDialog();
507                        dialog.show(
508                                tr("Error loading layer"),
509                                tr("<html>Could not load layer {0} ''{1}''.<br>Error is:<br>{2}</html>", idx, name, exception.getMessage()),
510                                JOptionPane.ERROR_MESSAGE,
511                                progressMonitor
512                                );
513                        if (dialog.isCancel()) {
514                            progressMonitor.cancel();
515                            return;
516                        } else {
517                            continue;
518                        }
519                    }
520                }
521
522                layersMap.put(idx, layer);
523            }
524            progressMonitor.worked(1);
525        }
526
527        layers = new ArrayList<>();
528        for (Entry<Integer, Layer> entry : layersMap.entrySet()) {
529            Layer layer = entry.getValue();
530            if (layer == null) {
531                continue;
532            }
533            Element el = elems.get(entry.getKey());
534            if (el.hasAttribute("visible")) {
535                layer.setVisible(Boolean.parseBoolean(el.getAttribute("visible")));
536            }
537            if (el.hasAttribute("opacity")) {
538                try {
539                    double opacity = Double.parseDouble(el.getAttribute("opacity"));
540                    layer.setOpacity(opacity);
541                } catch (NumberFormatException ex) {
542                    Main.warn(ex);
543                }
544            }
545            layer.setName(names.get(entry.getKey()));
546            layers.add(layer);
547        }
548    }
549
550    /**
551     * Show Dialog when there is an error for one layer.
552     * Ask the user whether to cancel the complete session loading or just to skip this layer.
553     *
554     * This is expected to run in a worker thread (PleaseWaitRunnable), so invokeAndWait is
555     * needed to block the current thread and wait for the result of the modal dialog from EDT.
556     */
557    private static class CancelOrContinueDialog {
558
559        private boolean cancel;
560
561        public void show(final String title, final String message, final int icon, final ProgressMonitor progressMonitor) {
562            try {
563                SwingUtilities.invokeAndWait(() -> {
564                    ExtendedDialog dlg = new ExtendedDialog(
565                            Main.parent,
566                            title,
567                            new String[] {tr("Cancel"), tr("Skip layer and continue")}
568                            );
569                    dlg.setButtonIcons(new String[] {"cancel", "dialogs/next"});
570                    dlg.setIcon(icon);
571                    dlg.setContent(message);
572                    dlg.showDialog();
573                    cancel = dlg.getValue() != 2;
574                });
575            } catch (InvocationTargetException | InterruptedException ex) {
576                throw new JosmRuntimeException(ex);
577            }
578        }
579
580        public boolean isCancel() {
581            return cancel;
582        }
583    }
584
585    /**
586     * Loads session from the given file.
587     * @param sessionFile session file to load
588     * @param zip {@code true} if it's a zipped session (.joz)
589     * @param progressMonitor progress monitor
590     * @throws IllegalDataException if invalid data is detected
591     * @throws IOException if any I/O error occurs
592     */
593    public void loadSession(File sessionFile, boolean zip, ProgressMonitor progressMonitor) throws IllegalDataException, IOException {
594        try (InputStream josIS = createInputStream(sessionFile, zip)) {
595            loadSession(josIS, sessionFile.toURI(), zip, progressMonitor != null ? progressMonitor : NullProgressMonitor.INSTANCE);
596        }
597    }
598
599    private InputStream createInputStream(File sessionFile, boolean zip) throws IOException, IllegalDataException {
600        if (zip) {
601            try {
602                zipFile = new ZipFile(sessionFile, StandardCharsets.UTF_8);
603                return getZipInputStream(zipFile);
604            } catch (ZipException ze) {
605                throw new IOException(ze);
606            }
607        } else {
608            try {
609                return new FileInputStream(sessionFile);
610            } catch (FileNotFoundException ex) {
611                throw new IOException(ex);
612            }
613        }
614    }
615
616    private static InputStream getZipInputStream(ZipFile zipFile) throws IOException, IllegalDataException {
617        ZipEntry josEntry = null;
618        Enumeration<? extends ZipEntry> entries = zipFile.entries();
619        while (entries.hasMoreElements()) {
620            ZipEntry entry = entries.nextElement();
621            if (Utils.hasExtension(entry.getName(), "jos")) {
622                josEntry = entry;
623                break;
624            }
625        }
626        if (josEntry == null) {
627            error(tr("expected .jos file inside .joz archive"));
628        }
629        return zipFile.getInputStream(josEntry);
630    }
631
632    private void loadSession(InputStream josIS, URI sessionFileURI, boolean zip, ProgressMonitor progressMonitor)
633            throws IOException, IllegalDataException {
634
635        this.sessionFileURI = sessionFileURI;
636        this.zip = zip;
637
638        try {
639            parseJos(Utils.parseSafeDOM(josIS), progressMonitor);
640        } catch (SAXException e) {
641            throw new IllegalDataException(e);
642        } catch (ParserConfigurationException e) {
643            throw new IOException(e);
644        }
645    }
646
647    private static Element getElementByTagName(Element root, String name) {
648        NodeList els = root.getElementsByTagName(name);
649        return els.getLength() > 0 ? (Element) els.item(0) : null;
650    }
651}