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. > 0 expected. 068 * @return the query object with the applied restriction 069 * @throws IllegalArgumentException thrown if uid <= 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&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}