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