001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.io;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.io.IOException;
007import java.io.Reader;
008import java.net.URL;
009import java.util.Collections;
010import java.util.LinkedList;
011import java.util.List;
012
013import javax.xml.parsers.ParserConfigurationException;
014
015import org.openstreetmap.josm.data.Bounds;
016import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
017import org.openstreetmap.josm.data.osm.PrimitiveId;
018import org.openstreetmap.josm.data.osm.SimplePrimitiveId;
019import org.openstreetmap.josm.data.preferences.StringProperty;
020import org.openstreetmap.josm.tools.HttpClient;
021import org.openstreetmap.josm.tools.HttpClient.Response;
022import org.openstreetmap.josm.tools.Logging;
023import org.openstreetmap.josm.tools.OsmUrlToBounds;
024import org.openstreetmap.josm.tools.UncheckedParseException;
025import org.openstreetmap.josm.tools.Utils;
026import org.xml.sax.Attributes;
027import org.xml.sax.InputSource;
028import org.xml.sax.SAXException;
029import org.xml.sax.helpers.DefaultHandler;
030
031/**
032 * Search for names and related items.
033 * @since 11002
034 */
035public final class NameFinder {
036
037    /**
038     * Nominatim default URL.
039     */
040    public static final String NOMINATIM_URL = "https://nominatim.openstreetmap.org/search?format=xml&q=";
041
042    /**
043     * Nominatim URL property.
044     * @since 12557
045     */
046    public static final StringProperty NOMINATIM_URL_PROP = new StringProperty("nominatim-url", NOMINATIM_URL);
047
048    private NameFinder() {
049    }
050
051    /**
052     * Performs a Nominatim search.
053     * @param searchExpression Nominatim search expression
054     * @return search results
055     * @throws IOException if any IO error occurs.
056     */
057    public static List<SearchResult> queryNominatim(final String searchExpression) throws IOException {
058        return query(new URL(NOMINATIM_URL_PROP.get() + Utils.encodeUrl(searchExpression)));
059    }
060
061    /**
062     * Performs a custom search.
063     * @param url search URL to any Nominatim instance
064     * @return search results
065     * @throws IOException if any IO error occurs.
066     */
067    public static List<SearchResult> query(final URL url) throws IOException {
068        final HttpClient connection = HttpClient.create(url);
069        Response response = connection.connect();
070        if (response.getResponseCode() >= 400) {
071            throw new IOException(response.getResponseMessage() + ": " + response.fetchContent());
072        }
073        try (Reader reader = response.getContentReader()) {
074            return parseSearchResults(reader);
075        } catch (ParserConfigurationException | SAXException ex) {
076            throw new UncheckedParseException(ex);
077        }
078    }
079
080    /**
081     * Parse search results as returned by Nominatim.
082     * @param reader reader
083     * @return search results
084     * @throws ParserConfigurationException if a parser cannot be created which satisfies the requested configuration.
085     * @throws SAXException for SAX errors.
086     * @throws IOException if any IO error occurs.
087     */
088    public static List<SearchResult> parseSearchResults(Reader reader) throws IOException, ParserConfigurationException, SAXException {
089        InputSource inputSource = new InputSource(reader);
090        NameFinderResultParser parser = new NameFinderResultParser();
091        Utils.parseSafeSAX(inputSource, parser);
092        return parser.getResult();
093    }
094
095    /**
096     * Data storage for search results.
097     */
098    public static class SearchResult {
099        private String name;
100        private String info;
101        private String nearestPlace;
102        private String description;
103        private double lat;
104        private double lon;
105        private int zoom;
106        private Bounds bounds;
107        private PrimitiveId osmId;
108
109        /**
110         * Returns the name.
111         * @return the name
112         */
113        public final String getName() {
114            return name;
115        }
116
117        /**
118         * Returns the info.
119         * @return the info
120         */
121        public final String getInfo() {
122            return info;
123        }
124
125        /**
126         * Returns the nearest place.
127         * @return the nearest place
128         */
129        public final String getNearestPlace() {
130            return nearestPlace;
131        }
132
133        /**
134         * Returns the description.
135         * @return the description
136         */
137        public final String getDescription() {
138            return description;
139        }
140
141        /**
142         * Returns the latitude.
143         * @return the latitude
144         */
145        public final double getLat() {
146            return lat;
147        }
148
149        /**
150         * Returns the longitude.
151         * @return the longitude
152         */
153        public final double getLon() {
154            return lon;
155        }
156
157        /**
158         * Returns the zoom.
159         * @return the zoom
160         */
161        public final int getZoom() {
162            return zoom;
163        }
164
165        /**
166         * Returns the bounds.
167         * @return the bounds
168         */
169        public final Bounds getBounds() {
170            return bounds;
171        }
172
173        /**
174         * Returns the OSM id.
175         * @return the OSM id
176         */
177        public final PrimitiveId getOsmId() {
178            return osmId;
179        }
180
181        /**
182         * Returns the download area.
183         * @return the download area
184         */
185        public Bounds getDownloadArea() {
186            return bounds != null ? bounds : OsmUrlToBounds.positionToBounds(lat, lon, zoom);
187        }
188    }
189
190    /**
191     * A very primitive parser for the name finder's output.
192     * Structure of xml described here:  http://wiki.openstreetmap.org/index.php/Name_finder
193     */
194    private static class NameFinderResultParser extends DefaultHandler {
195        private SearchResult currentResult;
196        private StringBuilder description;
197        private int depth;
198        private final List<SearchResult> data = new LinkedList<>();
199
200        /**
201         * Detect starting elements.
202         */
203        @Override
204        public void startElement(String namespaceURI, String localName, String qName, Attributes atts)
205                throws SAXException {
206            depth++;
207            try {
208                if ("searchresults".equals(qName)) {
209                    // do nothing
210                } else if (depth == 2 && "named".equals(qName)) {
211                    currentResult = new SearchResult();
212                    currentResult.name = atts.getValue("name");
213                    currentResult.info = atts.getValue("info");
214                    if (currentResult.info != null) {
215                        currentResult.info = tr(currentResult.info);
216                    }
217                    currentResult.lat = Double.parseDouble(atts.getValue("lat"));
218                    currentResult.lon = Double.parseDouble(atts.getValue("lon"));
219                    currentResult.zoom = Integer.parseInt(atts.getValue("zoom"));
220                    data.add(currentResult);
221                } else if (depth == 3 && "description".equals(qName)) {
222                    description = new StringBuilder();
223                } else if (depth == 4 && "named".equals(qName)) {
224                    // this is a "named" place in the nearest places list.
225                    String info = atts.getValue("info");
226                    if ("city".equals(info) || "town".equals(info) || "village".equals(info)) {
227                        currentResult.nearestPlace = atts.getValue("name");
228                    }
229                } else if ("place".equals(qName) && atts.getValue("lat") != null) {
230                    currentResult = new SearchResult();
231                    currentResult.name = atts.getValue("display_name");
232                    currentResult.description = currentResult.name;
233                    currentResult.info = atts.getValue("class");
234                    if (currentResult.info != null) {
235                        currentResult.info = tr(currentResult.info);
236                    }
237                    currentResult.nearestPlace = tr(atts.getValue("type"));
238                    currentResult.lat = Double.parseDouble(atts.getValue("lat"));
239                    currentResult.lon = Double.parseDouble(atts.getValue("lon"));
240                    String[] bbox = atts.getValue("boundingbox").split(",");
241                    currentResult.bounds = new Bounds(
242                            Double.parseDouble(bbox[0]), Double.parseDouble(bbox[2]),
243                            Double.parseDouble(bbox[1]), Double.parseDouble(bbox[3]));
244                    final String osmId = atts.getValue("osm_id");
245                    final String osmType = atts.getValue("osm_type");
246                    if (osmId != null && osmType != null) {
247                        currentResult.osmId = new SimplePrimitiveId(Long.parseLong(osmId), OsmPrimitiveType.from(osmType));
248                    }
249                    data.add(currentResult);
250                }
251            } catch (NumberFormatException ex) {
252                Logging.error(ex); // SAXException does not chain correctly
253                throw new SAXException(ex.getMessage(), ex);
254            } catch (NullPointerException ex) { // NOPMD
255                Logging.error(ex); // SAXException does not chain correctly
256                throw new SAXException(tr("Null pointer exception, possibly some missing tags."), ex);
257            }
258        }
259
260        /**
261         * Detect ending elements.
262         */
263        @Override
264        public void endElement(String namespaceURI, String localName, String qName) throws SAXException {
265            if (description != null && "description".equals(qName)) {
266                currentResult.description = description.toString();
267                description = null;
268            }
269            depth--;
270        }
271
272        /**
273         * Read characters for description.
274         */
275        @Override
276        public void characters(char[] data, int start, int length) throws SAXException {
277            if (description != null) {
278                description.append(data, start, length);
279            }
280        }
281
282        public List<SearchResult> getResult() {
283            return Collections.unmodifiableList(data);
284        }
285    }
286}