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