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