001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.io.session;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005import static org.openstreetmap.josm.tools.Utils.equal;
006
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.util.ArrayList;
017import java.util.Collections;
018import java.util.Enumeration;
019import java.util.HashMap;
020import java.util.List;
021import java.util.Map;
022import java.util.Map.Entry;
023import java.util.TreeMap;
024import java.util.zip.ZipEntry;
025import java.util.zip.ZipException;
026import java.util.zip.ZipFile;
027
028import javax.swing.JOptionPane;
029import javax.swing.SwingUtilities;
030import javax.xml.parsers.DocumentBuilder;
031import javax.xml.parsers.DocumentBuilderFactory;
032import javax.xml.parsers.ParserConfigurationException;
033
034import org.openstreetmap.josm.Main;
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.NavigatableComponent.ViewportData;
041import org.openstreetmap.josm.gui.layer.Layer;
042import org.openstreetmap.josm.gui.progress.NullProgressMonitor;
043import org.openstreetmap.josm.gui.progress.ProgressMonitor;
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 *
056 */
057public class SessionReader {
058
059    private static Map<String, Class<? extends SessionLayerImporter>> sessionLayerImporters = new HashMap<String, Class<? extends SessionLayerImporter>>();
060    static {
061        registerSessionLayerImporter("osm-data", OsmDataSessionImporter.class);
062        registerSessionLayerImporter("imagery", ImagerySessionImporter.class);
063        registerSessionLayerImporter("tracks", GpxTracksSessionImporter.class);
064        registerSessionLayerImporter("geoimage", GeoImageSessionImporter.class);
065        registerSessionLayerImporter("markers", MarkerSessionImporter.class);
066    }
067
068    public static void registerSessionLayerImporter(String layerType, Class<? extends SessionLayerImporter> importer) {
069        sessionLayerImporters.put(layerType, importer);
070    }
071
072    public static SessionLayerImporter getSessionLayerImporter(String layerType) {
073        Class<? extends SessionLayerImporter> importerClass = sessionLayerImporters.get(layerType);
074        if (importerClass == null)
075            return null;
076        SessionLayerImporter importer = null;
077        try {
078            importer = importerClass.newInstance();
079        } catch (InstantiationException e) {
080            throw new RuntimeException(e);
081        } catch (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<Layer>();
091    private int active = -1;
092    private List<Runnable> postLoadTasks = new ArrayList<Runnable>();
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         *
174         * @throws IOException Thrown when no Stream can be opened for the given URI, e.g. when the linked file has been deleted.
175         */
176        public InputStream getInputStream(String uriStr) throws IOException {
177            File file = getFile(uriStr);
178            if (file != null) {
179                try {
180                    return new BufferedInputStream(new FileInputStream(file));
181                } catch (FileNotFoundException e) {
182                    throw new IOException(tr("File ''{0}'' does not exist.", file.getPath()));
183                }
184            } else if (inZipPath != null) {
185                ZipEntry entry = zipFile.getEntry(inZipPath);
186                if (entry != null) {
187                    InputStream is = zipFile.getInputStream(entry);
188                    return is;
189                }
190            }
191            throw new IOException(tr("Unable to locate file  ''{0}''.", uriStr));
192        }
193
194        /**
195         * Return a File for a URI from a .jos/.joz file.
196         *
197         * Returns null if the URI points to a file inside the zip archive.
198         * In this case, inZipPath will be set to the corresponding path.
199         */
200        public File getFile(String uriStr) throws IOException {
201            inZipPath = null;
202            try {
203                URI uri = new URI(uriStr);
204                if ("file".equals(uri.getScheme()))
205                    // absolute path
206                    return new File(uri);
207                else if (uri.getScheme() == null) {
208                    // Check if this is an absolute path without 'file:' scheme part.
209                    // At this point, (as an exception) platform dependent path separator will be recognized.
210                    // (This form is discouraged, only for users that like to copy and paste a path manually.)
211                    File file = new File(uriStr);
212                    if (file.isAbsolute())
213                        return file;
214                    else {
215                        // for relative paths, only forward slashes are permitted
216                        if (isZip()) {
217                            if (uri.getPath().startsWith("../")) {
218                                // relative to session file - "../" step out of the archive
219                                String relPath = uri.getPath().substring(3);
220                                return new File(sessionFileURI.resolve(relPath));
221                            } else {
222                                // file inside zip archive
223                                inZipPath = uriStr;
224                                return null;
225                            }
226                        } else
227                            return new File(sessionFileURI.resolve(uri));
228                    }
229                } else
230                    throw new IOException(tr("Unsupported scheme ''{0}'' in URI ''{1}''.", uri.getScheme(), uriStr));
231            } catch (URISyntaxException e) {
232                throw new IOException(e);
233            }
234        }
235
236        /**
237         * Determines if we are reading from a .joz file.
238         * @return {@code true} if we are reading from a .joz file, {@code false} otherwise
239         */
240        public boolean isZip() {
241            return zip;
242        }
243
244        /**
245         * Name of the layer that is currently imported.
246         */
247        public String getLayerName() {
248            return layerName;
249        }
250
251        /**
252         * Index of the layer that is currently imported.
253         */
254        public int getLayerIndex() {
255            return layerIndex;
256        }
257
258        /**
259         * Dependencies - maps the layer index to the importer of the given
260         * layer. All the dependent importers have loaded completely at this point.
261         */
262        public List<LayerDependency> getLayerDependencies() {
263            return layerDependencies;
264        }
265    }
266
267    public static class LayerDependency {
268        private Integer index;
269        private Layer layer;
270        private SessionLayerImporter importer;
271
272        public LayerDependency(Integer index, Layer layer, SessionLayerImporter importer) {
273            this.index = index;
274            this.layer = layer;
275            this.importer = importer;
276        }
277
278        public SessionLayerImporter getImporter() {
279            return importer;
280        }
281
282        public Integer getIndex() {
283            return index;
284        }
285
286        public Layer getLayer() {
287            return layer;
288        }
289    }
290
291    private static void error(String msg) throws IllegalDataException {
292        throw new IllegalDataException(msg);
293    }
294
295    private void parseJos(Document doc, ProgressMonitor progressMonitor) throws IllegalDataException {
296        Element root = doc.getDocumentElement();
297        if (!equal(root.getTagName(), "josm-session")) {
298            error(tr("Unexpected root element ''{0}'' in session file", root.getTagName()));
299        }
300        String version = root.getAttribute("version");
301        if (!"0.1".equals(version)) {
302            error(tr("Version ''{0}'' of session file is not supported. Expected: 0.1", version));
303        }
304
305        Element viewportEl = getElementByTagName(root, "viewport");
306        if (viewportEl != null) {
307            EastNorth center = null;
308            Element centerEl = getElementByTagName(viewportEl, "center");
309            if (centerEl != null && centerEl.hasAttribute("lat") && centerEl.hasAttribute("lon")) {
310                try {
311                    LatLon centerLL = new LatLon(Double.parseDouble(centerEl.getAttribute("lat")), Double.parseDouble(centerEl.getAttribute("lon")));
312                    center = Projections.project(centerLL);
313                } catch (NumberFormatException ex) {}
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                }
333            }
334        }
335
336        Element layersEl = getElementByTagName(root, "layers");
337        if (layersEl == null) return;
338
339        String activeAtt = layersEl.getAttribute("active");
340        try {
341            active = (activeAtt != null && !activeAtt.isEmpty()) ? Integer.parseInt(activeAtt)-1 : -1;
342        } catch (NumberFormatException e) {
343            Main.warn("Unsupported value for 'active' layer attribute. Ignoring it. Error was: "+e.getMessage());
344            active = -1;
345        }
346        
347        MultiMap<Integer, Integer> deps = new MultiMap<Integer, Integer>();
348        Map<Integer, Element> elems = new HashMap<Integer, Element>();
349
350        NodeList nodes = layersEl.getChildNodes();
351
352        for (int i=0; i<nodes.getLength(); ++i) {
353            Node node = nodes.item(i);
354            if (node.getNodeType() == Node.ELEMENT_NODE) {
355                Element e = (Element) node;
356                if (equal(e.getTagName(), "layer")) {
357
358                    if (!e.hasAttribute("index")) {
359                        error(tr("missing mandatory attribute ''index'' for element ''layer''"));
360                    }
361                    Integer idx = null;
362                    try {
363                        idx = Integer.parseInt(e.getAttribute("index"));
364                    } catch (NumberFormatException ex) {}
365                    if (idx == null) {
366                        error(tr("unexpected format of attribute ''index'' for element ''layer''"));
367                    }
368                    if (elems.containsKey(idx)) {
369                        error(tr("attribute ''index'' ({0}) for element ''layer'' must be unique", Integer.toString(idx)));
370                    }
371                    elems.put(idx, e);
372
373                    deps.putVoid(idx);
374                    String depStr = e.getAttribute("depends");
375                    if (depStr != null) {
376                        for (String sd : depStr.split(",")) {
377                            Integer d = null;
378                            try {
379                                d = Integer.parseInt(sd);
380                            } catch (NumberFormatException ex) {
381                                Main.warn(ex);
382                            }
383                            if (d != null) {
384                                deps.put(idx, d);
385                            }
386                        }
387                    }
388                }
389            }
390        }
391
392        List<Integer> sorted = Utils.topologicalSort(deps);
393        final Map<Integer, Layer> layersMap = new TreeMap<Integer, Layer>(Collections.reverseOrder());
394        final Map<Integer, SessionLayerImporter> importers = new HashMap<Integer, SessionLayerImporter>();
395        final Map<Integer, String> names = new HashMap<Integer, String>();
396
397        progressMonitor.setTicksCount(sorted.size());
398        LAYER: for (int idx: sorted) {
399            Element e = elems.get(idx);
400            if (e == null) {
401                error(tr("missing layer with index {0}", idx));
402            }
403            if (!e.hasAttribute("name")) {
404                error(tr("missing mandatory attribute ''name'' for element ''layer''"));
405            }
406            String name = e.getAttribute("name");
407            names.put(idx, name);
408            if (!e.hasAttribute("type")) {
409                error(tr("missing mandatory attribute ''type'' for element ''layer''"));
410            }
411            String type = e.getAttribute("type");
412            SessionLayerImporter imp = getSessionLayerImporter(type);
413            if (imp == null) {
414                CancelOrContinueDialog dialog = new CancelOrContinueDialog();
415                dialog.show(
416                        tr("Unable to load layer"),
417                        tr("Cannot load layer of type ''{0}'' because no suitable importer was found.", type),
418                        JOptionPane.WARNING_MESSAGE,
419                        progressMonitor
420                        );
421                if (dialog.isCancel()) {
422                    progressMonitor.cancel();
423                    return;
424                } else {
425                    continue;
426                }
427            } else {
428                importers.put(idx, imp);
429                List<LayerDependency> depsImp = new ArrayList<LayerDependency>();
430                for (int d : deps.get(idx)) {
431                    SessionLayerImporter dImp = importers.get(d);
432                    if (dImp == null) {
433                        CancelOrContinueDialog dialog = new CancelOrContinueDialog();
434                        dialog.show(
435                                tr("Unable to load layer"),
436                                tr("Cannot load layer {0} because it depends on layer {1} which has been skipped.", idx, d),
437                                JOptionPane.WARNING_MESSAGE,
438                                progressMonitor
439                                );
440                        if (dialog.isCancel()) {
441                            progressMonitor.cancel();
442                            return;
443                        } else {
444                            continue LAYER;
445                        }
446                    }
447                    depsImp.add(new LayerDependency(d, layersMap.get(d), dImp));
448                }
449                ImportSupport support = new ImportSupport(name, idx, depsImp);
450                Layer layer = null;
451                Exception exception = null;
452                try {
453                    layer = imp.load(e, support, progressMonitor.createSubTaskMonitor(1, false));
454                } catch (IllegalDataException ex) {
455                    exception = ex;
456                } catch (IOException ex) {
457                    exception = ex;
458                }
459                if (exception != null) {
460                    exception.printStackTrace();
461                    CancelOrContinueDialog dialog = new CancelOrContinueDialog();
462                    dialog.show(
463                            tr("Error loading layer"),
464                            tr("<html>Could not load layer {0} ''{1}''.<br>Error is:<br>{2}</html>", idx, name, exception.getMessage()),
465                            JOptionPane.ERROR_MESSAGE,
466                            progressMonitor
467                            );
468                    if (dialog.isCancel()) {
469                        progressMonitor.cancel();
470                        return;
471                    } else {
472                        continue;
473                    }
474                }
475
476                if (layer == null) throw new RuntimeException();
477                layersMap.put(idx, layer);
478            }
479            progressMonitor.worked(1);
480        }
481
482        layers = new ArrayList<Layer>();
483        for (int idx : layersMap.keySet()) {
484            Layer layer = layersMap.get(idx);
485            if (layer == null) {
486                continue;
487            }
488            Element el = elems.get(idx);
489            if (el.hasAttribute("visible")) {
490                layer.setVisible(Boolean.parseBoolean(el.getAttribute("visible")));
491            }
492            if (el.hasAttribute("opacity")) {
493                try {
494                    double opacity = Double.parseDouble(el.getAttribute("opacity"));
495                    layer.setOpacity(opacity);
496                } catch (NumberFormatException ex) {
497                    Main.warn(ex);
498                }
499            }
500        }
501        for (Entry<Integer, Layer> e : layersMap.entrySet()) {
502            Layer l = e.getValue();
503            if (l == null) {
504                continue;
505            }
506
507            l.setName(names.get(e.getKey()));
508            layers.add(l);
509        }
510    }
511
512    /**
513     * Show Dialog when there is an error for one layer.
514     * Ask the user whether to cancel the complete session loading or just to skip this layer.
515     *
516     * This is expected to run in a worker thread (PleaseWaitRunnable), so invokeAndWait is
517     * needed to block the current thread and wait for the result of the modal dialog from EDT.
518     */
519    private static class CancelOrContinueDialog {
520
521        private boolean cancel;
522
523        public void show(final String title, final String message, final int icon, final ProgressMonitor progressMonitor) {
524            try {
525                SwingUtilities.invokeAndWait(new Runnable() {
526                    @Override public void run() {
527                        ExtendedDialog dlg = new ExtendedDialog(
528                                Main.parent,
529                                title,
530                                new String[] { tr("Cancel"), tr("Skip layer and continue") }
531                                );
532                        dlg.setButtonIcons(new String[] {"cancel", "dialogs/next"});
533                        dlg.setIcon(icon);
534                        dlg.setContent(message);
535                        dlg.showDialog();
536                        cancel = dlg.getValue() != 2;
537                    }
538                });
539            } catch (InvocationTargetException ex) {
540                throw new RuntimeException(ex);
541            } catch (InterruptedException ex) {
542                throw new RuntimeException(ex);
543            }
544        }
545
546        public boolean isCancel() {
547            return cancel;
548        }
549    }
550
551    public void loadSession(File sessionFile, boolean zip, ProgressMonitor progressMonitor) throws IllegalDataException, IOException {
552        if (progressMonitor == null) {
553            progressMonitor = NullProgressMonitor.INSTANCE;
554        }
555
556        InputStream josIS = null;
557
558        if (zip) {
559            try {
560                zipFile = new ZipFile(sessionFile);
561                josIS = getZipInputStream(zipFile);
562            } catch (ZipException ze) {
563                throw new IOException(ze);
564            }
565        } else {
566            try {
567                josIS = new FileInputStream(sessionFile);
568            } catch (FileNotFoundException ex) {
569                throw new IOException(ex);
570            }
571        }
572
573        loadSession(josIS, sessionFile.toURI(), zip, progressMonitor);
574    }
575
576    private static InputStream getZipInputStream(ZipFile zipFile) throws ZipException, IOException, IllegalDataException {
577        ZipEntry josEntry = null;
578        Enumeration<? extends ZipEntry> entries = zipFile.entries();
579        while (entries.hasMoreElements()) {
580            ZipEntry entry = entries.nextElement();
581            if (entry.getName().toLowerCase().endsWith(".jos")) {
582                josEntry = entry;
583                break;
584            }
585        }
586        if (josEntry == null) {
587            error(tr("expected .jos file inside .joz archive"));
588        }
589        return zipFile.getInputStream(josEntry);
590    }
591
592    private void loadSession(InputStream josIS, URI sessionFileURI, boolean zip, ProgressMonitor progressMonitor) throws IOException, IllegalDataException {
593        
594        this.sessionFileURI = sessionFileURI;
595        this.zip = zip;
596        
597        try {
598            DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance();
599            builderFactory.setValidating(false);
600            builderFactory.setNamespaceAware(true);
601            DocumentBuilder builder = builderFactory.newDocumentBuilder();
602            Document document = builder.parse(josIS);
603            parseJos(document, progressMonitor);
604        } catch (SAXException e) {
605            throw new IllegalDataException(e);
606        } catch (ParserConfigurationException e) {
607            throw new IOException(e);
608        }
609    }
610
611    private static Element getElementByTagName(Element root, String name) {
612        NodeList els = root.getElementsByTagName(name);
613        if (els.getLength() == 0) return null;
614        return (Element) els.item(0);
615    }
616}