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