001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.io.remotecontrol.handler; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.awt.geom.Area; 007import java.awt.geom.Rectangle2D; 008import java.util.Arrays; 009import java.util.Collection; 010import java.util.Collections; 011import java.util.LinkedHashSet; 012import java.util.Map; 013import java.util.Set; 014import java.util.concurrent.ExecutionException; 015import java.util.concurrent.Future; 016import java.util.concurrent.TimeUnit; 017import java.util.concurrent.TimeoutException; 018 019import javax.swing.JOptionPane; 020 021import org.openstreetmap.josm.actions.AutoScaleAction; 022import org.openstreetmap.josm.actions.AutoScaleAction.AutoScaleMode; 023import org.openstreetmap.josm.actions.downloadtasks.DownloadOsmTask; 024import org.openstreetmap.josm.actions.downloadtasks.DownloadParams; 025import org.openstreetmap.josm.actions.downloadtasks.PostDownloadHandler; 026import org.openstreetmap.josm.data.Bounds; 027import org.openstreetmap.josm.data.coor.LatLon; 028import org.openstreetmap.josm.data.osm.BBox; 029import org.openstreetmap.josm.data.osm.DataSet; 030import org.openstreetmap.josm.data.osm.OsmPrimitive; 031import org.openstreetmap.josm.data.osm.Relation; 032import org.openstreetmap.josm.data.osm.SimplePrimitiveId; 033import org.openstreetmap.josm.data.osm.search.SearchCompiler; 034import org.openstreetmap.josm.data.osm.search.SearchParseError; 035import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor; 036import org.openstreetmap.josm.gui.ExceptionDialogUtil; 037import org.openstreetmap.josm.gui.MainApplication; 038import org.openstreetmap.josm.gui.MapFrame; 039import org.openstreetmap.josm.gui.Notification; 040import org.openstreetmap.josm.gui.util.GuiHelper; 041import org.openstreetmap.josm.io.OsmApiException; 042import org.openstreetmap.josm.io.OsmTransferException; 043import org.openstreetmap.josm.io.remotecontrol.AddTagsDialog; 044import org.openstreetmap.josm.io.remotecontrol.PermissionPrefWithDefault; 045import org.openstreetmap.josm.tools.Logging; 046import org.openstreetmap.josm.tools.SubclassFilteredCollection; 047import org.openstreetmap.josm.tools.Utils; 048 049/** 050 * Handler for {@code load_and_zoom} and {@code zoom} requests. 051 * @since 3707 052 */ 053public class LoadAndZoomHandler extends RequestHandler { 054 055 /** 056 * The remote control command name used to load data and zoom. 057 */ 058 public static final String command = "load_and_zoom"; 059 060 /** 061 * The remote control command name used to zoom. 062 */ 063 public static final String command2 = "zoom"; 064 private static final String CURRENT_SELECTION = "currentselection"; 065 066 // Mandatory arguments 067 private double minlat; 068 private double maxlat; 069 private double minlon; 070 private double maxlon; 071 072 // Optional argument 'select' 073 private final Set<SimplePrimitiveId> toSelect = new LinkedHashSet<>(); 074 075 private boolean isKeepingCurrentSelection; 076 077 @Override 078 public String getPermissionMessage() { 079 String msg = tr("Remote Control has been asked to load data from the API.") + 080 "<br>" + tr("Bounding box: ") + new BBox(minlon, minlat, maxlon, maxlat).toStringCSV(", "); 081 if (args.containsKey("select") && !toSelect.isEmpty()) { 082 msg += "<br>" + tr("Selection: {0}", toSelect.size()); 083 } 084 return msg; 085 } 086 087 @Override 088 public String[] getMandatoryParams() { 089 return new String[] {"bottom", "top", "left", "right"}; 090 } 091 092 @Override 093 public String[] getOptionalParams() { 094 return new String[] {"new_layer", "layer_name", "addtags", "select", "zoom_mode", 095 "changeset_comment", "changeset_source", "changeset_hashtags", "changeset_tags", 096 "search", "layer_locked", "download_policy", "upload_policy"}; 097 } 098 099 @Override 100 public String getUsage() { 101 return "download a bounding box from the API, zoom to the downloaded area and optionally select one or more objects"; 102 } 103 104 @Override 105 public String[] getUsageExamples() { 106 return getUsageExamples(myCommand); 107 } 108 109 @Override 110 public String[] getUsageExamples(String cmd) { 111 if (command.equals(cmd)) { 112 return new String[] { 113 "/load_and_zoom?addtags=wikipedia:de=Wei%C3%9Fe_Gasse|maxspeed=5&select=way23071688,way23076176,way23076177," + 114 "&left=13.740&right=13.741&top=51.05&bottom=51.049", 115 "/load_and_zoom?left=8.19&right=8.20&top=48.605&bottom=48.590&select=node413602999&new_layer=true"}; 116 } else { 117 return new String[] { 118 "/zoom?left=8.19&right=8.20&top=48.605&bottom=48.590&select=node413602999", 119 "/zoom?left=8.19&right=8.20&top=48.605&bottom=48.590&search=highway+OR+railway", 120 "/zoom?left=8.19&right=8.20&top=48.605&bottom=48.590&search=" + CURRENT_SELECTION + "&addtags=foo=bar", 121 }; 122 } 123 } 124 125 @Override 126 protected void handleRequest() throws RequestHandlerErrorException { 127 DownloadOsmTask osmTask = new DownloadOsmTask(); 128 try { 129 DownloadParams settings = getDownloadParams(); 130 131 if (command.equals(myCommand)) { 132 if (!PermissionPrefWithDefault.LOAD_DATA.isAllowed()) { 133 Logging.info("RemoteControl: download forbidden by preferences"); 134 } else { 135 Area toDownload = null; 136 if (!settings.isNewLayer()) { 137 // find out whether some data has already been downloaded 138 Area present = null; 139 DataSet ds = MainApplication.getLayerManager().getEditDataSet(); 140 if (ds != null) { 141 present = ds.getDataSourceArea(); 142 } 143 if (present != null && !present.isEmpty()) { 144 toDownload = new Area(new Rectangle2D.Double(minlon, minlat, maxlon-minlon, maxlat-minlat)); 145 toDownload.subtract(present); 146 if (!toDownload.isEmpty()) { 147 // the result might not be a rectangle (L shaped etc) 148 Rectangle2D downloadBounds = toDownload.getBounds2D(); 149 minlat = downloadBounds.getMinY(); 150 minlon = downloadBounds.getMinX(); 151 maxlat = downloadBounds.getMaxY(); 152 maxlon = downloadBounds.getMaxX(); 153 } 154 } 155 } 156 if (toDownload != null && toDownload.isEmpty()) { 157 Logging.info("RemoteControl: no download necessary"); 158 } else { 159 Future<?> future = MainApplication.worker.submit( 160 new PostDownloadHandler(osmTask, osmTask.download(settings, new Bounds(minlat, minlon, maxlat, maxlon), 161 null /* let the task manage the progress monitor */))); 162 GuiHelper.executeByMainWorkerInEDT(() -> { 163 try { 164 future.get(OSM_DOWNLOAD_TIMEOUT.get(), TimeUnit.SECONDS); 165 if (osmTask.isFailed()) { 166 Object error = osmTask.getErrorObjects().get(0); 167 throw error instanceof OsmApiException 168 ? (OsmApiException) error 169 : new OsmTransferException(String.join(", ", osmTask.getErrorMessages())); 170 } 171 } catch (InterruptedException | ExecutionException | TimeoutException | 172 OsmTransferException | RuntimeException ex) { // NOPMD 173 ExceptionDialogUtil.explainException(ex); 174 } 175 }); 176 } 177 } 178 } 179 } catch (RuntimeException ex) { // NOPMD 180 Logging.warn("RemoteControl: Error parsing load_and_zoom remote control request:"); 181 Logging.error(ex); 182 throw new RequestHandlerErrorException(ex); 183 } 184 185 /** 186 * deselect objects if parameter addtags given 187 */ 188 if (args.containsKey("addtags") && !isKeepingCurrentSelection) { 189 GuiHelper.executeByMainWorkerInEDT(() -> { 190 DataSet ds = MainApplication.getLayerManager().getEditDataSet(); 191 if (ds == null) // e.g. download failed 192 return; 193 ds.clearSelection(); 194 }); 195 } 196 197 final Collection<OsmPrimitive> forTagAdd = new LinkedHashSet<>(); 198 final Bounds bbox = new Bounds(minlat, minlon, maxlat, maxlon); 199 if (args.containsKey("select") && PermissionPrefWithDefault.CHANGE_SELECTION.isAllowed()) { 200 // select objects after downloading, zoom to selection. 201 GuiHelper.executeByMainWorkerInEDT(() -> { 202 Set<OsmPrimitive> newSel = new LinkedHashSet<>(); 203 DataSet ds = MainApplication.getLayerManager().getEditDataSet(); 204 if (ds == null) // e.g. download failed 205 return; 206 for (SimplePrimitiveId id : toSelect) { 207 final OsmPrimitive p = ds.getPrimitiveById(id); 208 if (p != null) { 209 newSel.add(p); 210 forTagAdd.add(p); 211 } 212 } 213 if (isKeepingCurrentSelection) { 214 Collection<OsmPrimitive> sel = ds.getSelected(); 215 newSel.addAll(sel); 216 forTagAdd.addAll(sel); 217 } 218 toSelect.clear(); 219 ds.setSelected(newSel); 220 zoom(newSel, bbox); 221 MapFrame map = MainApplication.getMap(); 222 if (MainApplication.isDisplayingMapView() && map.relationListDialog != null) { 223 map.relationListDialog.selectRelations(null); // unselect all relations to fix #7342 224 map.relationListDialog.dataChanged(null); 225 map.relationListDialog.selectRelations(Utils.filteredCollection(newSel, Relation.class)); 226 } 227 }); 228 } else if (args.containsKey("search") && PermissionPrefWithDefault.CHANGE_SELECTION.isAllowed()) { 229 try { 230 final SearchCompiler.Match search = SearchCompiler.compile(args.get("search")); 231 MainApplication.worker.submit(() -> { 232 final DataSet ds = MainApplication.getLayerManager().getEditDataSet(); 233 final Collection<OsmPrimitive> filteredPrimitives = SubclassFilteredCollection.filter(ds.allPrimitives(), search); 234 ds.setSelected(filteredPrimitives); 235 forTagAdd.addAll(filteredPrimitives); 236 zoom(filteredPrimitives, bbox); 237 }); 238 } catch (SearchParseError ex) { 239 Logging.error(ex); 240 throw new RequestHandlerErrorException(ex); 241 } 242 } else { 243 // after downloading, zoom to downloaded area. 244 zoom(Collections.<OsmPrimitive>emptySet(), bbox); 245 } 246 247 // This comes before the other changeset tags, so that they can be overridden 248 parseChangesetTags(args); 249 250 // add changeset tags after download if necessary 251 if (args.containsKey("changeset_comment") || args.containsKey("changeset_source") || args.containsKey("changeset_hashtags")) { 252 MainApplication.worker.submit(() -> { 253 DataSet ds = MainApplication.getLayerManager().getEditDataSet(); 254 if (ds != null) { 255 for (String tag : Arrays.asList("changeset_comment", "changeset_source", "changeset_hashtags")) { 256 if (args.containsKey(tag)) { 257 final String tagKey = tag.substring("changeset_".length()); 258 final String value = args.get(tag); 259 if (!Utils.isStripEmpty(value)) { 260 ds.addChangeSetTag(tagKey, value); 261 } else { 262 ds.addChangeSetTag(tagKey, null); 263 } 264 } 265 } 266 } 267 }); 268 } 269 270 // add tags to objects 271 if (args.containsKey("addtags")) { 272 // needs to run in EDT since forTagAdd is updated in EDT as well 273 GuiHelper.executeByMainWorkerInEDT(() -> { 274 if (!forTagAdd.isEmpty()) { 275 AddTagsDialog.addTags(args, sender, forTagAdd); 276 } else { 277 new Notification(isKeepingCurrentSelection 278 ? tr("You clicked on a JOSM remotecontrol link that would apply tags onto selected objects.\n" 279 + "Since no objects have been selected before this click, no tags were added.\n" 280 + "Select one or more objects and click the link again.") 281 : tr("You clicked on a JOSM remotecontrol link that would apply tags onto objects.\n" 282 + "Unfortunately that link seems to be broken.\n" 283 + "Technical explanation: the URL query parameter ''select='' or ''search='' has an invalid value.\n" 284 + "Ask someone at the origin of the clicked link to fix this.") 285 ).setIcon(JOptionPane.WARNING_MESSAGE).setDuration(Notification.TIME_LONG).show(); 286 } 287 }); 288 } 289 } 290 291 static void parseChangesetTags(Map<String, String> args) { 292 if (args.containsKey("changeset_tags")) { 293 MainApplication.worker.submit(() -> { 294 DataSet ds = MainApplication.getLayerManager().getEditDataSet(); 295 if (ds != null) { 296 AddTagsDialog.parseUrlTagsToKeyValues(args.get("changeset_tags")).forEach(ds::addChangeSetTag); 297 } 298 }); 299 } 300 } 301 302 protected void zoom(Collection<OsmPrimitive> primitives, final Bounds bbox) { 303 if (!PermissionPrefWithDefault.CHANGE_VIEWPORT.isAllowed()) { 304 return; 305 } 306 // zoom_mode=(download|selection), defaults to selection 307 if (!"download".equals(args.get("zoom_mode")) && !primitives.isEmpty()) { 308 AutoScaleAction.autoScale(AutoScaleMode.SELECTION); 309 } else if (MainApplication.isDisplayingMapView()) { 310 // make sure this isn't called unless there *is* a MapView 311 GuiHelper.executeByMainWorkerInEDT(() -> { 312 BoundingXYVisitor bbox1 = new BoundingXYVisitor(); 313 bbox1.visit(bbox); 314 MainApplication.getMap().mapView.zoomTo(bbox1); 315 }); 316 } 317 } 318 319 @Override 320 public PermissionPrefWithDefault getPermissionPref() { 321 return null; 322 } 323 324 @Override 325 protected void validateRequest() throws RequestHandlerBadRequestException { 326 validateDownloadParams(); 327 // Process mandatory arguments 328 minlat = 0; 329 maxlat = 0; 330 minlon = 0; 331 maxlon = 0; 332 try { 333 minlat = LatLon.roundToOsmPrecision(Double.parseDouble(args != null ? args.get("bottom") : "")); 334 maxlat = LatLon.roundToOsmPrecision(Double.parseDouble(args != null ? args.get("top") : "")); 335 minlon = LatLon.roundToOsmPrecision(Double.parseDouble(args != null ? args.get("left") : "")); 336 maxlon = LatLon.roundToOsmPrecision(Double.parseDouble(args != null ? args.get("right") : "")); 337 } catch (NumberFormatException e) { 338 throw new RequestHandlerBadRequestException("NumberFormatException ("+e.getMessage()+')', e); 339 } 340 341 // Current API 0.6 check: "The latitudes must be between -90 and 90" 342 if (!LatLon.isValidLat(minlat) || !LatLon.isValidLat(maxlat)) { 343 throw new RequestHandlerBadRequestException(tr("The latitudes must be between {0} and {1}", -90d, 90d)); 344 } 345 // Current API 0.6 check: "longitudes between -180 and 180" 346 if (!LatLon.isValidLon(minlon) || !LatLon.isValidLon(maxlon)) { 347 throw new RequestHandlerBadRequestException(tr("The longitudes must be between {0} and {1}", -180d, 180d)); 348 } 349 // Current API 0.6 check: "the minima must be less than the maxima" 350 if (minlat > maxlat || minlon > maxlon) { 351 throw new RequestHandlerBadRequestException(tr("The minima must be less than the maxima")); 352 } 353 354 // Process optional argument 'select' 355 if (args != null && args.containsKey("select")) { 356 toSelect.clear(); 357 for (String item : args.get("select").split(",", -1)) { 358 if (!item.isEmpty()) { 359 if (CURRENT_SELECTION.equalsIgnoreCase(item)) { 360 isKeepingCurrentSelection = true; 361 continue; 362 } 363 try { 364 toSelect.add(SimplePrimitiveId.fromString(item)); 365 } catch (IllegalArgumentException ex) { 366 Logging.log(Logging.LEVEL_WARN, "RemoteControl: invalid selection '" + item + "' ignored", ex); 367 } 368 } 369 } 370 } 371 } 372}