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