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}