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}