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