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