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