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