// License: GPL. For details, see LICENSE file.
package org.openstreetmap.josm.io.session;
import static org.openstreetmap.josm.tools.I18n.tr;
import java.awt.GraphicsEnvironment;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.InvocationTargetException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.TreeMap;
import java.util.zip.ZipEntry;
import java.util.zip.ZipException;
import java.util.zip.ZipFile;
import javax.swing.JOptionPane;
import javax.swing.SwingUtilities;
import javax.xml.parsers.ParserConfigurationException;
import org.openstreetmap.josm.Main;
import org.openstreetmap.josm.data.ViewportData;
import org.openstreetmap.josm.data.coor.EastNorth;
import org.openstreetmap.josm.data.coor.LatLon;
import org.openstreetmap.josm.data.projection.Projection;
import org.openstreetmap.josm.data.projection.Projections;
import org.openstreetmap.josm.gui.ExtendedDialog;
import org.openstreetmap.josm.gui.layer.Layer;
import org.openstreetmap.josm.gui.progress.NullProgressMonitor;
import org.openstreetmap.josm.gui.progress.ProgressMonitor;
import org.openstreetmap.josm.io.Compression;
import org.openstreetmap.josm.io.IllegalDataException;
import org.openstreetmap.josm.tools.JosmRuntimeException;
import org.openstreetmap.josm.tools.MultiMap;
import org.openstreetmap.josm.tools.Utils;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
/**
* Reads a .jos session file and loads the layers in the process.
* @since 4668
*/
public class SessionReader {
private static final Map<String, Class<? extends SessionLayerImporter>> sessionLayerImporters = new HashMap<>();
private URI sessionFileURI;
private boolean zip; // true, if session file is a .joz file; false if it is a .jos file
private ZipFile zipFile;
private List<Layer> layers = new ArrayList<>();
private int active = -1;
private final List<Runnable> postLoadTasks = new ArrayList<>();
private ViewportData viewport;
static {
registerSessionLayerImporter("osm-data", OsmDataSessionImporter.class);
registerSessionLayerImporter("imagery", ImagerySessionImporter.class);
registerSessionLayerImporter("tracks", GpxTracksSessionImporter.class);
registerSessionLayerImporter("geoimage", GeoImageSessionImporter.class);
registerSessionLayerImporter("markers", MarkerSessionImporter.class);
registerSessionLayerImporter("osm-notes", NoteSessionImporter.class);
}
/**
* Register a session layer importer.
*
* @param layerType layer type
* @param importer importer for this layer class
*/
public static void registerSessionLayerImporter(String layerType, Class<? extends SessionLayerImporter> importer) {
sessionLayerImporters.put(layerType, importer);
}
/**
* Returns the session layer importer for the given layer type.
* @param layerType layer type to import
* @return session layer importer for the given layer
*/
public static SessionLayerImporter getSessionLayerImporter(String layerType) {
Class<? extends SessionLayerImporter> importerClass = sessionLayerImporters.get(layerType);
if (importerClass == null)
return null;
SessionLayerImporter importer = null;
try {
importer = importerClass.getConstructor().newInstance();
} catch (ReflectiveOperationException e) {
throw new JosmRuntimeException(e);
}
return importer;
}
/**
* @return list of layers that are later added to the mapview
*/
public List<Layer> getLayers() {
return layers;
}
/**
* @return active layer, or {@code null} if not set
* @since 6271
*/
public Layer getActive() {
// layers is in reverse order because of the way TreeMap is built
return (active >= 0 && active < layers.size()) ? layers.get(layers.size()-1-active) : null;
}
/**
* @return actions executed in EDT after layers have been added (message dialog, etc.)
*/
public List<Runnable> getPostLoadTasks() {
return postLoadTasks;
}
/**
* Return the viewport (map position and scale).
* @return The viewport. Can be null when no viewport info is found in the file.
*/
public ViewportData getViewport() {
return viewport;
}
/**
* A class that provides some context for the individual {@link SessionLayerImporter}
* when doing the import.
*/
public class ImportSupport {
private final String layerName;
private final int layerIndex;
private final List<LayerDependency> layerDependencies;
/**
* Path of the file inside the zip archive.
* Used as alternative return value for getFile method.
*/
private String inZipPath;
/**
* Constructs a new {@code ImportSupport}.
* @param layerName layer name
* @param layerIndex layer index
* @param layerDependencies layer dependencies
*/
public ImportSupport(String layerName, int layerIndex, List<LayerDependency> layerDependencies) {
this.layerName = layerName;
this.layerIndex = layerIndex;
this.layerDependencies = layerDependencies;
}
/**
* Add a task, e.g. a message dialog, that should
* be executed in EDT after all layers have been added.
* @param task task to run in EDT
*/
public void addPostLayersTask(Runnable task) {
postLoadTasks.add(task);
}
/**
* Return an InputStream for a URI from a .jos/.joz file.
*
* The following forms are supported:
*
* - absolute file (both .jos and .joz):
* "file:///home/user/data.osm"
* "file:/home/user/data.osm"
* "file:///C:/files/data.osm"
* "file:/C:/file/data.osm"
* "/home/user/data.osm"
* "C:\files\data.osm" (not a URI, but recognized by File constructor on Windows systems)
* - standalone .jos files:
* - relative uri:
* "save/data.osm"
* "../project2/data.osm"
* - for .joz files:
* - file inside zip archive:
* "layers/01/data.osm"
* - relativ to the .joz file:
* "../save/data.osm" ("../" steps out of the archive)
* @param uriStr URI as string
* @return the InputStream
*
* @throws IOException Thrown when no Stream can be opened for the given URI, e.g. when the linked file has been deleted.
*/
public InputStream getInputStream(String uriStr) throws IOException {
File file = getFile(uriStr);
if (file != null) {
try {
return new BufferedInputStream(Compression.getUncompressedFileInputStream(file));
} catch (FileNotFoundException e) {
throw new IOException(tr("File ''{0}'' does not exist.", file.getPath()), e);
}
} else if (inZipPath != null) {
ZipEntry entry = zipFile.getEntry(inZipPath);
if (entry != null) {
return zipFile.getInputStream(entry);
}
}
throw new IOException(tr("Unable to locate file ''{0}''.", uriStr));
}
/**
* Return a File for a URI from a .jos/.joz file.
*
* Returns null if the URI points to a file inside the zip archive.
* In this case, inZipPath will be set to the corresponding path.
* @param uriStr the URI as string
* @return the resulting File
* @throws IOException if any I/O error occurs
*/
public File getFile(String uriStr) throws IOException {
inZipPath = null;
try {
URI uri = new URI(uriStr);
if ("file".equals(uri.getScheme()))
// absolute path
return new File(uri);
else if (uri.getScheme() == null) {
// Check if this is an absolute path without 'file:' scheme part.
// At this point, (as an exception) platform dependent path separator will be recognized.
// (This form is discouraged, only for users that like to copy and paste a path manually.)
File file = new File(uriStr);
if (file.isAbsolute())
return file;
else {
// for relative paths, only forward slashes are permitted
if (isZip()) {
if (uri.getPath().startsWith("../")) {
// relative to session file - "../" step out of the archive
String relPath = uri.getPath().substring(3);
return new File(sessionFileURI.resolve(relPath));
} else {
// file inside zip archive
inZipPath = uriStr;
return null;
}
} else
return new File(sessionFileURI.resolve(uri));
}
} else
throw new IOException(tr("Unsupported scheme ''{0}'' in URI ''{1}''.", uri.getScheme(), uriStr));
} catch (URISyntaxException | IllegalArgumentException e) {
throw new IOException(e);
}
}
/**
* Determines if we are reading from a .joz file.
* @return {@code true} if we are reading from a .joz file, {@code false} otherwise
*/
public boolean isZip() {
return zip;
}
/**
* Name of the layer that is currently imported.
* @return layer name
*/
public String getLayerName() {
return layerName;
}
/**
* Index of the layer that is currently imported.
* @return layer index
*/
public int getLayerIndex() {
return layerIndex;
}
/**
* Dependencies - maps the layer index to the importer of the given
* layer. All the dependent importers have loaded completely at this point.
* @return layer dependencies
*/
public List<LayerDependency> getLayerDependencies() {
return layerDependencies;
}
@Override
public String toString() {
return "ImportSupport [layerName=" + layerName + ", layerIndex=" + layerIndex + ", layerDependencies="
+ layerDependencies + ", inZipPath=" + inZipPath + ']';
}
}
public static class LayerDependency {
private final Integer index;
private final Layer layer;
private final SessionLayerImporter importer;
public LayerDependency(Integer index, Layer layer, SessionLayerImporter importer) {
this.index = index;
this.layer = layer;
this.importer = importer;
}
public SessionLayerImporter getImporter() {
return importer;
}
public Integer getIndex() {
return index;
}
public Layer getLayer() {
return layer;
}
}
private static void error(String msg) throws IllegalDataException {
throw new IllegalDataException(msg);
}
private void parseJos(Document doc, ProgressMonitor progressMonitor) throws IllegalDataException {
Element root = doc.getDocumentElement();
if (!"josm-session".equals(root.getTagName())) {
error(tr("Unexpected root element ''{0}'' in session file", root.getTagName()));
}
String version = root.getAttribute("version");
if (!"0.1".equals(version)) {
error(tr("Version ''{0}'' of session file is not supported. Expected: 0.1", version));
}
Element viewportEl = getElementByTagName(root, "viewport");
if (viewportEl != null) {
EastNorth center = null;
Element centerEl = getElementByTagName(viewportEl, "center");
if (centerEl != null && centerEl.hasAttribute("lat") && centerEl.hasAttribute("lon")) {
try {
LatLon centerLL = new LatLon(Double.parseDouble(centerEl.getAttribute("lat")),
Double.parseDouble(centerEl.getAttribute("lon")));
center = Projections.project(centerLL);
} catch (NumberFormatException ex) {
Main.warn(ex);
}
}
if (center != null) {
Element scaleEl = getElementByTagName(viewportEl, "scale");
if (scaleEl != null && scaleEl.hasAttribute("meter-per-pixel")) {
try {
double meterPerPixel = Double.parseDouble(scaleEl.getAttribute("meter-per-pixel"));
Projection proj = Main.getProjection();
// Get a "typical" distance in east/north units that
// corresponds to a couple of pixels. Shouldn't be too
// large, to keep it within projection bounds and
// not too small to avoid rounding errors.
double dist = 0.01 * proj.getDefaultZoomInPPD();
LatLon ll1 = proj.eastNorth2latlon(new EastNorth(center.east() - dist, center.north()));
LatLon ll2 = proj.eastNorth2latlon(new EastNorth(center.east() + dist, center.north()));
double meterPerEasting = ll1.greatCircleDistance(ll2) / dist / 2;
double scale = meterPerPixel / meterPerEasting; // unit: easting per pixel
viewport = new ViewportData(center, scale);
} catch (NumberFormatException ex) {
Main.warn(ex);
}
}
}
}
Element layersEl = getElementByTagName(root, "layers");
if (layersEl == null) return;
String activeAtt = layersEl.getAttribute("active");
try {
active = !activeAtt.isEmpty() ? (Integer.parseInt(activeAtt)-1) : -1;
} catch (NumberFormatException e) {
Main.warn("Unsupported value for 'active' layer attribute. Ignoring it. Error was: "+e.getMessage());
active = -1;
}
MultiMap<Integer, Integer> deps = new MultiMap<>();
Map<Integer, Element> elems = new HashMap<>();
NodeList nodes = layersEl.getChildNodes();
for (int i = 0; i < nodes.getLength(); ++i) {
Node node = nodes.item(i);
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element e = (Element) node;
if ("layer".equals(e.getTagName())) {
if (!e.hasAttribute("index")) {
error(tr("missing mandatory attribute ''index'' for element ''layer''"));
}
Integer idx = null;
try {
idx = Integer.valueOf(e.getAttribute("index"));
} catch (NumberFormatException ex) {
Main.warn(ex);
}
if (idx == null) {
error(tr("unexpected format of attribute ''index'' for element ''layer''"));
} else if (elems.containsKey(idx)) {
error(tr("attribute ''index'' ({0}) for element ''layer'' must be unique", Integer.toString(idx)));
}
elems.put(idx, e);
deps.putVoid(idx);
String depStr = e.getAttribute("depends");
if (!depStr.isEmpty()) {
for (String sd : depStr.split(",")) {
Integer d = null;
try {
d = Integer.valueOf(sd);
} catch (NumberFormatException ex) {
Main.warn(ex);
}
if (d != null) {
deps.put(idx, d);
}
}
}
}
}
}
List<Integer> sorted = Utils.topologicalSort(deps);
final Map<Integer, Layer> layersMap = new TreeMap<>(Collections.reverseOrder());
final Map<Integer, SessionLayerImporter> importers = new HashMap<>();
final Map<Integer, String> names = new HashMap<>();
progressMonitor.setTicksCount(sorted.size());
LAYER: for (int idx: sorted) {
Element e = elems.get(idx);
if (e == null) {
error(tr("missing layer with index {0}", idx));
return;
} else if (!e.hasAttribute("name")) {
error(tr("missing mandatory attribute ''name'' for element ''layer''"));
return;
}
String name = e.getAttribute("name");
names.put(idx, name);
if (!e.hasAttribute("type")) {
error(tr("missing mandatory attribute ''type'' for element ''layer''"));
return;
}
String type = e.getAttribute("type");
SessionLayerImporter imp = getSessionLayerImporter(type);
if (imp == null && !GraphicsEnvironment.isHeadless()) {
CancelOrContinueDialog dialog = new CancelOrContinueDialog();
dialog.show(
tr("Unable to load layer"),
tr("Cannot load layer of type ''{0}'' because no suitable importer was found.", type),
JOptionPane.WARNING_MESSAGE,
progressMonitor
);
if (dialog.isCancel()) {
progressMonitor.cancel();
return;
} else {
continue;
}
} else if (imp != null) {
importers.put(idx, imp);
List<LayerDependency> depsImp = new ArrayList<>();
for (int d : deps.get(idx)) {
SessionLayerImporter dImp = importers.get(d);
if (dImp == null) {
CancelOrContinueDialog dialog = new CancelOrContinueDialog();
dialog.show(
tr("Unable to load layer"),
tr("Cannot load layer {0} because it depends on layer {1} which has been skipped.", idx, d),
JOptionPane.WARNING_MESSAGE,
progressMonitor
);
if (dialog.isCancel()) {
progressMonitor.cancel();
return;
} else {
continue LAYER;
}
}
depsImp.add(new LayerDependency(d, layersMap.get(d), dImp));
}
ImportSupport support = new ImportSupport(name, idx, depsImp);
Layer layer = null;
Exception exception = null;
try {
layer = imp.load(e, support, progressMonitor.createSubTaskMonitor(1, false));
if (layer == null) {
throw new IllegalStateException("Importer " + imp + " returned null for " + support);
}
} catch (IllegalDataException | IllegalStateException | IOException ex) {
exception = ex;
}
if (exception != null) {
Main.error(exception);
if (!GraphicsEnvironment.isHeadless()) {
CancelOrContinueDialog dialog = new CancelOrContinueDialog();
dialog.show(
tr("Error loading layer"),
tr("<html>Could not load layer {0} ''{1}''.<br>Error is:<br>{2}</html>", idx,
Utils.escapeReservedCharactersHTML(name),
Utils.escapeReservedCharactersHTML(exception.getMessage())),
JOptionPane.ERROR_MESSAGE,
progressMonitor
);
if (dialog.isCancel()) {
progressMonitor.cancel();
return;
} else {
continue;
}
}
}
layersMap.put(idx, layer);
}
progressMonitor.worked(1);
}
layers = new ArrayList<>();
for (Entry<Integer, Layer> entry : layersMap.entrySet()) {
Layer layer = entry.getValue();
if (layer == null) {
continue;
}
Element el = elems.get(entry.getKey());
if (el.hasAttribute("visible")) {
layer.setVisible(Boolean.parseBoolean(el.getAttribute("visible")));
}
if (el.hasAttribute("opacity")) {
try {
double opacity = Double.parseDouble(el.getAttribute("opacity"));
layer.setOpacity(opacity);
} catch (NumberFormatException ex) {
Main.warn(ex);
}
}
layer.setName(names.get(entry.getKey()));
layers.add(layer);
}
}
/**
* Show Dialog when there is an error for one layer.
* Ask the user whether to cancel the complete session loading or just to skip this layer.
*
* This is expected to run in a worker thread (PleaseWaitRunnable), so invokeAndWait is
* needed to block the current thread and wait for the result of the modal dialog from EDT.
*/
private static class CancelOrContinueDialog {
private boolean cancel;
public void show(final String title, final String message, final int icon, final ProgressMonitor progressMonitor) {
try {
SwingUtilities.invokeAndWait(() -> {
ExtendedDialog dlg = new ExtendedDialog(
Main.parent,
title,
new String[] {tr("Cancel"), tr("Skip layer and continue")}
);
dlg.setButtonIcons(new String[] {"cancel", "dialogs/next"});
dlg.setIcon(icon);
dlg.setContent(message);
dlg.showDialog();
cancel = dlg.getValue() != 2;
});
} catch (InvocationTargetException | InterruptedException ex) {
throw new JosmRuntimeException(ex);
}
}
public boolean isCancel() {
return cancel;
}
}
/**
* Loads session from the given file.
* @param sessionFile session file to load
* @param zip {@code true} if it's a zipped session (.joz)
* @param progressMonitor progress monitor
* @throws IllegalDataException if invalid data is detected
* @throws IOException if any I/O error occurs
*/
public void loadSession(File sessionFile, boolean zip, ProgressMonitor progressMonitor) throws IllegalDataException, IOException {
try (InputStream josIS = createInputStream(sessionFile, zip)) {
loadSession(josIS, sessionFile.toURI(), zip, progressMonitor != null ? progressMonitor : NullProgressMonitor.INSTANCE);
}
}
private InputStream createInputStream(File sessionFile, boolean zip) throws IOException, IllegalDataException {
if (zip) {
try {
zipFile = new ZipFile(sessionFile, StandardCharsets.UTF_8);
return getZipInputStream(zipFile);
} catch (ZipException ex) {
throw new IOException(ex);
}
} else {
try {
return new FileInputStream(sessionFile);
} catch (FileNotFoundException ex) {
throw new IOException(ex);
}
}
}
private static InputStream getZipInputStream(ZipFile zipFile) throws IOException, IllegalDataException {
ZipEntry josEntry = null;
Enumeration<? extends ZipEntry> entries = zipFile.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
if (Utils.hasExtension(entry.getName(), "jos")) {
josEntry = entry;
break;
}
}
if (josEntry == null) {
error(tr("expected .jos file inside .joz archive"));
}
return zipFile.getInputStream(josEntry);
}
private void loadSession(InputStream josIS, URI sessionFileURI, boolean zip, ProgressMonitor progressMonitor)
throws IOException, IllegalDataException {
this.sessionFileURI = sessionFileURI;
this.zip = zip;
try {
parseJos(Utils.parseSafeDOM(josIS), progressMonitor);
} catch (SAXException e) {
throw new IllegalDataException(e);
} catch (ParserConfigurationException e) {
throw new IOException(e);
}
}
private static Element getElementByTagName(Element root, String name) {
NodeList els = root.getElementsByTagName(name);
return els.getLength() > 0 ? (Element) els.item(0) : null;
}
}