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