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