001    // License: GPL. For details, see LICENSE file.
002    package org.openstreetmap.josm.io;
003    
004    import static org.openstreetmap.josm.tools.I18n.tr;
005    
006    import java.io.UnsupportedEncodingException;
007    import java.net.URLEncoder;
008    import java.text.DateFormat;
009    import java.text.MessageFormat;
010    import java.text.ParseException;
011    import java.text.SimpleDateFormat;
012    import java.util.Date;
013    import java.util.HashMap;
014    import java.util.Map;
015    
016    import org.openstreetmap.josm.data.Bounds;
017    import org.openstreetmap.josm.data.coor.LatLon;
018    import org.openstreetmap.josm.tools.CheckParameterUtil;
019    
020    public class ChangesetQuery {
021    
022        /**
023         * Replies a changeset query object from the query part of a OSM API URL for querying
024         * changesets.
025         *
026         * @param query the query part
027         * @return the query object
028         * @throws ChangesetQueryUrlException thrown if query doesn't consist of valid query parameters
029         *
030         */
031        static public ChangesetQuery buildFromUrlQuery(String query) throws ChangesetQueryUrlException{
032            return new ChangesetQueryUrlParser().parse(query);
033        }
034    
035        /** the user id this query is restricted to. null, if no restriction to a user id applies */
036        private Integer uid = null;
037        /** the user name this query is restricted to. null, if no restriction to a user name applies */
038        private String userName = null;
039        /** the bounding box this query is restricted to. null, if no restriction to a bounding box applies */
040        private Bounds bounds = null;
041    
042        private Date closedAfter = null;
043        private Date createdBefore = null;
044        /** indicates whether only open changesets are queried. null, if no restrictions regarding open changesets apply */
045        private Boolean open = null;
046        /** indicates whether only closed changesets are queried. null, if no restrictions regarding open changesets apply */
047        private Boolean closed = null;
048    
049        public ChangesetQuery() {}
050    
051        /**
052         * Restricts the query to changesets owned by the user with id <code>uid</code>.
053         *
054         * @param uid the uid of the user. >0 expected.
055         * @return the query object with the applied restriction
056         * @throws IllegalArgumentException thrown if uid <= 0
057         * @see #forUser(String)
058         */
059        public ChangesetQuery forUser(int uid) throws IllegalArgumentException{
060            if (uid <= 0)
061                throw new IllegalArgumentException(MessageFormat.format("Parameter ''{0}'' > 0 expected. Got ''{1}''.", "uid", uid));
062            this.uid = uid;
063            this.userName = null;
064            return this;
065        }
066    
067        /**
068         * Restricts the query to changesets owned by the user with user name <code>username</code>.
069         *
070         * Caveat: for historical reasons the username might not be unique! It is recommended to use
071         * {@link #forUser(int)} to restrict the query to a specific user.
072         *
073         * @param username the username. Must not be null.
074         * @return the query object with the applied restriction
075         * @throws IllegalArgumentException thrown if username is null.
076         * @see #forUser(int)
077         */
078        public ChangesetQuery forUser(String username) {
079            CheckParameterUtil.ensureParameterNotNull(username, "username");
080            this.userName = username;
081            this.uid = null;
082            return this;
083        }
084    
085        /**
086         * Replies true if this query is restricted to user whom we only know the user name
087         * for.
088         *
089         * @return true if this query is restricted to user whom we only know the user name
090         * 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 thrown 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) throws IllegalArgumentException{
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 thrown if min is null
148         * @throws IllegalArgumentException thrown 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 thrown if bbox is null.
163         */
164        public ChangesetQuery inBbox(Bounds bbox) throws IllegalArgumentException {
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 thrown if d is null
177         */
178        public ChangesetQuery closedAfter(Date d) throws IllegalArgumentException{
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 thrown if closedAfter is null
193         * @throws IllegalArgumentException thrown if createdBefore is null
194         */
195        public ChangesetQuery closedAfterAndCreatedBefore(Date closedAfter, Date createdBefore ) throws IllegalArgumentException{
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         * Replies the query string to be used in a query URL for the OSM API.
229         *
230         * @return the query string
231         */
232        public String getQueryString() {
233            StringBuffer sb = new StringBuffer();
234            if (uid != null) {
235                sb.append("user").append("=").append(uid);
236            } else if (userName != null) {
237                try {
238                    sb.append("display_name").append("=").append(URLEncoder.encode(userName, "UTF-8"));
239                } catch (UnsupportedEncodingException e) {
240                    e.printStackTrace();
241                }
242            }
243            if (bounds != null) {
244                if (sb.length() > 0) {
245                    sb.append("&");
246                }
247                sb.append("bbox=").append(bounds.encodeAsString(","));
248            }
249            if (closedAfter != null && createdBefore != null) {
250                if (sb.length() > 0) {
251                    sb.append("&");
252                }
253                SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssz");
254                sb.append("time").append("=").append(df.format(closedAfter));
255                sb.append(",").append(df.format(createdBefore));
256            } else if (closedAfter != null) {
257                if (sb.length() > 0) {
258                    sb.append("&");
259                }
260                SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssz");
261                sb.append("time").append("=").append(df.format(closedAfter));
262            }
263    
264            if (open != null) {
265                if (sb.length() > 0) {
266                    sb.append("&");
267                }
268                sb.append("open=").append(Boolean.toString(open));
269            } else if (closed != null) {
270                if (sb.length() > 0) {
271                    sb.append("&");
272                }
273                sb.append("closed=").append(Boolean.toString(closed));
274            }
275            return sb.toString();
276        }
277    
278        @Override
279        public String toString() {
280            return getQueryString();
281        }
282    
283        public static class ChangesetQueryUrlException extends Exception {
284    
285            public ChangesetQueryUrlException() {
286                super();
287            }
288    
289            public ChangesetQueryUrlException(String arg0, Throwable arg1) {
290                super(arg0, arg1);
291            }
292    
293            public ChangesetQueryUrlException(String arg0) {
294                super(arg0);
295            }
296    
297            public ChangesetQueryUrlException(Throwable arg0) {
298                super(arg0);
299            }
300        }
301    
302        public static class ChangesetQueryUrlParser {
303            protected int parseUid(String value) throws ChangesetQueryUrlException {
304                if (value == null || value.trim().equals(""))
305                    throw new ChangesetQueryUrlException(tr("Unexpected value for ''{0}'' in changeset query url, got {1}", "uid",value));
306                int id;
307                try {
308                    id = Integer.parseInt(value);
309                    if (id <= 0)
310                        throw new ChangesetQueryUrlException(tr("Unexpected value for ''{0}'' in changeset query url, got {1}", "uid",value));
311                } catch(NumberFormatException e) {
312                    throw new ChangesetQueryUrlException(tr("Unexpected value for ''{0}'' in changeset query url, got {1}", "uid",value));
313                }
314                return id;
315            }
316    
317            protected boolean parseOpen(String value) throws ChangesetQueryUrlException {
318                if (value == null || value.trim().equals(""))
319                    throw new ChangesetQueryUrlException(tr("Unexpected value for ''{0}'' in changeset query url, got {1}", "open",value));
320                if (value.equals("true"))
321                    return true;
322                else if (value.equals("false"))
323                    return false;
324                else
325                    throw new ChangesetQueryUrlException(tr("Unexpected value for ''{0}'' in changeset query url, got {1}", "open",value));
326            }
327    
328            protected boolean parseBoolean(String value, String parameter) throws ChangesetQueryUrlException {
329                if (value == null || value.trim().equals(""))
330                    throw new ChangesetQueryUrlException(tr("Unexpected value for ''{0}'' in changeset query url, got {1}", parameter,value));
331                if (value.equals("true"))
332                    return true;
333                else if (value.equals("false"))
334                    return false;
335                else
336                    throw new ChangesetQueryUrlException(tr("Unexpected value for ''{0}'' in changeset query url, got {1}", parameter,value));
337            }
338    
339            protected Date parseDate(String value, String parameter) throws ChangesetQueryUrlException {
340                if (value == null || value.trim().equals(""))
341                    throw new ChangesetQueryUrlException(tr("Unexpected value for ''{0}'' in changeset query url, got {1}", parameter,value));
342                if (value.endsWith("Z")) {
343                    // OSM API generates date strings we time zone abbreviation "Z" which Java SimpleDateFormat
344                    // doesn't understand. Convert into GMT time zone before parsing.
345                    //
346                    value = value.substring(0,value.length() - 1) + "GMT+00:00";
347                }
348                DateFormat formatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssz");
349                try {
350                    return formatter.parse(value);
351                } catch(ParseException e) {
352                    throw new ChangesetQueryUrlException(tr("Unexpected value for ''{0}'' in changeset query url, got {1}", parameter,value));
353                }
354            }
355    
356            protected Date[] parseTime(String value) throws ChangesetQueryUrlException {
357                String[] dates = value.split(",");
358                if (dates == null || dates.length == 0 || dates.length > 2)
359                    throw new ChangesetQueryUrlException(tr("Unexpected value for ''{0}'' in changeset query url, got {1}", "time", value));
360                if (dates.length == 1)
361                    return new Date[]{parseDate(dates[0], "time")};
362                else if (dates.length == 2)
363                    return new Date[]{parseDate(dates[0], "time"),parseDate(dates[1], "time")};
364                return null;
365            }
366    
367            protected ChangesetQuery crateFromMap(Map<String,String> queryParams) throws ChangesetQueryUrlException {
368                ChangesetQuery csQuery = new ChangesetQuery();
369    
370                for (String k: queryParams.keySet()) {
371                    if (k.equals("uid")) {
372                        if (queryParams.containsKey("display_name"))
373                            throw new ChangesetQueryUrlException(tr("Cannot create a changeset query including both the query parameters ''uid'' and ''display_name''"));
374                        csQuery.forUser(parseUid(queryParams.get("uid")));
375                    } else if (k.equals("display_name")) {
376                        if (queryParams.containsKey("uid"))
377                            throw new ChangesetQueryUrlException(tr("Cannot create a changeset query including both the query parameters ''uid'' and ''display_name''"));
378                        csQuery.forUser(queryParams.get("display_name"));
379                    } else if (k.equals("open")) {
380                        boolean b = parseBoolean(queryParams.get(k), "open");
381                        csQuery.beingOpen(b);
382                    } else if (k.equals("closed")) {
383                        boolean b = parseBoolean(queryParams.get(k), "closed");
384                        csQuery.beingClosed(b);
385                    } else if (k.equals("time")) {
386                        Date[] dates = parseTime(queryParams.get(k));
387                        switch(dates.length) {
388                        case 1:
389                            csQuery.closedAfter(dates[0]);
390                            break;
391                        case 2:
392                            csQuery.closedAfterAndCreatedBefore(dates[0], dates[1]);
393                            break;
394                        }
395                    } else if (k.equals("bbox")) {
396                        try {
397                            csQuery.inBbox(new Bounds(queryParams.get(k), ","));
398                        } catch(IllegalArgumentException e) {
399                            throw new ChangesetQueryUrlException(e);
400                        }
401                    } else
402                        throw new ChangesetQueryUrlException(tr("Unsupported parameter ''{0}'' in changeset query string",k ));
403                }
404                return csQuery;
405            }
406    
407            protected Map<String,String> createMapFromQueryString(String query) {
408                Map<String,String> queryParams  = new HashMap<String, String>();
409                String[] keyValuePairs = query.split("&");
410                for (String keyValuePair: keyValuePairs) {
411                    String[] kv = keyValuePair.split("=");
412                    queryParams.put(kv[0], kv[1]);
413                }
414                return queryParams;
415            }
416    
417            /**
418             * Parses the changeset query given as URL query parameters and replies a
419             * {@link ChangesetQuery}
420             *
421             * <code>query</code> is the query part of a API url for querying changesets,
422             * see <a href="http://wiki.openstreetmap.org/wiki/API_v0.6#Query:_GET_.2Fapi.2F0.6.2Fchangesets">OSM API</a>.
423             *
424             * Example for an query string:<br>
425             * <pre>
426             *    uid=1234&open=true
427             * </pre>
428             *
429             * @param query the query string. If null, an empty query (identical to a query for all changesets) is
430             * assumed
431             * @return the changeset query
432             * @throws ChangesetQueryUrlException if the query string doesn't represent a legal query for changesets
433             */
434            public ChangesetQuery parse(String query) throws  ChangesetQueryUrlException{
435                if (query == null)
436                    return new ChangesetQuery();
437                query = query.trim();
438                if (query.equals(""))
439                    return new ChangesetQuery();
440                Map<String,String> queryParams  = createMapFromQueryString(query);
441                return crateFromMap(queryParams);
442            }
443        }
444    }