001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.io.imagery;
003
004import java.io.IOException;
005import java.io.InputStream;
006import java.util.ArrayList;
007import java.util.Arrays;
008import java.util.HashMap;
009import java.util.List;
010import java.util.Map;
011import java.util.Objects;
012import java.util.Stack;
013
014import javax.xml.parsers.ParserConfigurationException;
015
016import org.openstreetmap.josm.Main;
017import org.openstreetmap.josm.data.imagery.ImageryInfo;
018import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryBounds;
019import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryType;
020import org.openstreetmap.josm.data.imagery.Shape;
021import org.openstreetmap.josm.io.CachedFile;
022import org.openstreetmap.josm.io.UTFInputStreamReader;
023import org.openstreetmap.josm.tools.LanguageInfo;
024import org.openstreetmap.josm.tools.Utils;
025import org.xml.sax.Attributes;
026import org.xml.sax.InputSource;
027import org.xml.sax.SAXException;
028import org.xml.sax.helpers.DefaultHandler;
029
030public class ImageryReader {
031
032    private final String source;
033
034    private enum State {
035        INIT,               // initial state, should always be at the bottom of the stack
036        IMAGERY,            // inside the imagery element
037        ENTRY,              // inside an entry
038        ENTRY_ATTRIBUTE,    // note we are inside an entry attribute to collect the character data
039        PROJECTIONS,
040        CODE,
041        BOUNDS,
042        SHAPE,
043        NO_TILE,
044        METADATA,
045        UNKNOWN,            // element is not recognized in the current context
046    }
047
048    public ImageryReader(String source) {
049        this.source = source;
050    }
051
052    public List<ImageryInfo> parse() throws SAXException, IOException {
053        Parser parser = new Parser();
054        try {
055            try (InputStream in = new CachedFile(source)
056                    .setMaxAge(1*CachedFile.DAYS)
057                    .setCachingStrategy(CachedFile.CachingStrategy.IfModifiedSince)
058                    .getInputStream()) {
059                InputSource is = new InputSource(UTFInputStreamReader.create(in));
060                Utils.parseSafeSAX(is, parser);
061                return parser.entries;
062            }
063        } catch (SAXException e) {
064            throw e;
065        } catch (ParserConfigurationException e) {
066            Main.error(e); // broken SAXException chaining
067            throw new SAXException(e);
068        }
069    }
070
071    private static class Parser extends DefaultHandler {
072        private StringBuilder accumulator = new StringBuilder();
073
074        private Stack<State> states;
075
076        private List<ImageryInfo> entries;
077
078        /**
079         * Skip the current entry because it has mandatory attributes
080         * that this version of JOSM cannot process.
081         */
082        private boolean skipEntry;
083
084        private ImageryInfo entry;
085        private ImageryBounds bounds;
086        private Shape shape;
087        // language of last element, does only work for simple ENTRY_ATTRIBUTE's
088        private String lang;
089        private List<String> projections;
090        private Map<String, String> noTileHeaders;
091        private Map<String, String> metadataHeaders;
092
093        @Override
094        public void startDocument() {
095            accumulator = new StringBuilder();
096            skipEntry = false;
097            states = new Stack<>();
098            states.push(State.INIT);
099            entries = new ArrayList<>();
100            entry = null;
101            bounds = null;
102            projections = null;
103            noTileHeaders = null;
104        }
105
106        @Override
107        public void startElement(String namespaceURI, String localName, String qName, Attributes atts) throws SAXException {
108            accumulator.setLength(0);
109            State newState = null;
110            switch (states.peek()) {
111            case INIT:
112                if ("imagery".equals(qName)) {
113                    newState = State.IMAGERY;
114                }
115                break;
116            case IMAGERY:
117                if ("entry".equals(qName)) {
118                    entry = new ImageryInfo();
119                    skipEntry = false;
120                    newState = State.ENTRY;
121                    noTileHeaders = new HashMap<>();
122                    metadataHeaders = new HashMap<>();
123                }
124                break;
125            case ENTRY:
126                if (Arrays.asList(new String[] {
127                        "name",
128                        "id",
129                        "type",
130                        "description",
131                        "default",
132                        "url",
133                        "eula",
134                        "min-zoom",
135                        "max-zoom",
136                        "attribution-text",
137                        "attribution-url",
138                        "logo-image",
139                        "logo-url",
140                        "terms-of-use-text",
141                        "terms-of-use-url",
142                        "country-code",
143                        "icon",
144                        "tile-size",
145                        "validGeoreference",
146                        "epsg4326to3857Supported",
147                }).contains(qName)) {
148                    newState = State.ENTRY_ATTRIBUTE;
149                    lang = atts.getValue("lang");
150                } else if ("bounds".equals(qName)) {
151                    try {
152                        bounds = new ImageryBounds(
153                                atts.getValue("min-lat") + ',' +
154                                        atts.getValue("min-lon") + ',' +
155                                        atts.getValue("max-lat") + ',' +
156                                        atts.getValue("max-lon"), ",");
157                    } catch (IllegalArgumentException e) {
158                        break;
159                    }
160                    newState = State.BOUNDS;
161                } else if ("projections".equals(qName)) {
162                    projections = new ArrayList<>();
163                    newState = State.PROJECTIONS;
164                } else if ("no-tile-header".equals(qName)) {
165                    noTileHeaders.put(atts.getValue("name"), atts.getValue("value"));
166                    newState = State.NO_TILE;
167                } else if ("metadata-header".equals(qName)) {
168                    metadataHeaders.put(atts.getValue("header-name"), atts.getValue("metadata-key"));
169                    newState = State.METADATA;
170                }
171                break;
172            case BOUNDS:
173                if ("shape".equals(qName)) {
174                    shape = new Shape();
175                    newState = State.SHAPE;
176                }
177                break;
178            case SHAPE:
179                if ("point".equals(qName)) {
180                    try {
181                        shape.addPoint(atts.getValue("lat"), atts.getValue("lon"));
182                    } catch (IllegalArgumentException e) {
183                        break;
184                    }
185                }
186                break;
187            case PROJECTIONS:
188                if ("code".equals(qName)) {
189                    newState = State.CODE;
190                }
191                break;
192            }
193            /**
194             * Did not recognize the element, so the new state is UNKNOWN.
195             * This includes the case where we are already inside an unknown
196             * element, i.e. we do not try to understand the inner content
197             * of an unknown element, but wait till it's over.
198             */
199            if (newState == null) {
200                newState = State.UNKNOWN;
201            }
202            states.push(newState);
203            if (newState == State.UNKNOWN && "true".equals(atts.getValue("mandatory"))) {
204                skipEntry = true;
205            }
206        }
207
208        @Override
209        public void characters(char[] ch, int start, int length) {
210            accumulator.append(ch, start, length);
211        }
212
213        @Override
214        public void endElement(String namespaceURI, String qName, String rqName) {
215            switch (states.pop()) {
216            case INIT:
217                throw new RuntimeException("parsing error: more closing than opening elements");
218            case ENTRY:
219                if ("entry".equals(qName)) {
220                    entry.setNoTileHeaders(noTileHeaders);
221                    noTileHeaders = null;
222                    entry.setMetadataHeaders(metadataHeaders);
223                    metadataHeaders = null;
224
225                    if (!skipEntry) {
226                        entries.add(entry);
227                    }
228                    entry = null;
229                }
230                break;
231            case ENTRY_ATTRIBUTE:
232                switch(qName) {
233                case "name":
234                    entry.setName(lang == null ? LanguageInfo.getJOSMLocaleCode(null) : lang, accumulator.toString());
235                    break;
236                case "description":
237                    entry.setDescription(lang, accumulator.toString());
238                    break;
239                case "id":
240                    entry.setId(accumulator.toString());
241                    break;
242                case "type":
243                    boolean found = false;
244                    for (ImageryType type : ImageryType.values()) {
245                        if (Objects.equals(accumulator.toString(), type.getTypeString())) {
246                            entry.setImageryType(type);
247                            found = true;
248                            break;
249                        }
250                    }
251                    if (!found) {
252                        skipEntry = true;
253                    }
254                    break;
255                case "default":
256                    switch (accumulator.toString()) {
257                    case "true":
258                        entry.setDefaultEntry(true);
259                        break;
260                    case "false":
261                        entry.setDefaultEntry(false);
262                        break;
263                    default:
264                        skipEntry = true;
265                    }
266                    break;
267                case "url":
268                    entry.setUrl(accumulator.toString());
269                    break;
270                case "eula":
271                    entry.setEulaAcceptanceRequired(accumulator.toString());
272                    break;
273                case "min-zoom":
274                case "max-zoom":
275                    Integer val = null;
276                    try {
277                        val = Integer.valueOf(accumulator.toString());
278                    } catch (NumberFormatException e) {
279                        val = null;
280                    }
281                    if (val == null) {
282                        skipEntry = true;
283                    } else {
284                        if ("min-zoom".equals(qName)) {
285                            entry.setDefaultMinZoom(val);
286                        } else {
287                            entry.setDefaultMaxZoom(val);
288                        }
289                    }
290                    break;
291                case "attribution-text":
292                    entry.setAttributionText(accumulator.toString());
293                    break;
294                case "attribution-url":
295                    entry.setAttributionLinkURL(accumulator.toString());
296                    break;
297                case "logo-image":
298                    entry.setAttributionImage(accumulator.toString());
299                    break;
300                case "logo-url":
301                    entry.setAttributionImageURL(accumulator.toString());
302                    break;
303                case "terms-of-use-text":
304                    entry.setTermsOfUseText(accumulator.toString());
305                    break;
306                case "terms-of-use-url":
307                    entry.setTermsOfUseURL(accumulator.toString());
308                    break;
309                case "country-code":
310                    entry.setCountryCode(accumulator.toString());
311                    break;
312                case "icon":
313                    entry.setIcon(accumulator.toString());
314                    break;
315                case "tile-size":
316                    Integer tileSize = null;
317                    try {
318                        tileSize = Integer.valueOf(accumulator.toString());
319                    } catch (NumberFormatException e) {
320                        tileSize = null;
321                    }
322                    if (tileSize == null) {
323                        skipEntry = true;
324                    } else {
325                        entry.setTileSize(tileSize.intValue());
326                    }
327                    break;
328                case "valid-georeference":
329                    entry.setGeoreferenceValid(new Boolean(accumulator.toString()));
330                    break;
331                case "epsg4326to3857Supported":
332                    entry.setEpsg4326To3857Supported(new Boolean(accumulator.toString()));
333                    break;
334                }
335                break;
336            case BOUNDS:
337                entry.setBounds(bounds);
338                bounds = null;
339                break;
340            case SHAPE:
341                bounds.addShape(shape);
342                shape = null;
343                break;
344            case CODE:
345                projections.add(accumulator.toString());
346                break;
347            case PROJECTIONS:
348                entry.setServerProjections(projections);
349                projections = null;
350                break;
351            case NO_TILE:
352                break;
353
354            }
355        }
356    }
357}