diff --git a/.classpath b/.classpath
index 43a5c6b..6a39b0d 100644
--- a/.classpath
+++ b/.classpath
@@ -19,5 +19,6 @@
+
diff --git a/gradle.properties b/gradle.properties
index 03f8180..e7be093 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -11,4 +11,4 @@ plugin.icon = images/dialogs/mapwithai.svg
plugin.link = https://gitlab.com/gokaart/JOSM_MapWithAI
plugin.description = Allows the use of MapWithAI data in JOSM (same data as used in RapiD)
-plugin.requires = utilsplugin2
+plugin.requires = utilsplugin2;apache-http
diff --git a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/backend/BoundingBoxMapWithAIDownloader.java b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/backend/BoundingBoxMapWithAIDownloader.java
index b479d26..10e42f1 100644
--- a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/backend/BoundingBoxMapWithAIDownloader.java
+++ b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/backend/BoundingBoxMapWithAIDownloader.java
@@ -5,12 +5,19 @@ import static org.openstreetmap.josm.tools.I18n.tr;
import java.io.InputStream;
import java.net.SocketTimeoutException;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Collectors;
import org.openstreetmap.josm.data.Bounds;
+import org.openstreetmap.josm.data.DataSource;
import org.openstreetmap.josm.data.osm.DataSet;
import org.openstreetmap.josm.gui.MainApplication;
import org.openstreetmap.josm.gui.Notification;
+import org.openstreetmap.josm.gui.layer.OsmDataLayer;
import org.openstreetmap.josm.gui.progress.NullProgressMonitor;
import org.openstreetmap.josm.gui.progress.ProgressMonitor;
import org.openstreetmap.josm.gui.util.GuiHelper;
@@ -21,9 +28,11 @@ import org.openstreetmap.josm.io.OsmApiException;
import org.openstreetmap.josm.io.OsmReader;
import org.openstreetmap.josm.io.OsmTransferException;
import org.openstreetmap.josm.plugins.mapwithai.MapWithAIPlugin;
+import org.openstreetmap.josm.plugins.mapwithai.data.mapwithai.MapWithAIConflationCategory;
import org.openstreetmap.josm.plugins.mapwithai.data.mapwithai.MapWithAIInfo;
import org.openstreetmap.josm.plugins.mapwithai.data.mapwithai.MapWithAIType;
import org.openstreetmap.josm.tools.HttpClient;
+import org.openstreetmap.josm.tools.Logging;
class BoundingBoxMapWithAIDownloader extends BoundingBoxDownloader {
private final String url;
@@ -33,6 +42,7 @@ class BoundingBoxMapWithAIDownloader extends BoundingBoxDownloader {
private final Bounds downloadArea;
private final MapWithAIInfo info;
+ private DataConflationSender dcs;
private static final int DEFAULT_TIMEOUT = 50_000; // 50 seconds
@@ -56,7 +66,28 @@ class BoundingBoxMapWithAIDownloader extends BoundingBoxDownloader {
public DataSet parseOsm(ProgressMonitor progressMonitor) throws OsmTransferException {
long startTime = System.nanoTime();
try {
- return super.parseOsm(progressMonitor);
+ DataSet externalData = super.parseOsm(progressMonitor);
+ if (!this.info.isConflated()
+ && !MapWithAIConflationCategory.conflationUrlFor(this.info.getCategory()).isEmpty()) {
+ if (externalData.getDataSourceBounds().isEmpty()) {
+ externalData.addDataSource(new DataSource(this.downloadArea, "External Data"));
+ }
+ DataSet toConflate = getConflationData(this.downloadArea);
+ dcs = new DataConflationSender(this.info.getCategory(), toConflate, externalData);
+ dcs.run();
+ try {
+ DataSet conflatedData = dcs.get(30, TimeUnit.SECONDS);
+ if (conflatedData != null) {
+ externalData = conflatedData;
+ }
+ } catch (InterruptedException e) {
+ Logging.error(e);
+ Thread.currentThread().interrupt();
+ } catch (ExecutionException | TimeoutException e) {
+ Logging.error(e);
+ }
+ }
+ return externalData;
} catch (OsmApiException e) {
if (!(e.getResponseCode() == 504 && (System.nanoTime() - lastErrorTime) < 120_000_000_000L)) {
throw e;
@@ -88,6 +119,21 @@ class BoundingBoxMapWithAIDownloader extends BoundingBoxDownloader {
return ds;
}
+ /**
+ * Get data to send to the conflation server
+ *
+ * @param bound The bounds that we are sending to the server
+ * @return The dataset to send to the server
+ */
+ private static DataSet getConflationData(Bounds bound) {
+ List layers = MainApplication
+ .getLayerManager().getLayersOfType(OsmDataLayer.class).stream().filter(l -> l.getDataSet()
+ .getDataSourceBounds().stream().anyMatch(b -> b.toBBox().bounds(bound.toBBox())))
+ .collect(Collectors.toList());
+ return layers.stream().max(Comparator.comparingInt(l -> l.getDataSet().allPrimitives().size()))
+ .map(OsmDataLayer::getDataSet).orElse(null);
+ }
+
private static void updateLastErrorTime(long time) {
lastErrorTime = time;
}
@@ -147,4 +193,12 @@ class BoundingBoxMapWithAIDownloader extends BoundingBoxDownloader {
defaultUserAgent.append(tr("/ {0} {1}", MapWithAIPlugin.NAME, MapWithAIPlugin.getVersionInfo()));
request.setHeader("User-Agent", defaultUserAgent.toString());
}
+
+ @Override
+ public void cancel() {
+ super.cancel();
+ if (dcs != null) {
+ dcs.cancel(true);
+ }
+ }
}
diff --git a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/backend/DataConflationSender.java b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/backend/DataConflationSender.java
new file mode 100644
index 0000000..58ac76d
--- /dev/null
+++ b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/backend/DataConflationSender.java
@@ -0,0 +1,143 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.mapwithai.backend;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.RunnableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpResponse;
+import org.apache.http.HttpStatus;
+import org.apache.http.client.methods.HttpUriRequest;
+import org.apache.http.client.methods.RequestBuilder;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.mime.MultipartEntityBuilder;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.openstreetmap.josm.data.osm.DataSet;
+import org.openstreetmap.josm.gui.progress.NullProgressMonitor;
+import org.openstreetmap.josm.io.IllegalDataException;
+import org.openstreetmap.josm.io.OsmReader;
+import org.openstreetmap.josm.io.OsmWriter;
+import org.openstreetmap.josm.io.OsmWriterFactory;
+import org.openstreetmap.josm.plugins.mapwithai.data.mapwithai.MapWithAICategory;
+import org.openstreetmap.josm.plugins.mapwithai.data.mapwithai.MapWithAIConflationCategory;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * Conflate data with a third party server
+ *
+ * @author Taylor Smock
+ */
+public class DataConflationSender implements RunnableFuture {
+
+ private static final int MAX_POLLS = 100;
+ private DataSet external;
+ private DataSet osm;
+ private MapWithAICategory category;
+ private DataSet conflatedData;
+ private CloseableHttpClient client;
+ private boolean done;
+ private boolean cancelled;
+
+ /**
+ * Conflate external data
+ *
+ * @param category The category to use to determine the conflation server
+ * @param openstreetmap The OSM data (may be null -- try to avoid this)
+ * @param external The data to conflate (may not be null)
+ */
+ public DataConflationSender(MapWithAICategory category, DataSet openstreetmap, DataSet external) {
+ Objects.requireNonNull(external, tr("We must have data to conflate"));
+ Objects.requireNonNull(category, tr("We must have a category for the data"));
+ this.osm = openstreetmap;
+ this.external = external;
+ this.category = category;
+ }
+
+ @Override
+ public void run() {
+ String url = MapWithAIConflationCategory.conflationUrlFor(category);
+ this.client = HttpClients.createDefault();
+ try (CloseableHttpClient client = this.client) {
+ StringWriter output = new StringWriter();
+ OsmWriter writer = OsmWriterFactory.createOsmWriter(new PrintWriter(output), true, "0.6");
+ MultipartEntityBuilder multipartEntityBuilder = MultipartEntityBuilder.create();
+ if (osm != null) {
+ writer.write(osm);
+ multipartEntityBuilder.addTextBody("openstreetmap", output.toString(), ContentType.APPLICATION_XML);
+ }
+ // We need to reset the writers to avoid writing previous streams
+ output = new StringWriter();
+ writer = OsmWriterFactory.createOsmWriter(new PrintWriter(output), true, "0.6");
+ writer.write(external);
+ multipartEntityBuilder.addTextBody("external", output.toString(), ContentType.APPLICATION_XML);
+ HttpEntity postData = multipartEntityBuilder.build();
+ HttpUriRequest request = RequestBuilder.post(url).setEntity(postData).build();
+
+ HttpResponse response = client.execute(request);
+ if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
+ conflatedData = OsmReader.parseDataSet(response.getEntity().getContent(), NullProgressMonitor.INSTANCE,
+ OsmReader.Options.SAVE_ORIGINAL_ID);
+ } else {
+ conflatedData = null;
+ }
+ this.done = true;
+ } catch (IOException | UnsupportedOperationException | IllegalDataException e) {
+ Logging.error(e);
+ }
+ }
+
+ @Override
+ public boolean cancel(boolean mayInterruptIfRunning) {
+ try {
+ client.close();
+ } catch (IOException e) {
+ Logging.error(e);
+ return false;
+ }
+ this.done = true;
+ this.cancelled = true;
+ return true;
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return this.cancelled;
+ }
+
+ @Override
+ public boolean isDone() {
+ return this.done;
+ }
+
+ @Override
+ public DataSet get() throws InterruptedException, ExecutionException {
+ while (!isDone()) {
+ Thread.sleep(100);
+ }
+ return this.conflatedData;
+ }
+
+ @Override
+ public DataSet get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException {
+ long realtime = unit.toMillis(timeout);
+ long waitTime = realtime > MAX_POLLS ? realtime / MAX_POLLS : 1;
+ long timeWaited = 0;
+ while (!isDone()) {
+ Thread.sleep(waitTime);
+ timeWaited += waitTime;
+ }
+ if (!isDone() && timeWaited > realtime) {
+ throw new TimeoutException();
+ }
+ return this.conflatedData;
+ }
+}
diff --git a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/data/mapwithai/MapWithAIConflationCategory.java b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/data/mapwithai/MapWithAIConflationCategory.java
new file mode 100644
index 0000000..c898d61
--- /dev/null
+++ b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/data/mapwithai/MapWithAIConflationCategory.java
@@ -0,0 +1,51 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.mapwithai.data.mapwithai;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+
+import org.openstreetmap.josm.plugins.mapwithai.io.mapwithai.ConflationSourceReader;
+import org.openstreetmap.josm.tools.Logging;
+
+public class MapWithAIConflationCategory {
+ private static final Map> CONFLATION_URLS = new EnumMap<>(MapWithAICategory.class);
+ private static final String EMPTY_URL = "";
+ protected static final String DEFAULT_CONFLATION_JSON = "https://gokaart.gitlab.io/JOSM_MapWithAI/json/conflation_servers.json";
+ static {
+ initialize();
+ }
+
+ static void initialize() {
+ try (ConflationSourceReader reader = new ConflationSourceReader(DEFAULT_CONFLATION_JSON)) {
+ CONFLATION_URLS.putAll(reader.parse());
+ } catch (IOException e) {
+ Logging.error(e);
+ }
+ }
+
+ /**
+ * Get a conflation URL for a specific category
+ *
+ * @param category The category for conflation
+ * @return The URL to use for conflation
+ */
+ public static String conflationUrlFor(MapWithAICategory category) {
+ return CONFLATION_URLS.getOrDefault(category, Collections.emptyList()).stream().findFirst().orElse(EMPTY_URL);
+ }
+
+ /**
+ * Add a conflation URL for a specific category
+ *
+ * @param category The category for conflation
+ * @param url The URL to use for conflation
+ */
+ public static void addConflationUrlFor(MapWithAICategory category, String url) {
+ Collection list = CONFLATION_URLS.computeIfAbsent(category, i -> new ArrayList<>(1));
+ list.add(url);
+ }
+}
diff --git a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/data/mapwithai/MapWithAIInfo.java b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/data/mapwithai/MapWithAIInfo.java
index 8fda7cd..4e9fae7 100644
--- a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/data/mapwithai/MapWithAIInfo.java
+++ b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/data/mapwithai/MapWithAIInfo.java
@@ -399,6 +399,15 @@ public class MapWithAIInfo extends
this.conflate = conflation;
}
+ /**
+ * Check if this source is being automatically conflated
+ *
+ * @return {@code true} if it should be returned already conflated
+ */
+ public boolean isConflated() {
+ return this.conflate;
+ }
+
/**
* Set the URL to use for conflation purposes.
*
diff --git a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/io/mapwithai/ConflationSourceReader.java b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/io/mapwithai/ConflationSourceReader.java
new file mode 100644
index 0000000..53cd09b
--- /dev/null
+++ b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/io/mapwithai/ConflationSourceReader.java
@@ -0,0 +1,110 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.mapwithai.io.mapwithai;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import javax.json.Json;
+import javax.json.JsonObject;
+import javax.json.JsonReader;
+import javax.json.JsonString;
+import javax.json.JsonStructure;
+import javax.json.JsonValue;
+
+import org.openstreetmap.josm.io.CachedFile;
+import org.openstreetmap.josm.plugins.mapwithai.data.mapwithai.MapWithAICategory;
+import org.openstreetmap.josm.tools.HttpClient;
+import org.openstreetmap.josm.tools.Pair;
+import org.openstreetmap.josm.tools.Utils;
+
+public class ConflationSourceReader implements Closeable {
+
+ private final String source;
+ private CachedFile cachedFile;
+ private boolean fastFail;
+
+ /**
+ * Constructs a {@code ConflationSourceReader} from a given filename, URL or
+ * internal resource.
+ *
+ * @param source can be:
+ *
+ * - relative or absolute file name
+ * - {@code file:///SOME/FILE} the same as above
+ * - {@code http://...} a URL. It will be cached on disk.
+ * - {@code resource://SOME/FILE} file from the classpath
+ * (usually in the current *.jar)
+ * - {@code josmdir://SOME/FILE} file inside josm user data
+ * directory (since r7058)
+ * - {@code josmplugindir://SOME/FILE} file inside josm plugin
+ * directory (since r7834)
+ *
+ */
+ public ConflationSourceReader(String source) {
+ this.source = source;
+ }
+
+ /**
+ * Parses MapWithAI entry sources
+ *
+ * @param jsonObject The json of the data sources
+ * @return The parsed entries
+ */
+ public static Map> parseJson(JsonObject jsonObject) {
+ return jsonObject.entrySet().stream().flatMap(i -> parse(i).stream())
+ .collect(Collectors.groupingBy(p -> p.a, Collectors.mapping(p -> p.b, Collectors.toList())));
+ }
+
+ /**
+ * Parses MapWithAI source.
+ *
+ * @return list of source info
+ * @throws IOException if any I/O error occurs
+ */
+ public Map> parse() throws IOException {
+ Map> entries = Collections.emptyMap();
+ cachedFile = new CachedFile(source);
+ cachedFile.setFastFail(fastFail);
+ try (JsonReader reader = Json.createReader(cachedFile.setMaxAge(CachedFile.DAYS)
+ .setCachingStrategy(CachedFile.CachingStrategy.IfModifiedSince).getContentReader())) {
+ JsonStructure struct = reader.read();
+ if (JsonValue.ValueType.OBJECT == struct.getValueType()) {
+ JsonObject jsonObject = struct.asJsonObject();
+ entries = parseJson(jsonObject);
+ }
+ return entries;
+ }
+ }
+
+ private static List> parse(Map.Entry entry) {
+ if (JsonValue.ValueType.OBJECT == entry.getValue().getValueType()) {
+ JsonObject object = entry.getValue().asJsonObject();
+ String url = object.getString("url", null);
+ List categories = object.getJsonArray("categories").getValuesAs(JsonString.class)
+ .stream().map(JsonString::toString).map(MapWithAICategory::fromString).collect(Collectors.toList());
+ return categories.stream().map(c -> new Pair<>(c, url)).collect(Collectors.toList());
+ }
+ return Collections.emptyList();
+ }
+
+ /**
+ * Sets whether opening HTTP connections should fail fast, i.e., whether a
+ * {@link HttpClient#setConnectTimeout(int) low connect timeout} should be used.
+ *
+ * @param fastFail whether opening HTTP connections should fail fast
+ * @see CachedFile#setFastFail(boolean)
+ */
+ public void setFastFail(boolean fastFail) {
+ this.fastFail = fastFail;
+ }
+
+ @Override
+ public void close() throws IOException {
+ Utils.close(cachedFile);
+ }
+
+}
diff --git a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/io/mapwithai/MapWithAISourceReader.java b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/io/mapwithai/MapWithAISourceReader.java
index 0366bc2..1dc1cf4 100644
--- a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/io/mapwithai/MapWithAISourceReader.java
+++ b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/io/mapwithai/MapWithAISourceReader.java
@@ -59,8 +59,8 @@ public class MapWithAISourceReader implements Closeable {
private static final int COORD_ARRAY_SIZE = 6;
/**
- * Constructs a {@code ImageryReader} from a given filename, URL or internal
- * resource.
+ * Constructs a {@code MapWithAISourceReader} from a given filename, URL or
+ * internal resource.
*
* @param source can be:
*