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