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