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