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