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                        break;
206                    }
207                    newState = State.BOUNDS;
208                } else if ("projections".equals(qName)) {
209                    projections = new ArrayList<>();
210                    newState = State.PROJECTIONS;
211                } else if ("mirror".equals(qName)) {
212                    projections = new ArrayList<>();
213                    newState = State.MIRROR;
214                    mirrorEntry = new ImageryInfo();
215                } else if ("no-tile-header".equals(qName)) {
216                    noTileHeaders.put(atts.getValue("name"), atts.getValue("value"));
217                    newState = State.NO_TILE;
218                } else if ("no-tile-checksum".equals(qName)) {
219                    noTileChecksums.put(atts.getValue("type"), atts.getValue("value"));
220                    newState = State.NO_TILESUM;
221                } else if ("metadata-header".equals(qName)) {
222                    metadataHeaders.put(atts.getValue("header-name"), atts.getValue("metadata-key"));
223                    newState = State.METADATA;
224                }
225                break;
226            case BOUNDS:
227                if ("shape".equals(qName)) {
228                    shape = new Shape();
229                    newState = State.SHAPE;
230                }
231                break;
232            case SHAPE:
233                if ("point".equals(qName)) {
234                    try {
235                        shape.addPoint(atts.getValue("lat"), atts.getValue("lon"));
236                    } catch (IllegalArgumentException e) {
237                        break;
238                    }
239                }
240                break;
241            case PROJECTIONS:
242            case MIRROR_PROJECTIONS:
243                if ("code".equals(qName)) {
244                    newState = State.CODE;
245                }
246                break;
247            default: // Do nothing
248            }
249            /**
250             * Did not recognize the element, so the new state is UNKNOWN.
251             * This includes the case where we are already inside an unknown
252             * element, i.e. we do not try to understand the inner content
253             * of an unknown element, but wait till it's over.
254             */
255            if (newState == null) {
256                newState = State.UNKNOWN;
257            }
258            states.push(newState);
259            if (newState == State.UNKNOWN && "true".equals(atts.getValue("mandatory"))) {
260                skipEntry = true;
261            }
262        }
263
264        @Override
265        public void characters(char[] ch, int start, int length) {
266            accumulator.append(ch, start, length);
267        }
268
269        @Override
270        public void endElement(String namespaceURI, String qName, String rqName) {
271            switch (states.pop()) {
272            case INIT:
273                throw new RuntimeException("parsing error: more closing than opening elements");
274            case ENTRY:
275                if ("entry".equals(qName)) {
276                    entry.setNoTileHeaders(noTileHeaders);
277                    noTileHeaders = null;
278                    entry.setNoTileChecksums(noTileChecksums);
279                    noTileChecksums = null;
280                    entry.setMetadataHeaders(metadataHeaders);
281                    metadataHeaders = null;
282
283                    if (!skipEntry) {
284                        entries.add(entry);
285                    }
286                    entry = null;
287                }
288                break;
289            case MIRROR:
290                if ("mirror".equals(qName)) {
291                    if (mirrorEntry != null) {
292                        entry.addMirror(mirrorEntry);
293                        mirrorEntry = null;
294                    }
295                }
296                break;
297            case MIRROR_ATTRIBUTE:
298                if (mirrorEntry != null) {
299                    switch(qName) {
300                    case "type":
301                        boolean found = false;
302                        for (ImageryType type : ImageryType.values()) {
303                            if (Objects.equals(accumulator.toString(), type.getTypeString())) {
304                                mirrorEntry.setImageryType(type);
305                                found = true;
306                                break;
307                            }
308                        }
309                        if (!found) {
310                            mirrorEntry = null;
311                        }
312                        break;
313                    case "url":
314                        mirrorEntry.setUrl(accumulator.toString());
315                        break;
316                    case "min-zoom":
317                    case "max-zoom":
318                        Integer val = null;
319                        try {
320                            val = Integer.valueOf(accumulator.toString());
321                        } catch (NumberFormatException e) {
322                            val = null;
323                        }
324                        if (val == null) {
325                            mirrorEntry = null;
326                        } else {
327                            if ("min-zoom".equals(qName)) {
328                                mirrorEntry.setDefaultMinZoom(val);
329                            } else {
330                                mirrorEntry.setDefaultMaxZoom(val);
331                            }
332                        }
333                        break;
334                    case "tile-size":
335                        Integer tileSize = null;
336                        try {
337                            tileSize = Integer.valueOf(accumulator.toString());
338                        } catch (NumberFormatException e) {
339                            tileSize = null;
340                        }
341                        if (tileSize == null) {
342                            mirrorEntry = null;
343                        } else {
344                            entry.setTileSize(tileSize.intValue());
345                        }
346                        break;
347                    default: // Do nothing
348                    }
349                }
350                break;
351            case ENTRY_ATTRIBUTE:
352                switch(qName) {
353                case "name":
354                    entry.setName(lang == null ? LanguageInfo.getJOSMLocaleCode(null) : lang, accumulator.toString());
355                    break;
356                case "description":
357                    entry.setDescription(lang, accumulator.toString());
358                    break;
359                case "id":
360                    entry.setId(accumulator.toString());
361                    break;
362                case "type":
363                    boolean found = false;
364                    for (ImageryType type : ImageryType.values()) {
365                        if (Objects.equals(accumulator.toString(), type.getTypeString())) {
366                            entry.setImageryType(type);
367                            found = true;
368                            break;
369                        }
370                    }
371                    if (!found) {
372                        skipEntry = true;
373                    }
374                    break;
375                case "default":
376                    switch (accumulator.toString()) {
377                    case "true":
378                        entry.setDefaultEntry(true);
379                        break;
380                    case "false":
381                        entry.setDefaultEntry(false);
382                        break;
383                    default:
384                        skipEntry = true;
385                    }
386                    break;
387                case "url":
388                    entry.setUrl(accumulator.toString());
389                    break;
390                case "eula":
391                    entry.setEulaAcceptanceRequired(accumulator.toString());
392                    break;
393                case "min-zoom":
394                case "max-zoom":
395                    Integer val = null;
396                    try {
397                        val = Integer.valueOf(accumulator.toString());
398                    } catch (NumberFormatException e) {
399                        val = null;
400                    }
401                    if (val == null) {
402                        skipEntry = true;
403                    } else {
404                        if ("min-zoom".equals(qName)) {
405                            entry.setDefaultMinZoom(val);
406                        } else {
407                            entry.setDefaultMaxZoom(val);
408                        }
409                    }
410                    break;
411                case "attribution-text":
412                    entry.setAttributionText(accumulator.toString());
413                    break;
414                case "attribution-url":
415                    entry.setAttributionLinkURL(accumulator.toString());
416                    break;
417                case "logo-image":
418                    entry.setAttributionImage(accumulator.toString());
419                    break;
420                case "logo-url":
421                    entry.setAttributionImageURL(accumulator.toString());
422                    break;
423                case "terms-of-use-text":
424                    entry.setTermsOfUseText(accumulator.toString());
425                    break;
426                case "terms-of-use-url":
427                    entry.setTermsOfUseURL(accumulator.toString());
428                    break;
429                case "country-code":
430                    entry.setCountryCode(accumulator.toString());
431                    break;
432                case "icon":
433                    entry.setIcon(accumulator.toString());
434                    break;
435                case "tile-size":
436                    Integer tileSize = null;
437                    try {
438                        tileSize = Integer.valueOf(accumulator.toString());
439                    } catch (NumberFormatException e) {
440                        tileSize = null;
441                    }
442                    if (tileSize == null) {
443                        skipEntry = true;
444                    } else {
445                        entry.setTileSize(tileSize.intValue());
446                    }
447                    break;
448                case "valid-georeference":
449                    entry.setGeoreferenceValid(Boolean.valueOf(accumulator.toString()));
450                    break;
451                case "epsg4326to3857Supported":
452                    entry.setEpsg4326To3857Supported(Boolean.valueOf(accumulator.toString()));
453                    break;
454                default: // Do nothing
455                }
456                break;
457            case BOUNDS:
458                entry.setBounds(bounds);
459                bounds = null;
460                break;
461            case SHAPE:
462                bounds.addShape(shape);
463                shape = null;
464                break;
465            case CODE:
466                projections.add(accumulator.toString());
467                break;
468            case PROJECTIONS:
469                entry.setServerProjections(projections);
470                projections = null;
471                break;
472            case MIRROR_PROJECTIONS:
473                mirrorEntry.setServerProjections(projections);
474                projections = null;
475                break;
476            case NO_TILE:
477            case NO_TILESUM:
478            case METADATA:
479            case UNKNOWN:
480            default:
481                // nothing to do for these or the unknown type
482            }
483        }
484    }
485
486    /**
487     * Sets whether opening HTTP connections should fail fast, i.e., whether a
488     * {@link HttpClient#setConnectTimeout(int) low connect timeout} should be used.
489     * @param fastFail whether opening HTTP connections should fail fast
490     * @see CachedFile#setFastFail(boolean)
491     */
492    public void setFastFail(boolean fastFail) {
493        this.fastFail = fastFail;
494    }
495
496    @Override
497    public void close() throws IOException {
498        Utils.close(cachedFile);
499    }
500}