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