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