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.JosmRuntimeException; 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 * @since 4668 057 */ 058public class SessionReader { 059 060 private static final Map<String, Class<? extends SessionLayerImporter>> sessionLayerImporters = new HashMap<>(); 061 062 private URI sessionFileURI; 063 private boolean zip; // true, if session file is a .joz file; false if it is a .jos file 064 private ZipFile zipFile; 065 private List<Layer> layers = new ArrayList<>(); 066 private int active = -1; 067 private final List<Runnable> postLoadTasks = new ArrayList<>(); 068 private ViewportData viewport; 069 070 static { 071 registerSessionLayerImporter("osm-data", OsmDataSessionImporter.class); 072 registerSessionLayerImporter("imagery", ImagerySessionImporter.class); 073 registerSessionLayerImporter("tracks", GpxTracksSessionImporter.class); 074 registerSessionLayerImporter("geoimage", GeoImageSessionImporter.class); 075 registerSessionLayerImporter("markers", MarkerSessionImporter.class); 076 registerSessionLayerImporter("osm-notes", NoteSessionImporter.class); 077 } 078 079 /** 080 * Register a session layer importer. 081 * 082 * @param layerType layer type 083 * @param importer importer for this layer class 084 */ 085 public static void registerSessionLayerImporter(String layerType, Class<? extends SessionLayerImporter> importer) { 086 sessionLayerImporters.put(layerType, importer); 087 } 088 089 /** 090 * Returns the session layer importer for the given layer type. 091 * @param layerType layer type to import 092 * @return session layer importer for the given layer 093 */ 094 public static SessionLayerImporter getSessionLayerImporter(String layerType) { 095 Class<? extends SessionLayerImporter> importerClass = sessionLayerImporters.get(layerType); 096 if (importerClass == null) 097 return null; 098 SessionLayerImporter importer = null; 099 try { 100 importer = importerClass.getConstructor().newInstance(); 101 } catch (ReflectiveOperationException e) { 102 throw new JosmRuntimeException(e); 103 } 104 return importer; 105 } 106 107 /** 108 * @return list of layers that are later added to the mapview 109 */ 110 public List<Layer> getLayers() { 111 return layers; 112 } 113 114 /** 115 * @return active layer, or {@code null} if not set 116 * @since 6271 117 */ 118 public Layer getActive() { 119 // layers is in reverse order because of the way TreeMap is built 120 return (active >= 0 && active < layers.size()) ? layers.get(layers.size()-1-active) : null; 121 } 122 123 /** 124 * @return actions executed in EDT after layers have been added (message dialog, etc.) 125 */ 126 public List<Runnable> getPostLoadTasks() { 127 return postLoadTasks; 128 } 129 130 /** 131 * Return the viewport (map position and scale). 132 * @return The viewport. Can be null when no viewport info is found in the file. 133 */ 134 public ViewportData getViewport() { 135 return viewport; 136 } 137 138 /** 139 * A class that provides some context for the individual {@link SessionLayerImporter} 140 * when doing the import. 141 */ 142 public class ImportSupport { 143 144 private final String layerName; 145 private final int layerIndex; 146 private final List<LayerDependency> layerDependencies; 147 148 /** 149 * Path of the file inside the zip archive. 150 * Used as alternative return value for getFile method. 151 */ 152 private String inZipPath; 153 154 /** 155 * Constructs a new {@code ImportSupport}. 156 * @param layerName layer name 157 * @param layerIndex layer index 158 * @param layerDependencies layer dependencies 159 */ 160 public ImportSupport(String layerName, int layerIndex, List<LayerDependency> layerDependencies) { 161 this.layerName = layerName; 162 this.layerIndex = layerIndex; 163 this.layerDependencies = layerDependencies; 164 } 165 166 /** 167 * Add a task, e.g. a message dialog, that should 168 * be executed in EDT after all layers have been added. 169 * @param task task to run in EDT 170 */ 171 public void addPostLayersTask(Runnable task) { 172 postLoadTasks.add(task); 173 } 174 175 /** 176 * Return an InputStream for a URI from a .jos/.joz file. 177 * 178 * The following forms are supported: 179 * 180 * - absolute file (both .jos and .joz): 181 * "file:///home/user/data.osm" 182 * "file:/home/user/data.osm" 183 * "file:///C:/files/data.osm" 184 * "file:/C:/file/data.osm" 185 * "/home/user/data.osm" 186 * "C:\files\data.osm" (not a URI, but recognized by File constructor on Windows systems) 187 * - standalone .jos files: 188 * - relative uri: 189 * "save/data.osm" 190 * "../project2/data.osm" 191 * - for .joz files: 192 * - file inside zip archive: 193 * "layers/01/data.osm" 194 * - relativ to the .joz file: 195 * "../save/data.osm" ("../" steps out of the archive) 196 * @param uriStr URI as string 197 * @return the InputStream 198 * 199 * @throws IOException Thrown when no Stream can be opened for the given URI, e.g. when the linked file has been deleted. 200 */ 201 public InputStream getInputStream(String uriStr) throws IOException { 202 File file = getFile(uriStr); 203 if (file != null) { 204 try { 205 return new BufferedInputStream(Compression.getUncompressedFileInputStream(file)); 206 } catch (FileNotFoundException e) { 207 throw new IOException(tr("File ''{0}'' does not exist.", file.getPath()), e); 208 } 209 } else if (inZipPath != null) { 210 ZipEntry entry = zipFile.getEntry(inZipPath); 211 if (entry != null) { 212 return zipFile.getInputStream(entry); 213 } 214 } 215 throw new IOException(tr("Unable to locate file ''{0}''.", uriStr)); 216 } 217 218 /** 219 * Return a File for a URI from a .jos/.joz file. 220 * 221 * Returns null if the URI points to a file inside the zip archive. 222 * In this case, inZipPath will be set to the corresponding path. 223 * @param uriStr the URI as string 224 * @return the resulting File 225 * @throws IOException if any I/O error occurs 226 */ 227 public File getFile(String uriStr) throws IOException { 228 inZipPath = null; 229 try { 230 URI uri = new URI(uriStr); 231 if ("file".equals(uri.getScheme())) 232 // absolute path 233 return new File(uri); 234 else if (uri.getScheme() == null) { 235 // Check if this is an absolute path without 'file:' scheme part. 236 // At this point, (as an exception) platform dependent path separator will be recognized. 237 // (This form is discouraged, only for users that like to copy and paste a path manually.) 238 File file = new File(uriStr); 239 if (file.isAbsolute()) 240 return file; 241 else { 242 // for relative paths, only forward slashes are permitted 243 if (isZip()) { 244 if (uri.getPath().startsWith("../")) { 245 // relative to session file - "../" step out of the archive 246 String relPath = uri.getPath().substring(3); 247 return new File(sessionFileURI.resolve(relPath)); 248 } else { 249 // file inside zip archive 250 inZipPath = uriStr; 251 return null; 252 } 253 } else 254 return new File(sessionFileURI.resolve(uri)); 255 } 256 } else 257 throw new IOException(tr("Unsupported scheme ''{0}'' in URI ''{1}''.", uri.getScheme(), uriStr)); 258 } catch (URISyntaxException e) { 259 throw new IOException(e); 260 } 261 } 262 263 /** 264 * Determines if we are reading from a .joz file. 265 * @return {@code true} if we are reading from a .joz file, {@code false} otherwise 266 */ 267 public boolean isZip() { 268 return zip; 269 } 270 271 /** 272 * Name of the layer that is currently imported. 273 * @return layer name 274 */ 275 public String getLayerName() { 276 return layerName; 277 } 278 279 /** 280 * Index of the layer that is currently imported. 281 * @return layer index 282 */ 283 public int getLayerIndex() { 284 return layerIndex; 285 } 286 287 /** 288 * Dependencies - maps the layer index to the importer of the given 289 * layer. All the dependent importers have loaded completely at this point. 290 * @return layer dependencies 291 */ 292 public List<LayerDependency> getLayerDependencies() { 293 return layerDependencies; 294 } 295 296 @Override 297 public String toString() { 298 return "ImportSupport [layerName=" + layerName + ", layerIndex=" + layerIndex + ", layerDependencies=" 299 + layerDependencies + ", inZipPath=" + inZipPath + ']'; 300 } 301 } 302 303 public static class LayerDependency { 304 private final Integer index; 305 private final Layer layer; 306 private final SessionLayerImporter importer; 307 308 public LayerDependency(Integer index, Layer layer, SessionLayerImporter importer) { 309 this.index = index; 310 this.layer = layer; 311 this.importer = importer; 312 } 313 314 public SessionLayerImporter getImporter() { 315 return importer; 316 } 317 318 public Integer getIndex() { 319 return index; 320 } 321 322 public Layer getLayer() { 323 return layer; 324 } 325 } 326 327 private static void error(String msg) throws IllegalDataException { 328 throw new IllegalDataException(msg); 329 } 330 331 private void parseJos(Document doc, ProgressMonitor progressMonitor) throws IllegalDataException { 332 Element root = doc.getDocumentElement(); 333 if (!"josm-session".equals(root.getTagName())) { 334 error(tr("Unexpected root element ''{0}'' in session file", root.getTagName())); 335 } 336 String version = root.getAttribute("version"); 337 if (!"0.1".equals(version)) { 338 error(tr("Version ''{0}'' of session file is not supported. Expected: 0.1", version)); 339 } 340 341 Element viewportEl = getElementByTagName(root, "viewport"); 342 if (viewportEl != null) { 343 EastNorth center = null; 344 Element centerEl = getElementByTagName(viewportEl, "center"); 345 if (centerEl != null && centerEl.hasAttribute("lat") && centerEl.hasAttribute("lon")) { 346 try { 347 LatLon centerLL = new LatLon(Double.parseDouble(centerEl.getAttribute("lat")), 348 Double.parseDouble(centerEl.getAttribute("lon"))); 349 center = Projections.project(centerLL); 350 } catch (NumberFormatException ex) { 351 Main.warn(ex); 352 } 353 } 354 if (center != null) { 355 Element scaleEl = getElementByTagName(viewportEl, "scale"); 356 if (scaleEl != null && scaleEl.hasAttribute("meter-per-pixel")) { 357 try { 358 double meterPerPixel = Double.parseDouble(scaleEl.getAttribute("meter-per-pixel")); 359 Projection proj = Main.getProjection(); 360 // Get a "typical" distance in east/north units that 361 // corresponds to a couple of pixels. Shouldn't be too 362 // large, to keep it within projection bounds and 363 // not too small to avoid rounding errors. 364 double dist = 0.01 * proj.getDefaultZoomInPPD(); 365 LatLon ll1 = proj.eastNorth2latlon(new EastNorth(center.east() - dist, center.north())); 366 LatLon ll2 = proj.eastNorth2latlon(new EastNorth(center.east() + dist, center.north())); 367 double meterPerEasting = ll1.greatCircleDistance(ll2) / dist / 2; 368 double scale = meterPerPixel / meterPerEasting; // unit: easting per pixel 369 viewport = new ViewportData(center, scale); 370 } catch (NumberFormatException ex) { 371 Main.warn(ex); 372 } 373 } 374 } 375 } 376 377 Element layersEl = getElementByTagName(root, "layers"); 378 if (layersEl == null) return; 379 380 String activeAtt = layersEl.getAttribute("active"); 381 try { 382 active = !activeAtt.isEmpty() ? (Integer.parseInt(activeAtt)-1) : -1; 383 } catch (NumberFormatException e) { 384 Main.warn("Unsupported value for 'active' layer attribute. Ignoring it. Error was: "+e.getMessage()); 385 active = -1; 386 } 387 388 MultiMap<Integer, Integer> deps = new MultiMap<>(); 389 Map<Integer, Element> elems = new HashMap<>(); 390 391 NodeList nodes = layersEl.getChildNodes(); 392 393 for (int i = 0; i < nodes.getLength(); ++i) { 394 Node node = nodes.item(i); 395 if (node.getNodeType() == Node.ELEMENT_NODE) { 396 Element e = (Element) node; 397 if ("layer".equals(e.getTagName())) { 398 if (!e.hasAttribute("index")) { 399 error(tr("missing mandatory attribute ''index'' for element ''layer''")); 400 } 401 Integer idx = null; 402 try { 403 idx = Integer.valueOf(e.getAttribute("index")); 404 } catch (NumberFormatException ex) { 405 Main.warn(ex); 406 } 407 if (idx == null) { 408 error(tr("unexpected format of attribute ''index'' for element ''layer''")); 409 } else 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.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 JosmRuntimeException(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}