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}