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