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