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