001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.io;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.text.MessageFormat;
007import java.time.Instant;
008import java.util.ArrayList;
009import java.util.Collection;
010import java.util.Collections;
011import java.util.HashMap;
012import java.util.Map;
013import java.util.Map.Entry;
014import java.util.stream.Collectors;
015import java.util.stream.Stream;
016
017import org.openstreetmap.josm.data.Bounds;
018import org.openstreetmap.josm.data.UserIdentityManager;
019import org.openstreetmap.josm.data.coor.LatLon;
020import org.openstreetmap.josm.tools.CheckParameterUtil;
021import org.openstreetmap.josm.tools.Logging;
022import org.openstreetmap.josm.tools.UncheckedParseException;
023import org.openstreetmap.josm.tools.Utils;
024import org.openstreetmap.josm.tools.date.DateUtils;
025
026/**
027 * Data class to collect restrictions (parameters) for downloading changesets from the
028 * OSM API.
029 * <p>
030 * @see <a href="https://wiki.openstreetmap.org/wiki/API_v0.6#Query:_GET_.2Fapi.2F0.6.2Fchangesets">OSM API 0.6 call "/changesets?"</a>
031 */
032public class ChangesetQuery {
033
034    /**
035     * Maximum number of changesets returned by the OSM API call "/changesets?"
036     */
037    public static final int MAX_CHANGESETS_NUMBER = 100;
038
039    /** the user id this query is restricted to. null, if no restriction to a user id applies */
040    private Integer uid;
041    /** the user name this query is restricted to. null, if no restriction to a user name applies */
042    private String userName;
043    /** the bounding box this query is restricted to. null, if no restriction to a bounding box applies */
044    private Bounds bounds;
045    /** the date after which changesets have been closed this query is restricted to. null, if no restriction to closure date applies */
046    private Instant closedAfter;
047    /** the date before which changesets have been created this query is restricted to. null, if no restriction to creation date applies */
048    private Instant createdBefore;
049    /** indicates whether only open changesets are queried. null, if no restrictions regarding open changesets apply */
050    private Boolean open;
051    /** indicates whether only closed changesets are queried. null, if no restrictions regarding closed changesets apply */
052    private Boolean closed;
053    /** a collection of changeset ids to query for */
054    private Collection<Long> changesetIds;
055
056    /**
057     * Replies a changeset query object from the query part of a OSM API URL for querying changesets.
058     *
059     * @param query the query part
060     * @return the query object
061     * @throws ChangesetQueryUrlException if query doesn't consist of valid query parameters
062     */
063    public static ChangesetQuery buildFromUrlQuery(String query) throws ChangesetQueryUrlException {
064        return new ChangesetQueryUrlParser().parse(query);
065    }
066
067    /**
068     * Replies a changeset query object restricted to the current user, if known.
069     * @return a changeset query object restricted to the current user, if known
070     * @throws IllegalStateException if current user is anonymous
071     * @since 12495
072     */
073    public static ChangesetQuery forCurrentUser() {
074        UserIdentityManager im = UserIdentityManager.getInstance();
075        if (im.isAnonymous()) {
076            throw new IllegalStateException("anonymous user");
077        }
078        ChangesetQuery query = new ChangesetQuery();
079        if (im.isFullyIdentified()) {
080            return query.forUser(im.getUserId());
081        } else {
082            return query.forUser(im.getUserName());
083        }
084    }
085
086    /**
087     * Restricts the query to changesets owned by the user with id <code>uid</code>.
088     *
089     * @param uid the uid of the user. &gt; 0 expected.
090     * @return the query object with the applied restriction
091     * @throws IllegalArgumentException if uid &lt;= 0
092     * @see #forUser(String)
093     */
094    public ChangesetQuery forUser(int uid) {
095        if (uid <= 0)
096            throw new IllegalArgumentException(MessageFormat.format("Parameter ''{0}'' > 0 expected. Got ''{1}''.", "uid", uid));
097        this.uid = uid;
098        this.userName = null;
099        return this;
100    }
101
102    /**
103     * Restricts the query to changesets owned by the user with user name <code>username</code>.
104     *
105     * Caveat: for historical reasons the username might not be unique! It is recommended to use
106     * {@link #forUser(int)} to restrict the query to a specific user.
107     *
108     * @param userName the username. Must not be null.
109     * @return the query object with the applied restriction
110     * @throws IllegalArgumentException if username is null.
111     * @see #forUser(int)
112     */
113    public ChangesetQuery forUser(String userName) {
114        CheckParameterUtil.ensureParameterNotNull(userName, "userName");
115        this.userName = userName;
116        this.uid = null;
117        return this;
118    }
119
120    /**
121     * Replies true if this query is restricted to user whom we only know the user name for.
122     *
123     * @return true if this query is restricted to user whom we only know the user name for
124     */
125    public boolean isRestrictedToPartiallyIdentifiedUser() {
126        return userName != null;
127    }
128
129    /**
130     * Replies true/false if this query is restricted to changesets which are or aren't open.
131     *
132     * @return whether changesets should or should not be open, or {@code null} if there is no restriction
133     * @since 14039
134     */
135    public Boolean getRestrictionToOpen() {
136        return open;
137    }
138
139    /**
140     * Replies true/false if this query is restricted to changesets which are or aren't closed.
141     *
142     * @return whether changesets should or should not be closed, or {@code null} if there is no restriction
143     * @since 14039
144     */
145    public Boolean getRestrictionToClosed() {
146        return closed;
147    }
148
149    /**
150     * Replies the date after which changesets have been closed this query is restricted to.
151     *
152     * @return the date after which changesets have been closed this query is restricted to.
153     *         {@code null}, if no restriction to closure date applies
154     * @since 14039
155     */
156    public Instant getClosedAfter() {
157        return closedAfter;
158    }
159
160    /**
161     * Replies the date before which changesets have been created this query is restricted to.
162     *
163     * @return the date before which changesets have been created this query is restricted to.
164     *         {@code null}, if no restriction to creation date applies
165     * @since 14039
166     */
167    public Instant getCreatedBefore() {
168        return createdBefore;
169    }
170
171    /**
172     * Replies the list of additional changeset ids to query.
173     * @return the list of additional changeset ids to query (never null)
174     * @since 14039
175     */
176    public final Collection<Long> getAdditionalChangesetIds() {
177        return changesetIds != null ? new ArrayList<>(changesetIds) : Collections.emptyList();
178    }
179
180    /**
181     * Replies the bounding box this query is restricted to.
182     * @return the bounding box this query is restricted to. null, if no restriction to a bounding box applies
183     * @since 14039
184     */
185    public final Bounds getBounds() {
186        return bounds;
187    }
188
189    /**
190     * Replies the user name which this query is restricted to. null, if this query isn't
191     * restricted to a user name, i.e. if {@link #isRestrictedToPartiallyIdentifiedUser()} is false.
192     *
193     * @return the user name which this query is restricted to
194     */
195    public String getUserName() {
196        return userName;
197    }
198
199    /**
200     * Replies true if this query is restricted to user whom know the user id for.
201     *
202     * @return true if this query is restricted to user whom know the user id for
203     */
204    public boolean isRestrictedToFullyIdentifiedUser() {
205        return uid > 0;
206    }
207
208    /**
209     * Replies a query which is restricted to a bounding box.
210     *
211     * @param minLon  min longitude of the bounding box. Valid longitude value expected.
212     * @param minLat  min latitude of the bounding box. Valid latitude value expected.
213     * @param maxLon  max longitude of the bounding box. Valid longitude value expected.
214     * @param maxLat  max latitude of the bounding box.  Valid latitude value expected.
215     *
216     * @return the restricted changeset query
217     * @throws IllegalArgumentException if either of the parameters isn't a valid longitude or
218     * latitude value
219     */
220    public ChangesetQuery inBbox(double minLon, double minLat, double maxLon, double maxLat) {
221        if (!LatLon.isValidLon(minLon))
222            throw new IllegalArgumentException(tr("Illegal longitude value for parameter ''{0}'', got {1}", "minLon", minLon));
223        if (!LatLon.isValidLon(maxLon))
224            throw new IllegalArgumentException(tr("Illegal longitude value for parameter ''{0}'', got {1}", "maxLon", maxLon));
225        if (!LatLon.isValidLat(minLat))
226            throw new IllegalArgumentException(tr("Illegal latitude value for parameter ''{0}'', got {1}", "minLat", minLat));
227        if (!LatLon.isValidLat(maxLat))
228            throw new IllegalArgumentException(tr("Illegal longitude value for parameter ''{0}'', got {1}", "maxLat", maxLat));
229
230        return inBbox(new LatLon(minLon, minLat), new LatLon(maxLon, maxLat));
231    }
232
233    /**
234     * Replies a query which is restricted to a bounding box.
235     *
236     * @param min the min lat/lon coordinates of the bounding box. Must not be null.
237     * @param max the max lat/lon coordinates of the bounding box. Must not be null.
238     *
239     * @return the restricted changeset query
240     * @throws IllegalArgumentException if min is null
241     * @throws IllegalArgumentException if max is null
242     */
243    public ChangesetQuery inBbox(LatLon min, LatLon max) {
244        CheckParameterUtil.ensureParameterNotNull(min, "min");
245        CheckParameterUtil.ensureParameterNotNull(max, "max");
246        this.bounds = new Bounds(min, max);
247        return this;
248    }
249
250    /**
251     *  Replies a query which is restricted to a bounding box given by <code>bbox</code>.
252     *
253     * @param bbox the bounding box. Must not be null.
254     * @return the changeset query
255     * @throws IllegalArgumentException if bbox is null.
256     */
257    public ChangesetQuery inBbox(Bounds bbox) {
258        CheckParameterUtil.ensureParameterNotNull(bbox, "bbox");
259        this.bounds = bbox;
260        return this;
261    }
262
263    /**
264     * Restricts the result to changesets which have been closed after the date given by <code>d</code>.
265     * <code>d</code> d is a date relative to the current time zone.
266     *
267     * @param d the date . Must not be null.
268     * @return the restricted changeset query
269     * @throws IllegalArgumentException if d is null
270     */
271    public ChangesetQuery closedAfter(Instant d) {
272        CheckParameterUtil.ensureParameterNotNull(d, "d");
273        this.closedAfter = d;
274        return this;
275    }
276
277    /**
278     * Restricts the result to changesets which have been closed after <code>closedAfter</code> and which
279     * have been created before <code>createdBefore</code>. Both dates are expressed relative to the current
280     * time zone.
281     *
282     * @param closedAfter only reply changesets closed after this date. Must not be null.
283     * @param createdBefore only reply changesets created before this date. Must not be null.
284     * @return the restricted changeset query
285     * @throws IllegalArgumentException if closedAfter is null
286     * @throws IllegalArgumentException if createdBefore is null
287     */
288    public ChangesetQuery closedAfterAndCreatedBefore(Instant closedAfter, Instant createdBefore) {
289        CheckParameterUtil.ensureParameterNotNull(closedAfter, "closedAfter");
290        CheckParameterUtil.ensureParameterNotNull(createdBefore, "createdBefore");
291        this.closedAfter = closedAfter;
292        this.createdBefore = createdBefore;
293        return this;
294    }
295
296    /**
297     * Restricts the result to changesets which are or aren't open, depending on the value of
298     * <code>isOpen</code>
299     *
300     * @param isOpen whether changesets should or should not be open
301     * @return the restricted changeset query
302     */
303    public ChangesetQuery beingOpen(boolean isOpen) {
304        this.open = isOpen;
305        return this;
306    }
307
308    /**
309     * Restricts the result to changesets which are or aren't closed, depending on the value of
310     * <code>isClosed</code>
311     *
312     * @param isClosed whether changesets should or should not be open
313     * @return the restricted changeset query
314     */
315    public ChangesetQuery beingClosed(boolean isClosed) {
316        this.closed = isClosed;
317        return this;
318    }
319
320    /**
321     * Restricts the query to the given changeset ids (which are added to previously added ones).
322     *
323     * @param changesetIds the changeset ids
324     * @return the query object with the applied restriction
325     * @throws IllegalArgumentException if changesetIds is null.
326     */
327    public ChangesetQuery forChangesetIds(Collection<Long> changesetIds) {
328        CheckParameterUtil.ensureParameterNotNull(changesetIds, "changesetIds");
329        if (changesetIds.size() > MAX_CHANGESETS_NUMBER) {
330            Logging.warn("Changeset query built with more than " + MAX_CHANGESETS_NUMBER + " changeset ids (" + changesetIds.size() + ')');
331        }
332        this.changesetIds = changesetIds;
333        return this;
334    }
335
336    /**
337     * Replies the query string to be used in a query URL for the OSM API.
338     *
339     * @return the query string
340     */
341    public String getQueryString() {
342        StringBuilder sb = new StringBuilder();
343        if (uid != null) {
344            sb.append("user=").append(uid);
345        } else if (userName != null) {
346            sb.append("display_name=").append(Utils.encodeUrl(userName));
347        }
348        if (bounds != null) {
349            if (sb.length() > 0) {
350                sb.append('&');
351            }
352            sb.append("bbox=").append(bounds.encodeAsString(","));
353        }
354        if (closedAfter != null && createdBefore != null) {
355            if (sb.length() > 0) {
356                sb.append('&');
357            }
358            sb.append("time=").append(closedAfter);
359            sb.append(',').append(createdBefore);
360        } else if (closedAfter != null) {
361            if (sb.length() > 0) {
362                sb.append('&');
363            }
364            sb.append("time=").append(closedAfter);
365        }
366
367        if (open != null) {
368            if (sb.length() > 0) {
369                sb.append('&');
370            }
371            sb.append("open=").append(Boolean.toString(open));
372        } else if (closed != null) {
373            if (sb.length() > 0) {
374                sb.append('&');
375            }
376            sb.append("closed=").append(Boolean.toString(closed));
377        } else if (changesetIds != null) {
378            // since 2013-12-05, see https://github.com/openstreetmap/openstreetmap-website/commit/1d1f194d598e54a5d6fb4f38fb569d4138af0dc8
379            if (sb.length() > 0) {
380                sb.append('&');
381            }
382            sb.append("changesets=").append(changesetIds.stream().map(String::valueOf).collect(Collectors.joining(",")));
383        }
384        return sb.toString();
385    }
386
387    @Override
388    public String toString() {
389        return getQueryString();
390    }
391
392    /**
393     * Exception thrown for invalid changeset queries.
394     */
395    public static class ChangesetQueryUrlException extends Exception {
396
397        /**
398         * Constructs a new {@code ChangesetQueryUrlException} with the specified detail message.
399         *
400         * @param message the detail message. The detail message is saved for later retrieval by the {@link #getMessage()} method.
401         */
402        public ChangesetQueryUrlException(String message) {
403            super(message);
404        }
405
406        /**
407         * Constructs a new {@code ChangesetQueryUrlException} with the specified cause and detail message.
408         *
409         * @param message the detail message. The detail message is saved for later retrieval by the {@link #getMessage()} method.
410         * @param  cause the cause (which is saved for later retrieval by the {@link #getCause()} method).
411         *         (A <code>null</code> value is permitted, and indicates that the cause is nonexistent or unknown.)
412         */
413        public ChangesetQueryUrlException(String message, Throwable cause) {
414            super(message, cause);
415        }
416
417        /**
418         * Constructs a new {@code ChangesetQueryUrlException} with the specified cause and a detail message of
419         * <code>(cause==null ? null : cause.toString())</code> (which typically contains the class and detail message of <code>cause</code>).
420         *
421         * @param  cause the cause (which is saved for later retrieval by the {@link #getCause()} method).
422         *         (A <code>null</code> value is permitted, and indicates that the cause is nonexistent or unknown.)
423         */
424        public ChangesetQueryUrlException(Throwable cause) {
425            super(cause);
426        }
427    }
428
429    /**
430     * Changeset query URL parser.
431     */
432    public static class ChangesetQueryUrlParser {
433        protected int parseUid(String value) throws ChangesetQueryUrlException {
434            if (Utils.isBlank(value))
435                throw new ChangesetQueryUrlException(
436                        tr("Unexpected value for ''{0}'' in changeset query url, got {1}", "uid", value));
437            int id;
438            try {
439                id = Integer.parseInt(value);
440                if (id <= 0)
441                    throw new ChangesetQueryUrlException(
442                            tr("Unexpected value for ''{0}'' in changeset query url, got {1}", "uid", value));
443            } catch (NumberFormatException e) {
444                throw new ChangesetQueryUrlException(
445                        tr("Unexpected value for ''{0}'' in changeset query url, got {1}", "uid", value), e);
446            }
447            return id;
448        }
449
450        protected boolean parseBoolean(String value, String parameter) throws ChangesetQueryUrlException {
451            if (Utils.isBlank(value))
452                throw new ChangesetQueryUrlException(
453                        tr("Unexpected value for ''{0}'' in changeset query url, got {1}", parameter, value));
454            switch (value) {
455            case "true":
456                return true;
457            case "false":
458                return false;
459            default:
460                throw new ChangesetQueryUrlException(
461                        tr("Unexpected value for ''{0}'' in changeset query url, got {1}", parameter, value));
462            }
463        }
464
465        protected Instant parseDate(String value, String parameter) throws ChangesetQueryUrlException {
466            if (Utils.isBlank(value))
467                throw new ChangesetQueryUrlException(
468                        tr("Unexpected value for ''{0}'' in changeset query url, got {1}", parameter, value));
469            try {
470                return DateUtils.parseInstant(value);
471            } catch (UncheckedParseException e) {
472                throw new ChangesetQueryUrlException(
473                        tr("Unexpected value for ''{0}'' in changeset query url, got {1}", parameter, value), e);
474            }
475        }
476
477        protected Instant[] parseTime(String value) throws ChangesetQueryUrlException {
478            String[] dates = value.split(",", -1);
479            if (dates.length == 0 || dates.length > 2)
480                throw new ChangesetQueryUrlException(
481                        tr("Unexpected value for ''{0}'' in changeset query url, got {1}", "time", value));
482            if (dates.length == 1)
483                return new Instant[]{parseDate(dates[0], "time")};
484            else if (dates.length == 2)
485                return new Instant[]{parseDate(dates[0], "time"), parseDate(dates[1], "time")};
486            return new Instant[]{};
487        }
488
489        protected Collection<Long> parseLongs(String value) {
490            if (Utils.isEmpty(value)) {
491                return Collections.<Long>emptySet();
492            } else {
493                return Stream.of(value.split(",", -1)).map(Long::valueOf).collect(Collectors.toSet());
494            }
495        }
496
497        protected ChangesetQuery createFromMap(Map<String, String> queryParams) throws ChangesetQueryUrlException {
498            ChangesetQuery csQuery = new ChangesetQuery();
499
500            for (Entry<String, String> entry: queryParams.entrySet()) {
501                String k = entry.getKey();
502                switch(k) {
503                case "uid":
504                    if (queryParams.containsKey("display_name"))
505                        throw new ChangesetQueryUrlException(
506                                tr("Cannot create a changeset query including both the query parameters ''uid'' and ''display_name''"));
507                    csQuery.forUser(parseUid(queryParams.get("uid")));
508                    break;
509                case "display_name":
510                    if (queryParams.containsKey("uid"))
511                        throw new ChangesetQueryUrlException(
512                                tr("Cannot create a changeset query including both the query parameters ''uid'' and ''display_name''"));
513                    csQuery.forUser(queryParams.get("display_name"));
514                    break;
515                case "open":
516                    csQuery.beingOpen(parseBoolean(entry.getValue(), "open"));
517                    break;
518                case "closed":
519                    csQuery.beingClosed(parseBoolean(entry.getValue(), "closed"));
520                    break;
521                case "time":
522                    Instant[] dates = parseTime(entry.getValue());
523                    switch(dates.length) {
524                    case 1:
525                        csQuery.closedAfter(dates[0]);
526                        break;
527                    case 2:
528                        csQuery.closedAfterAndCreatedBefore(dates[0], dates[1]);
529                        break;
530                    default:
531                        Logging.warn("Unable to parse time: " + entry.getValue());
532                    }
533                    break;
534                case "bbox":
535                    try {
536                        csQuery.inBbox(new Bounds(entry.getValue(), ","));
537                    } catch (IllegalArgumentException e) {
538                        throw new ChangesetQueryUrlException(e);
539                    }
540                    break;
541                case "changesets":
542                    try {
543                        csQuery.forChangesetIds(parseLongs(entry.getValue()));
544                    } catch (NumberFormatException e) {
545                        throw new ChangesetQueryUrlException(e);
546                    }
547                    break;
548                default:
549                    throw new ChangesetQueryUrlException(
550                            tr("Unsupported parameter ''{0}'' in changeset query string", k));
551                }
552            }
553            return csQuery;
554        }
555
556        protected Map<String, String> createMapFromQueryString(String query) {
557            Map<String, String> queryParams = new HashMap<>();
558            String[] keyValuePairs = query.split("&", -1);
559            for (String keyValuePair: keyValuePairs) {
560                String[] kv = keyValuePair.split("=", -1);
561                queryParams.put(kv[0], kv.length > 1 ? kv[1] : "");
562            }
563            return queryParams;
564        }
565
566        /**
567         * Parses the changeset query given as URL query parameters and replies a {@link ChangesetQuery}.
568         *
569         * <code>query</code> is the query part of a API url for querying changesets,
570         * see <a href="http://wiki.openstreetmap.org/wiki/API_v0.6#Query:_GET_.2Fapi.2F0.6.2Fchangesets">OSM API</a>.
571         *
572         * Example for an query string:<br>
573         * <pre>
574         *    uid=1234&amp;open=true
575         * </pre>
576         *
577         * @param query the query string. If null, an empty query (identical to a query for all changesets) is assumed
578         * @return the changeset query
579         * @throws ChangesetQueryUrlException if the query string doesn't represent a legal query for changesets
580         */
581        public ChangesetQuery parse(String query) throws ChangesetQueryUrlException {
582            if (query == null)
583                return new ChangesetQuery();
584            String apiQuery = query.trim();
585            if (apiQuery.isEmpty())
586                return new ChangesetQuery();
587            return createFromMap(createMapFromQueryString(apiQuery));
588        }
589    }
590}