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.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.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 if (value == null || value.isEmpty()) { 407 return Collections.<Long>emptySet(); 408 } else { 409 return Stream.of(value.split(",")).map(Long::valueOf).collect(Collectors.toSet()); 410 } 411 } 412 413 protected ChangesetQuery createFromMap(Map<String, String> queryParams) throws ChangesetQueryUrlException { 414 ChangesetQuery csQuery = new ChangesetQuery(); 415 416 for (Entry<String, String> entry: queryParams.entrySet()) { 417 String k = entry.getKey(); 418 switch(k) { 419 case "uid": 420 if (queryParams.containsKey("display_name")) 421 throw new ChangesetQueryUrlException( 422 tr("Cannot create a changeset query including both the query parameters ''uid'' and ''display_name''")); 423 csQuery.forUser(parseUid(queryParams.get("uid"))); 424 break; 425 case "display_name": 426 if (queryParams.containsKey("uid")) 427 throw new ChangesetQueryUrlException( 428 tr("Cannot create a changeset query including both the query parameters ''uid'' and ''display_name''")); 429 csQuery.forUser(queryParams.get("display_name")); 430 break; 431 case "open": 432 csQuery.beingOpen(parseBoolean(entry.getValue(), "open")); 433 break; 434 case "closed": 435 csQuery.beingClosed(parseBoolean(entry.getValue(), "closed")); 436 break; 437 case "time": 438 Date[] dates = parseTime(entry.getValue()); 439 switch(dates.length) { 440 case 1: 441 csQuery.closedAfter(dates[0]); 442 break; 443 case 2: 444 csQuery.closedAfterAndCreatedBefore(dates[0], dates[1]); 445 break; 446 default: 447 Main.warn("Unable to parse time: " + entry.getValue()); 448 } 449 break; 450 case "bbox": 451 try { 452 csQuery.inBbox(new Bounds(entry.getValue(), ",")); 453 } catch (IllegalArgumentException e) { 454 throw new ChangesetQueryUrlException(e); 455 } 456 break; 457 case "changesets": 458 try { 459 csQuery.forChangesetIds(parseLongs(entry.getValue())); 460 } catch (NumberFormatException e) { 461 throw new ChangesetQueryUrlException(e); 462 } 463 break; 464 default: 465 throw new ChangesetQueryUrlException( 466 tr("Unsupported parameter ''{0}'' in changeset query string", k)); 467 } 468 } 469 return csQuery; 470 } 471 472 protected Map<String, String> createMapFromQueryString(String query) { 473 Map<String, String> queryParams = new HashMap<>(); 474 String[] keyValuePairs = query.split("&"); 475 for (String keyValuePair: keyValuePairs) { 476 String[] kv = keyValuePair.split("="); 477 queryParams.put(kv[0], kv.length > 1 ? kv[1] : ""); 478 } 479 return queryParams; 480 } 481 482 /** 483 * Parses the changeset query given as URL query parameters and replies a {@link ChangesetQuery}. 484 * 485 * <code>query</code> is the query part of a API url for querying changesets, 486 * see <a href="http://wiki.openstreetmap.org/wiki/API_v0.6#Query:_GET_.2Fapi.2F0.6.2Fchangesets">OSM API</a>. 487 * 488 * Example for an query string:<br> 489 * <pre> 490 * uid=1234&open=true 491 * </pre> 492 * 493 * @param query the query string. If null, an empty query (identical to a query for all changesets) is assumed 494 * @return the changeset query 495 * @throws ChangesetQueryUrlException if the query string doesn't represent a legal query for changesets 496 */ 497 public ChangesetQuery parse(String query) throws ChangesetQueryUrlException { 498 if (query == null) 499 return new ChangesetQuery(); 500 String apiQuery = query.trim(); 501 if (apiQuery.isEmpty()) 502 return new ChangesetQuery(); 503 return createFromMap(createMapFromQueryString(apiQuery)); 504 } 505 } 506}