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