001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.io.imagery; 003 004import java.io.BufferedReader; 005import java.io.Closeable; 006import java.io.IOException; 007import java.util.ArrayList; 008import java.util.Arrays; 009import java.util.HashMap; 010import java.util.List; 011import java.util.Map; 012import java.util.Objects; 013import java.util.Optional; 014import java.util.Stack; 015import java.util.concurrent.ConcurrentHashMap; 016 017import javax.xml.parsers.ParserConfigurationException; 018 019import org.openstreetmap.josm.data.imagery.DefaultLayer; 020import org.openstreetmap.josm.data.imagery.ImageryInfo; 021import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryBounds; 022import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryCategory; 023import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryType; 024import org.openstreetmap.josm.data.imagery.Shape; 025import org.openstreetmap.josm.io.CachedFile; 026import org.openstreetmap.josm.tools.HttpClient; 027import org.openstreetmap.josm.tools.JosmRuntimeException; 028import org.openstreetmap.josm.tools.LanguageInfo; 029import org.openstreetmap.josm.tools.Logging; 030import org.openstreetmap.josm.tools.MultiMap; 031import org.openstreetmap.josm.tools.StringParser; 032import org.openstreetmap.josm.tools.Utils; 033import org.openstreetmap.josm.tools.XmlUtils; 034import org.xml.sax.Attributes; 035import org.xml.sax.InputSource; 036import org.xml.sax.SAXException; 037import org.xml.sax.helpers.DefaultHandler; 038 039/** 040 * Reader to parse the list of available imagery servers from an XML definition file. 041 * <p> 042 * The format is specified in the <a href="https://josm.openstreetmap.de/wiki/Maps">JOSM wiki</a>. 043 */ 044public class ImageryReader implements Closeable { 045 046 private final String source; 047 private CachedFile cachedFile; 048 private boolean fastFail; 049 050 private enum State { 051 INIT, // initial state, should always be at the bottom of the stack 052 IMAGERY, // inside the imagery element 053 ENTRY, // inside an entry 054 ENTRY_ATTRIBUTE, // note we are inside an entry attribute to collect the character data 055 PROJECTIONS, // inside projections block of an entry 056 MIRROR, // inside an mirror entry 057 MIRROR_ATTRIBUTE, // note we are inside an mirror attribute to collect the character data 058 MIRROR_PROJECTIONS, // inside projections block of an mirror entry 059 CODE, 060 BOUNDS, 061 SHAPE, 062 NO_TILE, 063 NO_TILESUM, 064 METADATA, 065 DEFAULT_LAYERS, 066 CUSTOM_HTTP_HEADERS, 067 NOOP, 068 UNKNOWN, // element is not recognized in the current context 069 } 070 071 /** 072 * Constructs a {@code ImageryReader} from a given filename, URL or internal resource. 073 * 074 * @param source can be:<ul> 075 * <li>relative or absolute file name</li> 076 * <li>{@code file:///SOME/FILE} the same as above</li> 077 * <li>{@code http://...} a URL. It will be cached on disk.</li> 078 * <li>{@code resource://SOME/FILE} file from the classpath (usually in the current *.jar)</li> 079 * <li>{@code josmdir://SOME/FILE} file inside josm user data directory (since r7058)</li> 080 * <li>{@code josmplugindir://SOME/FILE} file inside josm plugin directory (since r7834)</li></ul> 081 */ 082 public ImageryReader(String source) { 083 this.source = source; 084 } 085 086 /** 087 * Parses imagery source. 088 * @return list of imagery info 089 * @throws SAXException if any SAX error occurs 090 * @throws IOException if any I/O error occurs 091 */ 092 public List<ImageryInfo> parse() throws SAXException, IOException { 093 Parser parser = new Parser(); 094 try { 095 cachedFile = new CachedFile(source); 096 cachedFile.setParam(String.join(",", ImageryInfo.getActiveIds())); 097 cachedFile.setFastFail(fastFail); 098 try (BufferedReader in = cachedFile 099 .setMaxAge(CachedFile.DAYS) 100 .setCachingStrategy(CachedFile.CachingStrategy.IfModifiedSince) 101 .getContentReader()) { 102 InputSource is = new InputSource(in); 103 XmlUtils.parseSafeSAX(is, parser); 104 return parser.entries; 105 } 106 } catch (SAXException e) { 107 throw e; 108 } catch (ParserConfigurationException e) { 109 Logging.error(e); // broken SAXException chaining 110 throw new SAXException(e); 111 } 112 } 113 114 private static class Parser extends DefaultHandler { 115 private static final String MAX_ZOOM = "max-zoom"; 116 private static final String MIN_ZOOM = "min-zoom"; 117 private static final String TILE_SIZE = "tile-size"; 118 private static final String PRIVACY_POLICY_URL = "privacy-policy-url"; 119 private static final String TRUE = "true"; 120 121 private StringBuilder accumulator = new StringBuilder(); 122 123 private Stack<State> states; 124 125 private List<ImageryInfo> entries; 126 127 /** 128 * Skip the current entry because it has mandatory attributes 129 * that this version of JOSM cannot process. 130 */ 131 private boolean skipEntry; 132 133 private ImageryInfo entry; 134 /** In case of mirror parsing this contains the mirror entry */ 135 private ImageryInfo mirrorEntry; 136 private ImageryBounds bounds; 137 private final Map<ImageryBounds, ImageryBounds> boundsInterner = new HashMap<>(); 138 private Shape shape; 139 // language of last element, does only work for simple ENTRY_ATTRIBUTE's 140 private String lang; 141 private List<String> projections; 142 private MultiMap<String, String> noTileHeaders; 143 private MultiMap<String, String> noTileChecksums; 144 private Map<String, String> metadataHeaders; 145 private List<DefaultLayer> defaultLayers; 146 private Map<String, String> customHttpHeaders; 147 148 @Override 149 public void startDocument() { 150 accumulator = new StringBuilder(); 151 skipEntry = false; 152 states = new Stack<>(); 153 states.push(State.INIT); 154 entries = new ArrayList<>(); 155 entry = null; 156 bounds = null; 157 projections = null; 158 noTileHeaders = null; 159 noTileChecksums = null; 160 customHttpHeaders = null; 161 } 162 163 @Override 164 public void startElement(String namespaceURI, String localName, String qName, Attributes atts) throws SAXException { 165 accumulator.setLength(0); 166 State newState = null; 167 switch (states.peek()) { 168 case INIT: 169 if ("imagery".equals(qName)) { 170 newState = State.IMAGERY; 171 } 172 break; 173 case IMAGERY: 174 if ("entry".equals(qName)) { 175 entry = new ImageryInfo(); 176 skipEntry = false; 177 newState = State.ENTRY; 178 noTileHeaders = new MultiMap<>(); 179 noTileChecksums = new MultiMap<>(); 180 metadataHeaders = new ConcurrentHashMap<>(); 181 defaultLayers = new ArrayList<>(); 182 customHttpHeaders = new ConcurrentHashMap<>(); 183 String best = atts.getValue("eli-best"); 184 if (TRUE.equals(best)) { 185 entry.setBestMarked(true); 186 } 187 String overlay = atts.getValue("overlay"); 188 if (TRUE.equals(overlay)) { 189 entry.setOverlay(true); 190 } 191 } 192 break; 193 case MIRROR: 194 if (Arrays.asList( 195 "type", 196 "url", 197 "id", 198 MIN_ZOOM, 199 MAX_ZOOM, 200 PRIVACY_POLICY_URL, 201 TILE_SIZE 202 ).contains(qName)) { 203 newState = State.MIRROR_ATTRIBUTE; 204 lang = atts.getValue("lang"); 205 } else if ("projections".equals(qName)) { 206 projections = new ArrayList<>(); 207 newState = State.MIRROR_PROJECTIONS; 208 } 209 break; 210 case ENTRY: 211 if (Arrays.asList( 212 "name", 213 "id", 214 "oldid", 215 "type", 216 "description", 217 "default", 218 "url", 219 "eula", 220 MIN_ZOOM, 221 MAX_ZOOM, 222 "attribution-text", 223 "attribution-url", 224 "logo-image", 225 "logo-url", 226 "terms-of-use-text", 227 "terms-of-use-url", 228 PRIVACY_POLICY_URL, 229 "permission-ref", 230 "country-code", 231 "category", 232 "icon", 233 "date", 234 TILE_SIZE, 235 "valid-georeference", 236 "mod-tile-features", 237 "transparent", 238 "minimum-tile-expire" 239 ).contains(qName)) { 240 newState = State.ENTRY_ATTRIBUTE; 241 lang = atts.getValue("lang"); 242 } else if ("bounds".equals(qName)) { 243 try { 244 bounds = new ImageryBounds( 245 atts.getValue("min-lat") + ',' + 246 atts.getValue("min-lon") + ',' + 247 atts.getValue("max-lat") + ',' + 248 atts.getValue("max-lon"), ","); 249 } catch (IllegalArgumentException e) { 250 Logging.trace(e); 251 break; 252 } 253 newState = State.BOUNDS; 254 } else if ("projections".equals(qName)) { 255 projections = new ArrayList<>(); 256 newState = State.PROJECTIONS; 257 } else if ("mirror".equals(qName)) { 258 projections = new ArrayList<>(); 259 newState = State.MIRROR; 260 mirrorEntry = new ImageryInfo(); 261 } else if ("no-tile-header".equals(qName)) { 262 noTileHeaders.put(atts.getValue("name"), atts.getValue("value")); 263 newState = State.NO_TILE; 264 } else if ("no-tile-checksum".equals(qName)) { 265 noTileChecksums.put(atts.getValue("type"), atts.getValue("value")); 266 newState = State.NO_TILESUM; 267 } else if ("metadata-header".equals(qName)) { 268 metadataHeaders.put(atts.getValue("header-name"), atts.getValue("metadata-key")); 269 newState = State.METADATA; 270 } else if ("default-layers".equals(qName)) { 271 newState = State.DEFAULT_LAYERS; 272 } else if ("custom-http-header".equals(qName)) { 273 customHttpHeaders.put(atts.getValue("header-name"), atts.getValue("header-value")); 274 newState = State.CUSTOM_HTTP_HEADERS; 275 } 276 break; 277 case BOUNDS: 278 if ("shape".equals(qName)) { 279 shape = new Shape(); 280 newState = State.SHAPE; 281 } 282 break; 283 case SHAPE: 284 if ("point".equals(qName)) { 285 try { 286 shape.addPoint(atts.getValue("lat"), atts.getValue("lon")); 287 } catch (IllegalArgumentException e) { 288 Logging.trace(e); 289 break; 290 } 291 } 292 break; 293 case PROJECTIONS: 294 case MIRROR_PROJECTIONS: 295 if ("code".equals(qName)) { 296 newState = State.CODE; 297 } 298 break; 299 case DEFAULT_LAYERS: 300 if ("layer".equals(qName)) { 301 newState = State.NOOP; 302 try { 303 defaultLayers.add(new DefaultLayer( 304 entry.getImageryType(), 305 atts.getValue("name"), 306 atts.getValue("style"), 307 atts.getValue("tile-matrix-set") 308 )); 309 } catch (IllegalArgumentException e) { 310 Logging.error(e); 311 } 312 } 313 break; 314 default: // Do nothing 315 } 316 /** 317 * Did not recognize the element, so the new state is UNKNOWN. 318 * This includes the case where we are already inside an unknown 319 * element, i.e. we do not try to understand the inner content 320 * of an unknown element, but wait till it's over. 321 */ 322 if (newState == null) { 323 newState = State.UNKNOWN; 324 } 325 states.push(newState); 326 if (newState == State.UNKNOWN && TRUE.equals(atts.getValue("mandatory"))) { 327 skipEntry = true; 328 } 329 } 330 331 @Override 332 public void characters(char[] ch, int start, int length) { 333 accumulator.append(ch, start, length); 334 } 335 336 @Override 337 public void endElement(String namespaceURI, String qName, String rqName) { 338 switch (states.pop()) { 339 case INIT: 340 throw new JosmRuntimeException("parsing error: more closing than opening elements"); 341 case ENTRY: 342 if ("entry".equals(qName)) { 343 entry.setNoTileHeaders(noTileHeaders); 344 noTileHeaders = null; 345 entry.setNoTileChecksums(noTileChecksums); 346 noTileChecksums = null; 347 entry.setMetadataHeaders(metadataHeaders); 348 metadataHeaders = null; 349 entry.setDefaultLayers(defaultLayers); 350 defaultLayers = null; 351 entry.setCustomHttpHeaders(customHttpHeaders); 352 customHttpHeaders = null; 353 354 if (!skipEntry) { 355 entries.add(entry); 356 } 357 entry = null; 358 } 359 break; 360 case MIRROR: 361 if (mirrorEntry != null && "mirror".equals(qName)) { 362 entry.addMirror(mirrorEntry); 363 mirrorEntry = null; 364 } 365 break; 366 case MIRROR_ATTRIBUTE: 367 if (mirrorEntry != null) { 368 switch(qName) { 369 case "type": 370 Optional<ImageryType> type = Arrays.stream(ImageryType.values()) 371 .filter(t -> Objects.equals(accumulator.toString(), t.getTypeString())) 372 .findFirst(); 373 if (type.isPresent()) { 374 mirrorEntry.setImageryType(type.get()); 375 } else { 376 mirrorEntry = null; 377 } 378 break; 379 case "id": 380 mirrorEntry.setId(accumulator.toString()); 381 break; 382 case "url": 383 mirrorEntry.setUrl(accumulator.toString()); 384 break; 385 case PRIVACY_POLICY_URL: 386 mirrorEntry.setPrivacyPolicyURL(accumulator.toString()); 387 break; 388 case MIN_ZOOM: 389 case MAX_ZOOM: 390 Optional<Integer> zoom = tryParseInt(); 391 if (!zoom.isPresent()) { 392 mirrorEntry = null; 393 } else { 394 if (MIN_ZOOM.equals(qName)) { 395 mirrorEntry.setDefaultMinZoom(zoom.get()); 396 } else { 397 mirrorEntry.setDefaultMaxZoom(zoom.get()); 398 } 399 } 400 break; 401 case TILE_SIZE: 402 Optional<Integer> tileSize = tryParseInt(); 403 if (!tileSize.isPresent()) { 404 mirrorEntry = null; 405 } else { 406 entry.setTileSize(tileSize.get()); 407 } 408 break; 409 default: // Do nothing 410 } 411 } 412 break; 413 case ENTRY_ATTRIBUTE: 414 switch(qName) { 415 case "name": 416 entry.setName(lang == null ? LanguageInfo.getJOSMLocaleCode(null) : lang, accumulator.toString()); 417 break; 418 case "description": 419 entry.setDescription(lang, accumulator.toString()); 420 break; 421 case "date": 422 entry.setDate(accumulator.toString()); 423 break; 424 case "id": 425 entry.setId(accumulator.toString()); 426 break; 427 case "oldid": 428 entry.addOldId(accumulator.toString()); 429 break; 430 case "type": 431 ImageryType type = ImageryType.fromString(accumulator.toString()); 432 if (type != null) 433 entry.setImageryType(type); 434 else 435 skipEntry = true; 436 break; 437 case "default": 438 switch (accumulator.toString()) { 439 case TRUE: 440 entry.setDefaultEntry(true); 441 break; 442 case "false": 443 entry.setDefaultEntry(false); 444 break; 445 default: 446 skipEntry = true; 447 } 448 break; 449 case "url": 450 entry.setUrl(accumulator.toString()); 451 break; 452 case "eula": 453 entry.setEulaAcceptanceRequired(accumulator.toString()); 454 break; 455 case MIN_ZOOM: 456 case MAX_ZOOM: 457 Optional<Integer> zoom = tryParseInt(); 458 if (!zoom.isPresent()) { 459 skipEntry = true; 460 } else { 461 if (MIN_ZOOM.equals(qName)) { 462 entry.setDefaultMinZoom(zoom.get()); 463 } else { 464 entry.setDefaultMaxZoom(zoom.get()); 465 } 466 } 467 break; 468 case "attribution-text": 469 entry.setAttributionText(accumulator.toString()); 470 break; 471 case "attribution-url": 472 entry.setAttributionLinkURL(accumulator.toString()); 473 break; 474 case "logo-image": 475 entry.setAttributionImage(accumulator.toString()); 476 break; 477 case "logo-url": 478 entry.setAttributionImageURL(accumulator.toString()); 479 break; 480 case "terms-of-use-text": 481 entry.setTermsOfUseText(accumulator.toString()); 482 break; 483 case PRIVACY_POLICY_URL: 484 entry.setPrivacyPolicyURL(accumulator.toString()); 485 break; 486 case "permission-ref": 487 entry.setPermissionReferenceURL(accumulator.toString()); 488 break; 489 case "terms-of-use-url": 490 entry.setTermsOfUseURL(accumulator.toString()); 491 break; 492 case "country-code": 493 entry.setCountryCode(accumulator.toString()); 494 break; 495 case "icon": 496 entry.setIcon(accumulator.toString()); 497 break; 498 case TILE_SIZE: 499 Optional<Integer> tileSize = tryParseInt(); 500 if (!tileSize.isPresent()) { 501 skipEntry = true; 502 } else { 503 entry.setTileSize(tileSize.get()); 504 } 505 break; 506 case "valid-georeference": 507 entry.setGeoreferenceValid(Boolean.parseBoolean(accumulator.toString())); 508 break; 509 case "mod-tile-features": 510 entry.setModTileFeatures(Boolean.parseBoolean(accumulator.toString())); 511 break; 512 case "transparent": 513 entry.setTransparent(Boolean.parseBoolean(accumulator.toString())); 514 break; 515 case "minimum-tile-expire": 516 entry.setMinimumTileExpire(Integer.parseInt(accumulator.toString())); 517 break; 518 case "category": 519 String cat = accumulator.toString(); 520 ImageryCategory category = ImageryCategory.fromString(cat); 521 if (category != null) 522 entry.setImageryCategory(category); 523 entry.setImageryCategoryOriginalString(cat); 524 break; 525 default: // Do nothing 526 } 527 break; 528 case BOUNDS: 529 entry.setBounds(intern(bounds)); 530 bounds = null; 531 break; 532 case SHAPE: 533 bounds.addShape(shape); 534 shape = null; 535 break; 536 case CODE: 537 projections.add(accumulator.toString()); 538 break; 539 case PROJECTIONS: 540 entry.setServerProjections(projections); 541 projections = null; 542 break; 543 case MIRROR_PROJECTIONS: 544 mirrorEntry.setServerProjections(projections); 545 projections = null; 546 break; 547 case NO_TILE: 548 case NO_TILESUM: 549 case METADATA: 550 case UNKNOWN: 551 default: 552 // nothing to do for these or the unknown type 553 } 554 } 555 556 private ImageryBounds intern(ImageryBounds imageryBounds) { 557 return boundsInterner.computeIfAbsent(imageryBounds, ignore -> imageryBounds); 558 } 559 560 private Optional<Integer> tryParseInt() { 561 return StringParser.DEFAULT.tryParse(Integer.class, accumulator.toString()); 562 } 563 } 564 565 /** 566 * Sets whether opening HTTP connections should fail fast, i.e., whether a 567 * {@link HttpClient#setConnectTimeout(int) low connect timeout} should be used. 568 * @param fastFail whether opening HTTP connections should fail fast 569 * @see CachedFile#setFastFail(boolean) 570 */ 571 public void setFastFail(boolean fastFail) { 572 this.fastFail = fastFail; 573 } 574 575 @Override 576 public void close() throws IOException { 577 Utils.close(cachedFile); 578 } 579}