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