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