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