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