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