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