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