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