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