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}