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