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