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 296 public static class LayerDependency { 297 private final Integer index; 298 private final Layer layer; 299 private final SessionLayerImporter importer; 300 301 public LayerDependency(Integer index, Layer layer, SessionLayerImporter importer) { 302 this.index = index; 303 this.layer = layer; 304 this.importer = importer; 305 } 306 307 public SessionLayerImporter getImporter() { 308 return importer; 309 } 310 311 public Integer getIndex() { 312 return index; 313 } 314 315 public Layer getLayer() { 316 return layer; 317 } 318 } 319 320 private static void error(String msg) throws IllegalDataException { 321 throw new IllegalDataException(msg); 322 } 323 324 private void parseJos(Document doc, ProgressMonitor progressMonitor) throws IllegalDataException { 325 Element root = doc.getDocumentElement(); 326 if (!"josm-session".equals(root.getTagName())) { 327 error(tr("Unexpected root element ''{0}'' in session file", root.getTagName())); 328 } 329 String version = root.getAttribute("version"); 330 if (!"0.1".equals(version)) { 331 error(tr("Version ''{0}'' of session file is not supported. Expected: 0.1", version)); 332 } 333 334 Element viewportEl = getElementByTagName(root, "viewport"); 335 if (viewportEl != null) { 336 EastNorth center = null; 337 Element centerEl = getElementByTagName(viewportEl, "center"); 338 if (centerEl != null && centerEl.hasAttribute("lat") && centerEl.hasAttribute("lon")) { 339 try { 340 LatLon centerLL = new LatLon(Double.parseDouble(centerEl.getAttribute("lat")), 341 Double.parseDouble(centerEl.getAttribute("lon"))); 342 center = Projections.project(centerLL); 343 } catch (NumberFormatException ex) { 344 Main.warn(ex); 345 } 346 } 347 if (center != null) { 348 Element scaleEl = getElementByTagName(viewportEl, "scale"); 349 if (scaleEl != null && scaleEl.hasAttribute("meter-per-pixel")) { 350 try { 351 double meterPerPixel = Double.parseDouble(scaleEl.getAttribute("meter-per-pixel")); 352 Projection proj = Main.getProjection(); 353 // Get a "typical" distance in east/north units that 354 // corresponds to a couple of pixels. Shouldn't be too 355 // large, to keep it within projection bounds and 356 // not too small to avoid rounding errors. 357 double dist = 0.01 * proj.getDefaultZoomInPPD(); 358 LatLon ll1 = proj.eastNorth2latlon(new EastNorth(center.east() - dist, center.north())); 359 LatLon ll2 = proj.eastNorth2latlon(new EastNorth(center.east() + dist, center.north())); 360 double meterPerEasting = ll1.greatCircleDistance(ll2) / dist / 2; 361 double scale = meterPerPixel / meterPerEasting; // unit: easting per pixel 362 viewport = new ViewportData(center, scale); 363 } catch (NumberFormatException ex) { 364 Main.warn(ex); 365 } 366 } 367 } 368 } 369 370 Element layersEl = getElementByTagName(root, "layers"); 371 if (layersEl == null) return; 372 373 String activeAtt = layersEl.getAttribute("active"); 374 try { 375 active = (activeAtt != null && !activeAtt.isEmpty()) ? Integer.parseInt(activeAtt)-1 : -1; 376 } catch (NumberFormatException e) { 377 Main.warn("Unsupported value for 'active' layer attribute. Ignoring it. Error was: "+e.getMessage()); 378 active = -1; 379 } 380 381 MultiMap<Integer, Integer> deps = new MultiMap<>(); 382 Map<Integer, Element> elems = new HashMap<>(); 383 384 NodeList nodes = layersEl.getChildNodes(); 385 386 for (int i = 0; i < nodes.getLength(); ++i) { 387 Node node = nodes.item(i); 388 if (node.getNodeType() == Node.ELEMENT_NODE) { 389 Element e = (Element) node; 390 if ("layer".equals(e.getTagName())) { 391 if (!e.hasAttribute("index")) { 392 error(tr("missing mandatory attribute ''index'' for element ''layer''")); 393 } 394 Integer idx = null; 395 try { 396 idx = Integer.valueOf(e.getAttribute("index")); 397 } catch (NumberFormatException ex) { 398 Main.warn(ex); 399 } 400 if (idx == null) { 401 error(tr("unexpected format of attribute ''index'' for element ''layer''")); 402 } 403 if (elems.containsKey(idx)) { 404 error(tr("attribute ''index'' ({0}) for element ''layer'' must be unique", Integer.toString(idx))); 405 } 406 elems.put(idx, e); 407 408 deps.putVoid(idx); 409 String depStr = e.getAttribute("depends"); 410 if (depStr != null && !depStr.isEmpty()) { 411 for (String sd : depStr.split(",")) { 412 Integer d = null; 413 try { 414 d = Integer.valueOf(sd); 415 } catch (NumberFormatException ex) { 416 Main.warn(ex); 417 } 418 if (d != null) { 419 deps.put(idx, d); 420 } 421 } 422 } 423 } 424 } 425 } 426 427 List<Integer> sorted = Utils.topologicalSort(deps); 428 final Map<Integer, Layer> layersMap = new TreeMap<>(Collections.reverseOrder()); 429 final Map<Integer, SessionLayerImporter> importers = new HashMap<>(); 430 final Map<Integer, String> names = new HashMap<>(); 431 432 progressMonitor.setTicksCount(sorted.size()); 433 LAYER: for (int idx: sorted) { 434 Element e = elems.get(idx); 435 if (e == null) { 436 error(tr("missing layer with index {0}", idx)); 437 return; 438 } else if (!e.hasAttribute("name")) { 439 error(tr("missing mandatory attribute ''name'' for element ''layer''")); 440 return; 441 } 442 String name = e.getAttribute("name"); 443 names.put(idx, name); 444 if (!e.hasAttribute("type")) { 445 error(tr("missing mandatory attribute ''type'' for element ''layer''")); 446 return; 447 } 448 String type = e.getAttribute("type"); 449 SessionLayerImporter imp = getSessionLayerImporter(type); 450 if (imp == null && !GraphicsEnvironment.isHeadless()) { 451 CancelOrContinueDialog dialog = new CancelOrContinueDialog(); 452 dialog.show( 453 tr("Unable to load layer"), 454 tr("Cannot load layer of type ''{0}'' because no suitable importer was found.", type), 455 JOptionPane.WARNING_MESSAGE, 456 progressMonitor 457 ); 458 if (dialog.isCancel()) { 459 progressMonitor.cancel(); 460 return; 461 } else { 462 continue; 463 } 464 } else if (imp != null) { 465 importers.put(idx, imp); 466 List<LayerDependency> depsImp = new ArrayList<>(); 467 for (int d : deps.get(idx)) { 468 SessionLayerImporter dImp = importers.get(d); 469 if (dImp == null) { 470 CancelOrContinueDialog dialog = new CancelOrContinueDialog(); 471 dialog.show( 472 tr("Unable to load layer"), 473 tr("Cannot load layer {0} because it depends on layer {1} which has been skipped.", idx, d), 474 JOptionPane.WARNING_MESSAGE, 475 progressMonitor 476 ); 477 if (dialog.isCancel()) { 478 progressMonitor.cancel(); 479 return; 480 } else { 481 continue LAYER; 482 } 483 } 484 depsImp.add(new LayerDependency(d, layersMap.get(d), dImp)); 485 } 486 ImportSupport support = new ImportSupport(name, idx, depsImp); 487 Layer layer = null; 488 Exception exception = null; 489 try { 490 layer = imp.load(e, support, progressMonitor.createSubTaskMonitor(1, false)); 491 } catch (IllegalDataException | IOException ex) { 492 exception = ex; 493 } 494 if (exception != null) { 495 Main.error(exception); 496 if (!GraphicsEnvironment.isHeadless()) { 497 CancelOrContinueDialog dialog = new CancelOrContinueDialog(); 498 dialog.show( 499 tr("Error loading layer"), 500 tr("<html>Could not load layer {0} ''{1}''.<br>Error is:<br>{2}</html>", idx, name, exception.getMessage()), 501 JOptionPane.ERROR_MESSAGE, 502 progressMonitor 503 ); 504 if (dialog.isCancel()) { 505 progressMonitor.cancel(); 506 return; 507 } else { 508 continue; 509 } 510 } 511 } 512 513 if (layer == null) throw new RuntimeException(); 514 layersMap.put(idx, layer); 515 } 516 progressMonitor.worked(1); 517 } 518 519 layers = new ArrayList<>(); 520 for (Entry<Integer, Layer> entry : layersMap.entrySet()) { 521 Layer layer = entry.getValue(); 522 if (layer == null) { 523 continue; 524 } 525 Element el = elems.get(entry.getKey()); 526 if (el.hasAttribute("visible")) { 527 layer.setVisible(Boolean.parseBoolean(el.getAttribute("visible"))); 528 } 529 if (el.hasAttribute("opacity")) { 530 try { 531 double opacity = Double.parseDouble(el.getAttribute("opacity")); 532 layer.setOpacity(opacity); 533 } catch (NumberFormatException ex) { 534 Main.warn(ex); 535 } 536 } 537 layer.setName(names.get(entry.getKey())); 538 layers.add(layer); 539 } 540 } 541 542 /** 543 * Show Dialog when there is an error for one layer. 544 * Ask the user whether to cancel the complete session loading or just to skip this layer. 545 * 546 * This is expected to run in a worker thread (PleaseWaitRunnable), so invokeAndWait is 547 * needed to block the current thread and wait for the result of the modal dialog from EDT. 548 */ 549 private static class CancelOrContinueDialog { 550 551 private boolean cancel; 552 553 public void show(final String title, final String message, final int icon, final ProgressMonitor progressMonitor) { 554 try { 555 SwingUtilities.invokeAndWait(new Runnable() { 556 @Override public void run() { 557 ExtendedDialog dlg = new ExtendedDialog( 558 Main.parent, 559 title, 560 new String[] {tr("Cancel"), tr("Skip layer and continue")} 561 ); 562 dlg.setButtonIcons(new String[] {"cancel", "dialogs/next"}); 563 dlg.setIcon(icon); 564 dlg.setContent(message); 565 dlg.showDialog(); 566 cancel = dlg.getValue() != 2; 567 } 568 }); 569 } catch (InvocationTargetException | InterruptedException ex) { 570 throw new RuntimeException(ex); 571 } 572 } 573 574 public boolean isCancel() { 575 return cancel; 576 } 577 } 578 579 /** 580 * Loads session from the given file. 581 * @param sessionFile session file to load 582 * @param zip {@code true} if it's a zipped session (.joz) 583 * @param progressMonitor progress monitor 584 * @throws IllegalDataException if invalid data is detected 585 * @throws IOException if any I/O error occurs 586 */ 587 public void loadSession(File sessionFile, boolean zip, ProgressMonitor progressMonitor) throws IllegalDataException, IOException { 588 try (InputStream josIS = createInputStream(sessionFile, zip)) { 589 loadSession(josIS, sessionFile.toURI(), zip, progressMonitor != null ? progressMonitor : NullProgressMonitor.INSTANCE); 590 } 591 } 592 593 private InputStream createInputStream(File sessionFile, boolean zip) throws IOException, IllegalDataException { 594 if (zip) { 595 try { 596 zipFile = new ZipFile(sessionFile, StandardCharsets.UTF_8); 597 return getZipInputStream(zipFile); 598 } catch (ZipException ze) { 599 throw new IOException(ze); 600 } 601 } else { 602 try { 603 return new FileInputStream(sessionFile); 604 } catch (FileNotFoundException ex) { 605 throw new IOException(ex); 606 } 607 } 608 } 609 610 private static InputStream getZipInputStream(ZipFile zipFile) throws ZipException, IOException, IllegalDataException { 611 ZipEntry josEntry = null; 612 Enumeration<? extends ZipEntry> entries = zipFile.entries(); 613 while (entries.hasMoreElements()) { 614 ZipEntry entry = entries.nextElement(); 615 if (Utils.hasExtension(entry.getName(), "jos")) { 616 josEntry = entry; 617 break; 618 } 619 } 620 if (josEntry == null) { 621 error(tr("expected .jos file inside .joz archive")); 622 } 623 return zipFile.getInputStream(josEntry); 624 } 625 626 private void loadSession(InputStream josIS, URI sessionFileURI, boolean zip, ProgressMonitor progressMonitor) 627 throws IOException, IllegalDataException { 628 629 this.sessionFileURI = sessionFileURI; 630 this.zip = zip; 631 632 try { 633 parseJos(Utils.parseSafeDOM(josIS), progressMonitor); 634 } catch (SAXException e) { 635 throw new IllegalDataException(e); 636 } catch (ParserConfigurationException e) { 637 throw new IOException(e); 638 } 639 } 640 641 private static Element getElementByTagName(Element root, String name) { 642 NodeList els = root.getElementsByTagName(name); 643 return els.getLength() > 0 ? (Element) els.item(0) : null; 644 } 645}