// License: GPL. For details, see LICENSE file. package org.openstreetmap.josm.io.imagery; import java.io.BufferedReader; import java.io.Closeable; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Stack; import javax.xml.parsers.ParserConfigurationException; import org.openstreetmap.josm.Main; import org.openstreetmap.josm.data.imagery.ImageryInfo; import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryBounds; import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryType; import org.openstreetmap.josm.data.imagery.Shape; import org.openstreetmap.josm.io.CachedFile; import org.openstreetmap.josm.tools.HttpClient; import org.openstreetmap.josm.tools.JosmRuntimeException; import org.openstreetmap.josm.tools.LanguageInfo; import org.openstreetmap.josm.tools.MultiMap; import org.openstreetmap.josm.tools.Utils; import org.xml.sax.Attributes; import org.xml.sax.InputSource; import org.xml.sax.SAXException; import org.xml.sax.helpers.DefaultHandler; public class ImageryReader implements Closeable { private final String source; private CachedFile cachedFile; private boolean fastFail; private enum State { INIT, // initial state, should always be at the bottom of the stack IMAGERY, // inside the imagery element ENTRY, // inside an entry ENTRY_ATTRIBUTE, // note we are inside an entry attribute to collect the character data PROJECTIONS, // inside projections block of an entry MIRROR, // inside an mirror entry MIRROR_ATTRIBUTE, // note we are inside an mirror attribute to collect the character data MIRROR_PROJECTIONS, // inside projections block of an mirror entry CODE, BOUNDS, SHAPE, NO_TILE, NO_TILESUM, METADATA, UNKNOWN, // element is not recognized in the current context } /** * Constructs a {@code ImageryReader} from a given filename, URL or internal resource. * * @param source can be:<ul> * <li>relative or absolute file name</li> * <li>{@code file:///SOME/FILE} the same as above</li> * <li>{@code http://...} a URL. It will be cached on disk.</li> * <li>{@code resource://SOME/FILE} file from the classpath (usually in the current *.jar)</li> * <li>{@code josmdir://SOME/FILE} file inside josm user data directory (since r7058)</li> * <li>{@code josmplugindir://SOME/FILE} file inside josm plugin directory (since r7834)</li></ul> */ public ImageryReader(String source) { this.source = source; } /** * Parses imagery source. * @return list of imagery info * @throws SAXException if any SAX error occurs * @throws IOException if any I/O error occurs */ public List<ImageryInfo> parse() throws SAXException, IOException { Parser parser = new Parser(); try { cachedFile = new CachedFile(source); cachedFile.setFastFail(fastFail); try (BufferedReader in = cachedFile .setMaxAge(CachedFile.DAYS) .setCachingStrategy(CachedFile.CachingStrategy.IfModifiedSince) .getContentReader()) { InputSource is = new InputSource(in); Utils.parseSafeSAX(is, parser); return parser.entries; } } catch (SAXException e) { throw e; } catch (ParserConfigurationException e) { Main.error(e); // broken SAXException chaining throw new SAXException(e); } } private static class Parser extends DefaultHandler { private StringBuilder accumulator = new StringBuilder(); private Stack<State> states; private List<ImageryInfo> entries; /** * Skip the current entry because it has mandatory attributes * that this version of JOSM cannot process. */ private boolean skipEntry; private ImageryInfo entry; /** In case of mirror parsing this contains the mirror entry */ private ImageryInfo mirrorEntry; private ImageryBounds bounds; private Shape shape; // language of last element, does only work for simple ENTRY_ATTRIBUTE's private String lang; private List<String> projections; private MultiMap<String, String> noTileHeaders; private MultiMap<String, String> noTileChecksums; private Map<String, String> metadataHeaders; @Override public void startDocument() { accumulator = new StringBuilder(); skipEntry = false; states = new Stack<>(); states.push(State.INIT); entries = new ArrayList<>(); entry = null; bounds = null; projections = null; noTileHeaders = null; noTileChecksums = null; } @Override public void startElement(String namespaceURI, String localName, String qName, Attributes atts) throws SAXException { accumulator.setLength(0); State newState = null; switch (states.peek()) { case INIT: if ("imagery".equals(qName)) { newState = State.IMAGERY; } break; case IMAGERY: if ("entry".equals(qName)) { entry = new ImageryInfo(); skipEntry = false; newState = State.ENTRY; noTileHeaders = new MultiMap<>(); noTileChecksums = new MultiMap<>(); metadataHeaders = new HashMap<>(); String best = atts.getValue("eli-best"); if ("true".equals(best)) { entry.setBestMarked(true); } } break; case MIRROR: if (Arrays.asList(new String[] { "type", "url", "min-zoom", "max-zoom", "tile-size", }).contains(qName)) { newState = State.MIRROR_ATTRIBUTE; lang = atts.getValue("lang"); } else if ("projections".equals(qName)) { projections = new ArrayList<>(); newState = State.MIRROR_PROJECTIONS; } break; case ENTRY: if (Arrays.asList(new String[] { "name", "id", "type", "description", "default", "url", "eula", "min-zoom", "max-zoom", "attribution-text", "attribution-url", "logo-image", "logo-url", "terms-of-use-text", "terms-of-use-url", "country-code", "icon", "date", "tile-size", "valid-georeference", }).contains(qName)) { newState = State.ENTRY_ATTRIBUTE; lang = atts.getValue("lang"); } else if ("bounds".equals(qName)) { try { bounds = new ImageryBounds( atts.getValue("min-lat") + ',' + atts.getValue("min-lon") + ',' + atts.getValue("max-lat") + ',' + atts.getValue("max-lon"), ","); } catch (IllegalArgumentException e) { Main.trace(e); break; } newState = State.BOUNDS; } else if ("projections".equals(qName)) { projections = new ArrayList<>(); newState = State.PROJECTIONS; } else if ("mirror".equals(qName)) { projections = new ArrayList<>(); newState = State.MIRROR; mirrorEntry = new ImageryInfo(); } else if ("no-tile-header".equals(qName)) { noTileHeaders.put(atts.getValue("name"), atts.getValue("value")); newState = State.NO_TILE; } else if ("no-tile-checksum".equals(qName)) { noTileChecksums.put(atts.getValue("type"), atts.getValue("value")); newState = State.NO_TILESUM; } else if ("metadata-header".equals(qName)) { metadataHeaders.put(atts.getValue("header-name"), atts.getValue("metadata-key")); newState = State.METADATA; } break; case BOUNDS: if ("shape".equals(qName)) { shape = new Shape(); newState = State.SHAPE; } break; case SHAPE: if ("point".equals(qName)) { try { shape.addPoint(atts.getValue("lat"), atts.getValue("lon")); } catch (IllegalArgumentException e) { Main.trace(e); break; } } break; case PROJECTIONS: case MIRROR_PROJECTIONS: if ("code".equals(qName)) { newState = State.CODE; } break; default: // Do nothing } /** * Did not recognize the element, so the new state is UNKNOWN. * This includes the case where we are already inside an unknown * element, i.e. we do not try to understand the inner content * of an unknown element, but wait till it's over. */ if (newState == null) { newState = State.UNKNOWN; } states.push(newState); if (newState == State.UNKNOWN && "true".equals(atts.getValue("mandatory"))) { skipEntry = true; } } @Override public void characters(char[] ch, int start, int length) { accumulator.append(ch, start, length); } @Override public void endElement(String namespaceURI, String qName, String rqName) { switch (states.pop()) { case INIT: throw new JosmRuntimeException("parsing error: more closing than opening elements"); case ENTRY: if ("entry".equals(qName)) { entry.setNoTileHeaders(noTileHeaders); noTileHeaders = null; entry.setNoTileChecksums(noTileChecksums); noTileChecksums = null; entry.setMetadataHeaders(metadataHeaders); metadataHeaders = null; if (!skipEntry) { entries.add(entry); } entry = null; } break; case MIRROR: if (mirrorEntry != null && "mirror".equals(qName)) { entry.addMirror(mirrorEntry); mirrorEntry = null; } break; case MIRROR_ATTRIBUTE: if (mirrorEntry != null) { switch(qName) { case "type": boolean found = false; for (ImageryType type : ImageryType.values()) { if (Objects.equals(accumulator.toString(), type.getTypeString())) { mirrorEntry.setImageryType(type); found = true; break; } } if (!found) { mirrorEntry = null; } break; case "url": mirrorEntry.setUrl(accumulator.toString()); break; case "min-zoom": case "max-zoom": Integer val = null; try { val = Integer.valueOf(accumulator.toString()); } catch (NumberFormatException e) { val = null; } if (val == null) { mirrorEntry = null; } else { if ("min-zoom".equals(qName)) { mirrorEntry.setDefaultMinZoom(val); } else { mirrorEntry.setDefaultMaxZoom(val); } } break; case "tile-size": Integer tileSize = null; try { tileSize = Integer.valueOf(accumulator.toString()); } catch (NumberFormatException e) { tileSize = null; } if (tileSize == null) { mirrorEntry = null; } else { entry.setTileSize(tileSize.intValue()); } break; default: // Do nothing } } break; case ENTRY_ATTRIBUTE: switch(qName) { case "name": entry.setName(lang == null ? LanguageInfo.getJOSMLocaleCode(null) : lang, accumulator.toString()); break; case "description": entry.setDescription(lang, accumulator.toString()); break; case "date": entry.setDate(accumulator.toString()); break; case "id": entry.setId(accumulator.toString()); break; case "type": boolean found = false; for (ImageryType type : ImageryType.values()) { if (Objects.equals(accumulator.toString(), type.getTypeString())) { entry.setImageryType(type); found = true; break; } } if (!found) { skipEntry = true; } break; case "default": switch (accumulator.toString()) { case "true": entry.setDefaultEntry(true); break; case "false": entry.setDefaultEntry(false); break; default: skipEntry = true; } break; case "url": entry.setUrl(accumulator.toString()); break; case "eula": entry.setEulaAcceptanceRequired(accumulator.toString()); break; case "min-zoom": case "max-zoom": Integer val = null; try { val = Integer.valueOf(accumulator.toString()); } catch (NumberFormatException e) { val = null; } if (val == null) { skipEntry = true; } else { if ("min-zoom".equals(qName)) { entry.setDefaultMinZoom(val); } else { entry.setDefaultMaxZoom(val); } } break; case "attribution-text": entry.setAttributionText(accumulator.toString()); break; case "attribution-url": entry.setAttributionLinkURL(accumulator.toString()); break; case "logo-image": entry.setAttributionImage(accumulator.toString()); break; case "logo-url": entry.setAttributionImageURL(accumulator.toString()); break; case "terms-of-use-text": entry.setTermsOfUseText(accumulator.toString()); break; case "terms-of-use-url": entry.setTermsOfUseURL(accumulator.toString()); break; case "country-code": entry.setCountryCode(accumulator.toString()); break; case "icon": entry.setIcon(accumulator.toString()); break; case "tile-size": Integer tileSize = null; try { tileSize = Integer.valueOf(accumulator.toString()); } catch (NumberFormatException e) { tileSize = null; } if (tileSize == null) { skipEntry = true; } else { entry.setTileSize(tileSize.intValue()); } break; case "valid-georeference": entry.setGeoreferenceValid(Boolean.parseBoolean(accumulator.toString())); break; default: // Do nothing } break; case BOUNDS: entry.setBounds(bounds); bounds = null; break; case SHAPE: bounds.addShape(shape); shape = null; break; case CODE: projections.add(accumulator.toString()); break; case PROJECTIONS: entry.setServerProjections(projections); projections = null; break; case MIRROR_PROJECTIONS: mirrorEntry.setServerProjections(projections); projections = null; break; case NO_TILE: case NO_TILESUM: case METADATA: case UNKNOWN: default: // nothing to do for these or the unknown type } } } /** * Sets whether opening HTTP connections should fail fast, i.e., whether a * {@link HttpClient#setConnectTimeout(int) low connect timeout} should be used. * @param fastFail whether opening HTTP connections should fail fast * @see CachedFile#setFastFail(boolean) */ public void setFastFail(boolean fastFail) { this.fastFail = fastFail; } @Override public void close() throws IOException { Utils.close(cachedFile); } }