// License: GPL. For details, see LICENSE file. package org.openstreetmap.josm.io; import static org.openstreetmap.josm.tools.I18n.tr; import java.io.IOException; import java.io.Reader; import java.io.UncheckedIOException; import java.net.MalformedURLException; import java.net.URL; import java.util.Collection; import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; import javax.xml.parsers.ParserConfigurationException; import org.openstreetmap.josm.data.Bounds; import org.openstreetmap.josm.data.osm.OsmPrimitiveType; import org.openstreetmap.josm.data.osm.PrimitiveId; import org.openstreetmap.josm.data.osm.SimplePrimitiveId; import org.openstreetmap.josm.data.preferences.StringProperty; import org.openstreetmap.josm.tools.HttpClient; import org.openstreetmap.josm.tools.HttpClient.Response; import org.openstreetmap.josm.tools.Logging; import org.openstreetmap.josm.tools.OsmUrlToBounds; import org.openstreetmap.josm.tools.UncheckedParseException; import org.openstreetmap.josm.tools.Utils; import org.openstreetmap.josm.tools.XmlUtils; import org.xml.sax.Attributes; import org.xml.sax.InputSource; import org.xml.sax.SAXException; import org.xml.sax.helpers.DefaultHandler; /** * Search for names and related items. * @since 11002 */ public final class NameFinder { /** * Nominatim default URL. */ public static final String NOMINATIM_URL = "https://nominatim.openstreetmap.org/search?format=xml&q="; /** * Nominatim URL property. * @since 12557 */ public static final StringProperty NOMINATIM_URL_PROP = new StringProperty("nominatim-url", NOMINATIM_URL); private NameFinder() { } /** * Builds the Nominatim URL for performing the given search * @param searchExpression the Nominatim query * @return the Nominatim URL */ public static URL buildNominatimURL(String searchExpression) { return buildNominatimURL(searchExpression, Collections.emptyList()); } /** * Builds the Nominatim URL for performing the given search and excluding the results (of a previous search) * @param searchExpression the Nominatim query * @param excludeResults the results to exclude * @return the Nominatim URL * @see Result limitation in Nominatim Documentation */ public static URL buildNominatimURL(String searchExpression, Collection excludeResults) { try { final String excludeString = excludeResults.isEmpty() ? "" : excludeResults.stream() .map(SearchResult::getPlaceId) .map(String::valueOf) .collect(Collectors.joining(",", "&exclude_place_ids=", "")); return new URL(NOMINATIM_URL_PROP.get() + Utils.encodeUrl(searchExpression) + excludeString); } catch (MalformedURLException ex) { throw new UncheckedIOException(ex); } } /** * Performs a Nominatim search. * @param searchExpression Nominatim search expression * @return search results * @throws IOException if any IO error occurs. */ public static List queryNominatim(final String searchExpression) throws IOException { return query(buildNominatimURL(searchExpression)); } /** * Performs a custom search. * @param url search URL to any Nominatim instance * @return search results * @throws IOException if any IO error occurs. */ public static List query(final URL url) throws IOException { final HttpClient connection = HttpClient.create(url) .setAccept("application/xml, */*;q=0.8"); Response response = connection.connect(); if (response.getResponseCode() >= 400) { throw new IOException(response.getResponseMessage() + ": " + response.fetchContent()); } try (Reader reader = response.getContentReader()) { return parseSearchResults(reader); } catch (ParserConfigurationException | SAXException ex) { throw new UncheckedParseException(ex); } } /** * Parse search results as returned by Nominatim. * @param reader reader * @return search results * @throws ParserConfigurationException if a parser cannot be created which satisfies the requested configuration. * @throws SAXException for SAX errors. * @throws IOException if any IO error occurs. */ public static List parseSearchResults(Reader reader) throws IOException, ParserConfigurationException, SAXException { InputSource inputSource = new InputSource(reader); NameFinderResultParser parser = new NameFinderResultParser(); XmlUtils.parseSafeSAX(inputSource, parser); return parser.getResult(); } /** * Data storage for search results. */ public static class SearchResult { private String name; private String info; private String nearestPlace; private String description; private double lat; private double lon; private int zoom; private Bounds bounds; private PrimitiveId osmId; private long placeId; /** * Returns the name. * @return the name */ public final String getName() { return name; } /** * Returns the info. * @return the info */ public final String getInfo() { return info; } /** * Returns the nearest place. * @return the nearest place */ public final String getNearestPlace() { return nearestPlace; } /** * Returns the description. * @return the description */ public final String getDescription() { return description; } /** * Returns the latitude. * @return the latitude */ public final double getLat() { return lat; } /** * Returns the longitude. * @return the longitude */ public final double getLon() { return lon; } /** * Returns the zoom. * @return the zoom */ public final int getZoom() { return zoom; } /** * Returns the bounds. * @return the bounds */ public final Bounds getBounds() { return bounds; } /** * Returns the OSM id. * @return the OSM id */ public final PrimitiveId getOsmId() { return osmId; } /** * Returns the Nominatim place id. * @return the Nominatim place id */ public long getPlaceId() { return placeId; } /** * Returns the download area. * @return the download area */ public Bounds getDownloadArea() { return bounds != null ? bounds : OsmUrlToBounds.positionToBounds(lat, lon, zoom); } } /** * A very primitive parser for the name finder's output. * Structure of xml described here: http://wiki.openstreetmap.org/index.php/Name_finder */ private static class NameFinderResultParser extends DefaultHandler { private SearchResult currentResult; private StringBuilder description; private int depth; private final List data = new LinkedList<>(); /** * Detect starting elements. */ @Override public void startElement(String namespaceURI, String localName, String qName, Attributes atts) throws SAXException { depth++; try { if ("searchresults".equals(qName)) { // do nothing } else if (depth == 2 && "named".equals(qName)) { currentResult = new SearchResult(); currentResult.name = atts.getValue("name"); currentResult.info = atts.getValue("info"); if (currentResult.info != null) { currentResult.info = tr(currentResult.info); } currentResult.lat = Double.parseDouble(atts.getValue("lat")); currentResult.lon = Double.parseDouble(atts.getValue("lon")); currentResult.zoom = Integer.parseInt(atts.getValue("zoom")); data.add(currentResult); } else if (depth == 3 && "description".equals(qName)) { description = new StringBuilder(); } else if (depth == 4 && "named".equals(qName)) { // this is a "named" place in the nearest places list. String info = atts.getValue("info"); if ("city".equals(info) || "town".equals(info) || "village".equals(info)) { currentResult.nearestPlace = atts.getValue("name"); } } else if ("place".equals(qName) && atts.getValue("lat") != null) { currentResult = new SearchResult(); currentResult.name = atts.getValue("display_name"); currentResult.description = currentResult.name; currentResult.info = atts.getValue("class"); if (currentResult.info != null) { currentResult.info = tr(currentResult.info); } currentResult.nearestPlace = tr(atts.getValue("type")); currentResult.lat = Double.parseDouble(atts.getValue("lat")); currentResult.lon = Double.parseDouble(atts.getValue("lon")); String[] bbox = atts.getValue("boundingbox").split(","); currentResult.bounds = new Bounds( Double.parseDouble(bbox[0]), Double.parseDouble(bbox[2]), Double.parseDouble(bbox[1]), Double.parseDouble(bbox[3])); final String osmId = atts.getValue("osm_id"); final String osmType = atts.getValue("osm_type"); if (osmId != null && osmType != null) { currentResult.osmId = new SimplePrimitiveId(Long.parseLong(osmId), OsmPrimitiveType.from(osmType)); } currentResult.placeId = Optional.ofNullable(atts.getValue("place_id")).filter(s -> !s.isEmpty()) .map(Long::parseLong).orElse(0L); data.add(currentResult); } } catch (NumberFormatException ex) { Logging.error(ex); // SAXException does not chain correctly throw new SAXException(ex.getMessage(), ex); } catch (NullPointerException ex) { // NOPMD Logging.error(ex); // SAXException does not chain correctly throw new SAXException(tr("Null pointer exception, possibly some missing tags."), ex); } } /** * Detect ending elements. */ @Override public void endElement(String namespaceURI, String localName, String qName) throws SAXException { if (description != null && "description".equals(qName)) { currentResult.description = description.toString(); description = null; } depth--; } /** * Read characters for description. */ @Override public void characters(char[] data, int start, int length) throws SAXException { if (description != null) { description.append(data, start, length); } } public List getResult() { return Collections.unmodifiableList(data); } } }