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